package hostfiles import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "io/fs" "os" "path" "path/filepath" "slices" "sort" "strings" "github.com/carlosframework/platform/internal/release" ) // syncDir makes a directory's entries durable. A test seam. // // The open is descriptor-relative (os.Root) with restoreDirFlags: // O_DIRECTORY|O_NONBLOCK|O_NOFOLLOW on unix, so whatever was swapped in at // rel since realParents looked (a FIFO, a file) is refused at once rather // than waited on: a plain open of a FIFO with no writer never returns, and // no context can interrupt it. var syncDir = func(root *os.Root, rel string) error { d, err := root.OpenFile(filepath.FromSlash(rel), restoreDirFlags, 0) if err != nil { return err } defer d.Close() return d.Sync() } type RestoreStats struct { Written, Removed int Skipped []string // paths the manifest holds as present but unpublished Superseded []string // live paths not written: a live entry above or below them is newer } // Restore writes into dir what the manifest under prefix names and removes // what it tombstones: every tombstoned entry is removed, every published one // written, and the skipped ones reported. It does not delete files the // manifest does not name. It reads nothing but the manifest and the objects // the manifest names, and verifies each object against the hash the manifest // records. // // Every path is validated before anything is touched, so a manifest with // one bad path changes nothing. Every file operation goes through an // os.Root opened on dir, so nothing is written or removed outside it even // if dir already holds a symlink pointing elsewhere. // // When Restore returns nil, what it did is durable: each file was synced // before its rename, and every directory in which a name was created, // replaced or removed has been synced since. A caller may record the // installation as complete on that basis, and on no weaker one. // // origins are the generation prefixes whose objects this restore may read: // the set the caller's pin admits. nil or empty means prefix alone. Every // entry's object must be exactly "objects/-" for one // of them, with the entry's own Seq and SHA256; any other entry refuses the // whole manifest before anything is touched (ErrOrigin when the key is // well formed but its origin is not admitted). func Restore(ctx context.Context, s release.Store, prefix, dir string, origins []string) (Manifest, RestoreStats, error) { return restore(ctx, s, prefix, origins, func() (*os.Root, func(), error) { root, err := os.OpenRoot(dir) if err != nil { return nil, nil, fmt.Errorf("hostfiles: restore into %s: %w", dir, err) } return root, func() { root.Close() }, nil }) } // RestoreRoot is Restore into a directory the caller has already opened as // root, and does not close. os.OpenRoot resolves Restore's dir as a path, // following a link anywhere in it and blocking on a FIFO at its last // component; a caller restoring into ground someone else can write opens // the root itself without either (instancefs.OpenRootBeneath) and restores // here. Everything else is Restore's: the one implementation both share. func RestoreRoot(ctx context.Context, s release.Store, prefix string, root *os.Root, origins []string) (Manifest, RestoreStats, error) { if root == nil { return Manifest{}, RestoreStats{}, errors.New("hostfiles: restore into a nil root") } return restore(ctx, s, prefix, origins, func() (*os.Root, func(), error) { return root, func() {}, nil }) } // restore is Restore and RestoreRoot. open is called only once the // manifest is validated and names something to do, so a bad manifest // touches no directory and an empty one needs none. func restore(ctx context.Context, s release.Store, prefix string, origins []string, open func() (*os.Root, func(), error)) (Manifest, RestoreStats, error) { var st RestoreStats if err := checkPrefix(prefix); err != nil { return Manifest{}, st, err } origins, err := admitted(prefix, origins) if err != nil { return Manifest{}, st, err } m, _, _, err := load(ctx, s, prefix) if err != nil { return Manifest{}, st, err } if err := checkManifest(m, origins); err != nil { return Manifest{}, st, err } var live, dead []string for p, e := range m.Entries { if e.Deleted { dead = append(dead, p) } else { live = append(live, p) } } if len(live)+len(dead) == 0 { return m, st, nil } root, done, err := open() if err != nil { return Manifest{}, st, err } defer done() // Tombstones first, deepest first, so a directory that became a file // is emptied before the file is written over it. // Every directory on the way to a path the manifest speaks of, live or // tombstoned, is a directory whose entries this restore may have // changed: now, or in an earlier attempt that removed or created the // name and then failed before its sync. So the set does not depend on // what this call happened to find already done. touched := map[string]bool{".": true} mark := func(p string) { for d := path.Dir(p); d != "."; d = path.Dir(d) { touched[d] = true } } sort.Slice(dead, func(i, j int) bool { di, dj := strings.Count(dead[i], "/"), strings.Count(dead[j], "/") if di != dj { return di > dj } return dead[i] < dead[j] }) for _, p := range dead { mark(p) removed, err := removeInside(root, p) if err != nil { return Manifest{}, st, fmt.Errorf("hostfiles: remove %s: %w", p, err) } if removed { st.Removed++ } } sort.Strings(live) loser := superseded(m) for _, p := range live { if loser[p] { st.Superseded = append(st.Superseded, p) } } for _, p := range live { if loser[p] { continue } e := m.Entries[p] if e.Skipped != "" { st.Skipped = append(st.Skipped, p) continue } mark(p) body, err := fetch(ctx, s, e, origins) if err != nil { return Manifest{}, st, fmt.Errorf("hostfiles: %s: %w", p, err) } if err := writeInside(root, p, body, os.FileMode(e.Mode&0o777)); err != nil { return Manifest{}, st, fmt.Errorf("hostfiles: write %s: %w", p, err) } st.Written++ } // Sync the directories that survive, deepest first and the root last, // so a directory is durable before the entry that names it is. One // that no longer exists as a real directory (it was emptied and then // replaced by a file, or it sat under a path that became a file) has // nothing to sync: the directory above it records its going. dirs := make([]string, 0, len(touched)) for d := range touched { dirs = append(dirs, d) } sort.Slice(dirs, func(i, j int) bool { if (dirs[i] == ".") != (dirs[j] == ".") { return dirs[j] == "." } di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/") if di != dj { return di > dj } return dirs[i] < dirs[j] }) for _, d := range dirs { if d != "." { if ok, err := realParents(root, d+"/x"); err != nil { return Manifest{}, st, fmt.Errorf("hostfiles: sync directory %s: %w", d, err) } else if !ok { continue } } if err := syncDir(root, d); err != nil { return Manifest{}, st, fmt.Errorf("hostfiles: sync directory %s: %w", d, err) } } return m, st, nil } // superseded is the one overlap rule for live entries: a live file and a // live entry beneath it cannot both exist, so the newer one (higher Seq) // wins and the other is superseded; equal sequences keep the deeper one. // Restore leaves a superseded entry unwritten and Present does not look // for it, so what Restore produces is exactly what Present accepts // (stage B final review A1). Tombstones take no part. func superseded(m Manifest) map[string]bool { loser := map[string]bool{} for p, e := range m.Entries { if e.Deleted { continue } for a := path.Dir(p); a != "."; a = path.Dir(a) { above, ok := m.Entries[a] if !ok || above.Deleted { continue } if above.Seq > e.Seq { loser[p] = true } else { loser[a] = true } } } return loser } // ReadPlatform returns a platform-managed item's bytes, verified. A name // the manifest does not hold is os.ErrNotExist. prefix is where m was // loaded from; origins are as for Restore (nil or empty: prefix alone). func ReadPlatform(ctx context.Context, s release.Store, prefix string, m Manifest, name string, origins []string) ([]byte, error) { if err := checkPrefix(prefix); err != nil { return nil, err } origins, err := admitted(prefix, origins) if err != nil { return nil, err } e, ok := m.Platform[name] if !ok { return nil, fmt.Errorf("hostfiles: platform item %q: %w", name, os.ErrNotExist) } return fetch(ctx, s, e, origins) } // checkManifest validates every path and every entry before a caller acts // on any of them. func checkManifest(m Manifest, origins []string) error { for name, e := range m.Platform { if err := checkEntry(e, origins); err != nil { return fmt.Errorf("hostfiles: platform item %q: %w", name, err) } } for p, e := range m.Entries { if _, err := CleanPath(p); err != nil { return err } if err := withinTreeCap(p); err != nil { return err // no restore could recreate it: refused before anything is touched } if err := checkEntry(e, origins); err != nil { return fmt.Errorf("hostfiles: %s: %w", p, err) } } return nil } // checkEntry refuses a published entry whose object is not one this // package would have written for it in an admitted origin, or whose size // it could never have published. Tombstones and skipped entries name no // object. origins are already validated prefixes. func checkEntry(e Entry, origins []string) error { if e.Deleted || e.Skipped != "" { return nil } if len(e.SHA256) != 64 || strings.Trim(e.SHA256, "0123456789abcdef") != "" { return fmt.Errorf("sha256 %q is not 64 lowercase hex characters", e.SHA256) } if _, err := CleanPath(e.Object); err != nil { return fmt.Errorf("object %q is not a clean bucket key", e.Object) } want := objectKey(e.Seq, e.SHA256) origin, ok := strings.CutSuffix(e.Object, want) if !ok { return fmt.Errorf("object %q does not end in %q", e.Object, want) } if !slices.Contains(origins, origin) { return fmt.Errorf("%w: object %q lies under %q, not one of %q", ErrOrigin, e.Object, origin, origins) } if e.Size < 0 || e.Size > MaxFileBytes { return fmt.Errorf("size %d is outside 0 to %d", e.Size, MaxFileBytes) } return nil } // fetch reads e's object at exactly the key the entry names. func fetch(ctx context.Context, s release.Store, e Entry, origins []string) ([]byte, error) { if err := checkEntry(e, origins); err != nil { return nil, err } body, _, err := s.Get(ctx, e.Object) if err != nil { return nil, err } if got := sum(body); got != e.SHA256 { return nil, fmt.Errorf("object %s has sha256 %s, the manifest says %s", e.Object, got, e.SHA256) } if int64(len(body)) != e.Size { return nil, fmt.Errorf("object %s has size %d, the manifest says %d", e.Object, len(body), e.Size) } return body, nil } // realParents reports whether every ancestor of rel inside root is a real // directory: not a symlink, not a file, not missing. func realParents(root *os.Root, rel string) (bool, error) { parts := strings.Split(rel, "/") cur := "" for _, part := range parts[:len(parts)-1] { cur = path.Join(cur, part) fi, err := root.Lstat(filepath.FromSlash(cur)) if errors.Is(err, fs.ErrNotExist) { return false, nil } if err != nil { return false, err } if !fi.IsDir() { return false, nil } } return true, nil } // removeInside removes rel if it is a file of ours. A path whose ancestor // is a symlink, a file or missing is not ours to remove and is left alone: // that is how a tombstone for "a/b" behaves once "a" has become a file, // and why a symlinked ancestor never lets a delete reach outside root. func removeInside(root *os.Root, rel string) (bool, error) { ok, err := realParents(root, rel) if err != nil || !ok { return false, err } name := filepath.FromSlash(rel) fi, err := root.Lstat(name) if errors.Is(err, fs.ErrNotExist) { return false, nil } if err != nil { return false, err } if fi.IsDir() { // The path is a directory now: a later entry lives under it, and // the tombstone is for a file that no longer exists. return false, nil } if err := root.Remove(name); err != nil && !errors.Is(err, fs.ErrNotExist) { return false, err } return true, nil } // writeInside writes rel atomically inside root. Anything in the way that // is not a real directory (a file where a directory must be, a symlink) // is removed first, and a directory where the file must be is removed // with its contents: the manifest is the truth about this tree. func writeInside(root *os.Root, rel string, body []byte, mode os.FileMode) error { if mode == 0 { mode = 0o600 } parent := path.Dir(rel) if parent != "." { cur := "" for _, part := range strings.Split(parent, "/") { cur = path.Join(cur, part) fi, err := root.Lstat(filepath.FromSlash(cur)) if errors.Is(err, fs.ErrNotExist) { break } if err != nil { return err } if !fi.IsDir() { if err := root.Remove(filepath.FromSlash(cur)); err != nil { return err } break } } if err := root.MkdirAll(filepath.FromSlash(parent), 0o755); err != nil { return err } } name := filepath.FromSlash(rel) if fi, err := root.Lstat(name); err == nil && fi.IsDir() { if err := root.RemoveAll(name); err != nil { return err } } var rnd [8]byte if _, err := rand.Read(rnd[:]); err != nil { return err } tmp := filepath.FromSlash(path.Join(parent, ".carlos-restore-"+hex.EncodeToString(rnd[:]))) f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return err } done := false defer func() { if !done { f.Close() root.Remove(tmp) } }() if _, err := f.Write(body); err != nil { return err } if err := f.Chmod(mode); err != nil { return err } if err := f.Sync(); err != nil { return err } if err := f.Close(); err != nil { return err } if err := root.Rename(tmp, name); err != nil { return err } done = true return nil }