// Package library models the two-tree media layout and the hardlink sync that // marks a title as kids ("cheeztv") content. // // Layout under the media root: // // fafflix/{tvseries,movies}//... source library // cheeztv/{tvseries,movies}/<title>/... kids tree (hardlinks into fafflix) // // Marking a title hardlinks every regular file across; unmarking removes only // the kids-side directory, which never destroys data because the source inode // keeps a link. package library import ( "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" ) // Tree names, relative to the media root. const ( SourceTree = "fafflix" KidsTree = "cheeztv" ) // Section is a library section directory shared by both trees. type Section string // The supported sections. const ( SectionTVSeries Section = "tvseries" SectionMovies Section = "movies" ) // Sections lists every valid section in display order. var Sections = []Section{SectionMovies, SectionTVSeries} // ErrInvalidSection is returned for a section outside Sections. var ErrInvalidSection = errors.New("invalid section") // ErrInvalidName is returned when a title name is not a single, clean path // element — the guard against path traversal out of the library roots. var ErrInvalidName = errors.New("invalid title name") // ErrNotFound is returned when a title has no source directory. var ErrNotFound = errors.New("title not found") // ParseSection validates s against the known sections. func ParseSection(s string) (Section, error) { for _, k := range Sections { if string(k) == s { return k, nil } } return "", fmt.Errorf("%w: %q", ErrInvalidSection, s) } // ValidateName rejects anything that is not a single, clean path element: // empties, dot entries, absolute paths, and any name containing a separator. // This is the sole traversal guard between an HTTP path segment and the // filesystem, so it is deliberately strict rather than sanitising. func ValidateName(name string) error { switch { case name == "": return fmt.Errorf("%w: empty", ErrInvalidName) case name == "." || name == "..": return fmt.Errorf("%w: dot entry", ErrInvalidName) // A backslash is a legal byte in a Linux filename, but it is a separator on // the platforms that write into these libraries; reject it rather than // reason about which side produced the name. case strings.ContainsRune(name, '/'), strings.ContainsRune(name, '\\'), strings.ContainsRune(name, os.PathSeparator): return fmt.Errorf("%w: contains a path separator", ErrInvalidName) case strings.ContainsRune(name, 0): return fmt.Errorf("%w: contains NUL", ErrInvalidName) case filepath.IsAbs(name): return fmt.Errorf("%w: absolute path", ErrInvalidName) case name != filepath.Clean(name): return fmt.Errorf("%w: not a clean path element", ErrInvalidName) } return nil } // Title is the reported state of one library title. type Title struct { Name string `json:"name"` // Marked is true when the kids tree has a directory of the same name. Marked bool `json:"marked"` // NeedsSync is true for a marked title whose source holds regular files // that are not linked into the kids tree (new episodes, upgrades). NeedsSync bool `json:"needsSync"` // SizeBytes and FileCount describe the source directory. SizeBytes int64 `json:"sizeBytes"` FileCount int `json:"fileCount"` // UnlinkedFiles counts the source files missing from the kids tree; zero // for an unmarked title. UnlinkedFiles int `json:"unlinkedFiles"` } // Library reads and mutates the two trees under Root. type Library struct { Root string } // New returns a Library rooted at the (cleaned) media root. func New(root string) *Library { return &Library{Root: filepath.Clean(root)} } // sourceRoot and kidsRoot are the per-section tree roots. func (l *Library) sourceRoot(s Section) string { return filepath.Join(l.Root, SourceTree, string(s)) } func (l *Library) kidsRoot(s Section) string { return filepath.Join(l.Root, KidsTree, string(s)) } // paths resolves the source and kids directories for a validated title, and // re-verifies containment: ValidateName already makes escape impossible, but // the check costs nothing and keeps the invariant local to the filesystem call. func (l *Library) paths(section Section, name string) (src, kids string, err error) { if _, err := ParseSection(string(section)); err != nil { return "", "", err } if err := ValidateName(name); err != nil { return "", "", err } src = filepath.Join(l.sourceRoot(section), name) kids = filepath.Join(l.kidsRoot(section), name) if !under(l.sourceRoot(section), src) || !under(l.kidsRoot(section), kids) { return "", "", fmt.Errorf("%w: escapes the library root", ErrInvalidName) } return src, kids, nil } // under reports whether path is root itself or lies beneath it. func under(root, path string) bool { rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) if err != nil { return false } return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) } // Ready reports whether the media root is visible, backing /readyz. func (l *Library) Ready() error { fi, err := os.Stat(l.Root) if err != nil { return err } if !fi.IsDir() { return fmt.Errorf("media root %q is not a directory", l.Root) } return nil } // List reports every title in a section, sorted case-insensitively by name. func (l *Library) List(section Section) ([]Title, error) { if _, err := ParseSection(string(section)); err != nil { return nil, err } entries, err := os.ReadDir(l.sourceRoot(section)) if err != nil { if os.IsNotExist(err) { return []Title{}, nil } return nil, err } out := make([]Title, 0, len(entries)) for _, e := range entries { if !e.IsDir() || ValidateName(e.Name()) != nil { continue } t, err := l.Stat(section, e.Name()) if err != nil { continue } out = append(out, t) } sort.Slice(out, func(i, j int) bool { li, lj := strings.ToLower(out[i].Name), strings.ToLower(out[j].Name) if li == lj { return out[i].Name < out[j].Name } return li < lj }) return out, nil } // Stat reports one title's size, file count, marked state and link drift. func (l *Library) Stat(section Section, name string) (Title, error) { src, kids, err := l.paths(section, name) if err != nil { return Title{}, err } fi, err := os.Stat(src) if err != nil { if os.IsNotExist(err) { return Title{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name) } return Title{}, err } if !fi.IsDir() { return Title{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name) } t := Title{Name: name} if kfi, err := os.Stat(kids); err == nil && kfi.IsDir() { t.Marked = true } err = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { if err != nil { // A vanished file mid-walk is normal on a live library; skip it // rather than failing the whole listing. if os.IsNotExist(err) { return nil } return err } if d.IsDir() || !d.Type().IsRegular() { return nil } info, err := d.Info() if err != nil { return nil } t.FileCount++ t.SizeBytes += info.Size() if !t.Marked { return nil } rel, err := filepath.Rel(src, p) if err != nil { return nil } if !sameFile(p, filepath.Join(kids, rel)) { t.UnlinkedFiles++ } return nil }) if err != nil { return Title{}, err } t.NeedsSync = t.Marked && t.UnlinkedFiles > 0 return t, nil } // sameFile reports whether both paths resolve to the same inode. func sameFile(a, b string) bool { ai, err := os.Lstat(a) if err != nil { return false } bi, err := os.Lstat(b) if err != nil { return false } return os.SameFile(ai, bi) } // SyncResult reports what a Mark run changed. type SyncResult struct { Linked int `json:"linked"` Replaced int `json:"replaced"` Unchanged int `json:"unchanged"` Skipped int `json:"skipped"` Dirs int `json:"dirs"` } // Mark hardlinks every regular file of a title into the kids tree, creating the // directory skeleton as it goes. It is idempotent: files already linked to the // same inode are left alone, a kids-side file pointing at a different inode is // replaced, and symlinks/devices/sockets are skipped. Re-running after new // episodes arrive syncs only the new files. func (l *Library) Mark(section Section, name string) (SyncResult, error) { src, kids, err := l.paths(section, name) if err != nil { return SyncResult{}, err } fi, err := os.Stat(src) if err != nil || !fi.IsDir() { return SyncResult{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name) } if err := os.MkdirAll(kids, 0o755); err != nil { return SyncResult{}, err } var res SyncResult walkErr := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { if err != nil { if os.IsNotExist(err) { return nil } return err } rel, err := filepath.Rel(src, p) if err != nil { return err } if rel == "." { return nil } dst := filepath.Join(kids, rel) if !under(kids, dst) { // Unreachable for a well-formed walk; belt-and-braces against a // crafted name surviving the entry guards. return fmt.Errorf("refusing to write outside the kids tree: %s", dst) } switch { case d.IsDir(): if err := os.MkdirAll(dst, 0o755); err != nil { return err } res.Dirs++ return nil case !d.Type().IsRegular(): res.Skipped++ return nil } return linkFile(p, dst, &res) }) if walkErr != nil { return res, walkErr } return res, nil } // linkFile hardlinks src to dst, replacing a stale link to a different inode. func linkFile(src, dst string, res *SyncResult) error { di, err := os.Lstat(dst) switch { case err == nil: si, serr := os.Lstat(src) if serr == nil && di.Mode().IsRegular() && os.SameFile(si, di) { res.Unchanged++ return nil } if err := os.Remove(dst); err != nil { return err } if err := os.Link(src, dst); err != nil { return err } res.Replaced++ return nil case !os.IsNotExist(err): return err } if err := os.Link(src, dst); err != nil { // A file that disappeared between the walk and the link is not fatal. if os.IsNotExist(err) { res.Skipped++ return nil } return err } res.Linked++ return nil } // Unmark removes a title's kids-tree directory. Only hardlinks live there, so // the source library is untouched. Removing an already-absent title succeeds. func (l *Library) Unmark(section Section, name string) error { _, kids, err := l.paths(section, name) if err != nil { return err } return os.RemoveAll(kids) }