// SPDX-License-Identifier: GPL-3.0-or-later // Package scan walks a directory tree and reports the files krino will // consider sorting, and why the rest were skipped. package scan import ( "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "time" "krino/internal/ignore" ) // File is a regular file found by Walk. type File struct { Path string // absolute, cleaned Rel string // slash-separated, relative to the root Name string // base name Size int64 ModTime time.Time Mode fs.FileMode } // Reason is why an entry was not returned as a File. type Reason int const ( Ignored Reason = iota // matched an ignore pattern Busy // a sibling NAME exists TooNew // modified less than MinAge ago Symlink // a symbolic link (never followed) NotRegular // fifo, socket, device Unreadable // a directory that could not be read TooBig // larger than MaxSize ) // String names a Reason the way it should read in a report. func (r Reason) String() string { switch r { case Ignored: return "ignored" case Busy: return "busy" case TooNew: return "too new" case Symlink: return "symlink" case NotRegular: return "not a regular file" case Unreadable: return "unreadable" case TooBig: return "too big" default: return fmt.Sprintf("Reason(%d)", int(r)) } } // Skipped is one entry Walk did not return as a File, and why. type Skipped struct { Rel string Reason Reason } // Options controls how Walk traverses a directory. type Options struct { Recursive bool MaxDepth int // 0: unlimited; 1: the root's own entries only Ignore *ignore.Matcher // nil: nothing ignored Exclude []string // absolute directories never entered Busy []string // suffixes, e.g. ".part" MinAge time.Duration MaxSize int64 // bytes; 0: no limit Now time.Time } // Result is everything Walk found under a root. type Result struct { Files []File // sorted by Rel Skipped []Skipped // sorted by Rel } // Walk lists the files under root that krino will consider, and reports why // the rest were skipped. root must exist, be a directory, and be readable; // an unreadable subdirectory found during the walk is reported as // Unreadable and does not abort the scan. func Walk(root string, opt Options) (*Result, error) { root, err := filepath.Abs(root) if err != nil { return nil, err } info, err := os.Stat(root) if err != nil { return nil, err } if !info.IsDir() { return nil, fmt.Errorf("%s is not a directory", root) } entries, err := os.ReadDir(root) if err != nil { return nil, err } exclude := make(map[string]bool, len(opt.Exclude)) for _, e := range opt.Exclude { exclude[filepath.Clean(e)] = true } w := &walker{root: root, opt: opt, exclude: exclude} if err := w.walk(root, "", 1, entries); err != nil { return nil, err } sort.Slice(w.result.Files, func(i, j int) bool { return w.result.Files[i].Rel < w.result.Files[j].Rel }) sort.Slice(w.result.Skipped, func(i, j int) bool { return w.result.Skipped[i].Rel < w.result.Skipped[j].Rel }) return &w.result, nil } // walker accumulates the Result across recursive calls. type walker struct { opt Options root string exclude map[string]bool result Result } // walk applies the skip checks to entries, the already-read contents of dir // (at relDir relative to the root, dir's own entries at depth). A // subdirectory it recurses into is read here, right before recursing, so a // ReadDir failure on it can be reported as Unreadable and skipped without // aborting the rest of the walk; only a failure reading dir itself (passed // in by the caller) would need to propagate, and only Walk's own read of // the root works that way. func (w *walker) walk(dir, relDir string, depth int, entries []os.DirEntry) error { // The set of names in this directory, built once, for the busy check. names := make(map[string]bool, len(entries)) for _, e := range entries { names[e.Name()] = true } for _, e := range entries { name := e.Name() rel := name if relDir != "" { rel = relDir + "/" + name } path := filepath.Join(dir, name) // A symlink is never followed, whatever it points to; DirEntry's // Type is Lstat-like and does not resolve it. if e.Type()&fs.ModeSymlink != 0 { w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Symlink}) continue } if e.IsDir() { // Directories themselves are not reported, with two // exceptions: the symlink case above, and an ignored // directory (C2) — reported once for the directory itself, // not for each file inside it, since pruning it without // descending is the whole point; without this, its contents // would appear in no count and in no -v listing at all. if !w.opt.Recursive { continue } if w.opt.Ignore != nil && w.opt.Ignore.Match(rel, true) { w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Ignored}) continue } if w.exclude[filepath.Clean(path)] { continue } if w.opt.MaxDepth > 0 && depth+1 > w.opt.MaxDepth { continue } subEntries, err := os.ReadDir(path) if err != nil { w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Unreadable}) continue } if err := w.walk(path, rel, depth+1, subEntries); err != nil { return err } continue } info, err := e.Info() if err != nil { if errors.Is(err, fs.ErrNotExist) { continue // vanished mid-walk (a download finishing, say): silently skipped } w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Unreadable}) continue } if !info.Mode().IsRegular() { w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: NotRegular}) continue } if w.opt.Ignore != nil && w.opt.Ignore.Match(rel, false) { w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Ignored}) continue } if busy := w.isBusy(name, names); busy { w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: Busy}) continue } if w.opt.Now.Sub(info.ModTime()) < w.opt.MinAge { w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: TooNew}) continue } if w.opt.MaxSize > 0 && info.Size() > w.opt.MaxSize { w.result.Skipped = append(w.result.Skipped, Skipped{Rel: rel, Reason: TooBig}) continue } w.result.Files = append(w.result.Files, File{ Path: path, Rel: rel, Name: name, Size: info.Size(), ModTime: info.ModTime(), Mode: info.Mode(), }) } return nil } // isBusy reports whether name+suffix, for any configured Busy suffix, is // among names — the sibling of an in-progress download. func (w *walker) isBusy(name string, names map[string]bool) bool { for _, suffix := range w.opt.Busy { if names[name+suffix] { return true } } return false }