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"` }