1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
|
package bunny
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"tsne.dev/hopper/internal/log"
)
// Iterator is an old-school pull iterator over the recursive remote listing under
// a starting directory. It yields every entry (files AND directories; a
// directory has Size 0) so consumers can build whatever structure they need:
// push collects files and verifies no file collides with a remote directory;
// prune derives orphans and empty directories. Usage:
//
// it := client.List(ctx, dir)
// for it.Next() {
// rec := it.Object()
// }
// if err := it.Err(); err != nil { ... }
//
// The walk is iterative: a stack holds directories still to list, each listed
// on demand. A 404 listing is an empty subtree (skipped, not fatal); any other
// listing failure stops iteration and surfaces via Err.
type Iterator struct {
ctx context.Context
listDir func(context.Context, string) ([]Object, error)
stack []string
pending []Object
cur Object
err error
done bool
}
// NewIterator builds an Iterator rooted at dir, listing each directory via listDir.
func NewIterator(ctx context.Context, dir string, listDir func(context.Context, string) ([]Object, error)) *Iterator {
stack := make([]string, 0, 4)
stack = append(stack, dir)
return &Iterator{ctx: ctx, listDir: listDir, stack: stack}
}
// Next advances to the next entry, returning false when the walk is exhausted
// or a listing failed (check Err). Directory entries are yielded and also queued
// for descent.
func (it *Iterator) Next() bool {
if it.err != nil || it.done {
return false
}
for {
if len(it.pending) > 0 {
o := it.pending[0]
it.pending = it.pending[1:]
if o.IsDirectory {
it.stack = append(it.stack, o.Path)
}
it.cur = o
return true
}
if len(it.stack) == 0 {
it.done = true
return false
}
dir := it.stack[len(it.stack)-1]
it.stack = it.stack[:len(it.stack)-1]
objs, err := it.listDir(it.ctx, dir)
if err != nil {
var apiErr *apiError
if errors.As(err, &apiErr) && apiErr.status == http.StatusNotFound {
continue // 404 = empty subtree
}
it.err = err
return false
}
it.pending = objs
}
}
// Object returns the entry reached by the most recent Next.
func (it *Iterator) Object() Object {
return it.cur
}
// Err returns the listing failure that stopped iteration, if any.
func (it *Iterator) Err() error {
return it.err
}
// List returns an Iterator over the recursive listing rooted at dir. An empty
// dir denotes zone root.
func (api *API) List(ctx context.Context, dir string) *Iterator {
return NewIterator(ctx, dir, api.listDir)
}
// listDir returns the immediate children of dir. Bunny's list endpoint is
// per-directory and expects a trailing slash on the directory URL. Returned
// Object.Path is the full storage key relative to the zone (matching source
// keys), so the skip-check can match it.
func (api *API) listDir(ctx context.Context, dir string) ([]Object, error) {
if dir == "" {
log.PrintVerbose("listing zone root")
} else {
log.PrintVerbose("listing %s", dir)
}
u := api.url(dir)
if !strings.HasSuffix(u, "/") {
u += "/"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := api.do(ctx, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var raw []listObject
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, newAPIError(errorKindInternal, errors.New("failed to decode list response ("+err.Error()+")"), req, resp.StatusCode)
}
prefix := "/" + api.zone + "/"
objs := make([]Object, len(raw))
for i, o := range raw {
objs[i] = Object{
Path: strings.TrimPrefix(o.Path, prefix) + o.ObjectName,
Size: o.Length,
IsDirectory: o.IsDirectory,
LastChanged: parseTime(o.LastChanged),
}
}
return objs, nil
}
// listObject mirrors a single entry of Bunny's per-directory list response. The
// full storage key is Path+ObjectName (Path includes the zone and trailing
// slash); see List.
type listObject struct {
Path string `json:"Path"`
ObjectName string `json:"ObjectName"`
Length int `json:"Length"`
LastChanged string `json:"LastChanged"`
IsDirectory bool `json:"IsDirectory"`
}
|