Inventory on retained descriptors (plan Task 2, first slice): the fleet's pins (the pinned slash and a platform root per spelled directory, the parent of the first platform link's directory or of the spelled directory), the platform-link predicate on descriptors, strictTreeDBs walking Dirs with every link class named (may lead to a database, a link to a link, neither a file nor a directory, out of the tree by Within, cannot be looked at, a file in the tree skipped with its target, absent as churn), the pathname link look deleted; the owners tests keep their strings, three expectations move to the design's classes, and a link-class test covers each class and Astra 2's layout
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+516
−231
internal/ops/authoritybridge.go+246 −0internal/ops/linktarget_other.go+0 −26internal/ops/linktarget_unix.go+0 −59internal/ops/linktarget_unix_test.go+0 −50internal/ops/litestream_owners.go+119 −78internal/ops/litestream_owners_test.go+54 −18internal/ops/litestream_owners_unix_test.go+97 −0
CI passed — run details
diff --git a/internal/ops/authoritybridge.go b/internal/ops/authoritybridge.go| new file mode 100644 |
| index 000000000..f60078ed1 |
| --- /dev/null |
| +++ b/internal/ops/authoritybridge.go |
| @@ -0,0 +1,246 @@ |
| +package ops |
| + |
| +import ( |
| + "errors" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/authority" |
| +) |
| + |
| +// slashPath is where the pinned "/" is: "/" on a box. A test points it at |
| +// a directory whose ancestry it owns, since the platform walk requires |
| +// every component from the pinned slash down to be the platform's and a |
| +// developer's temp directory is not. |
| +var slashPath = "/" |
| + |
| +// authFacts is the seam the owners tests use to give entries owners by |
| +// name (withOwners), the same way factsOf is for pathname lookups; the |
| +// production value changes nothing. |
| +var authFacts = func(name string, f authority.Facts) authority.Facts { return f } |
| + |
| +// platformFacts is the platform rule's uid half for the authority package. |
| +func platformFacts(f authority.Facts) bool { return platformUID(f.UID) } |
| + |
| +// fleetPins is the pinned "/" and the platform roots a fleet's inventory |
| +// walks from, built once per inventory and closed with it. A root is the |
| +// parent of the first platform link's directory in a spelled path, or the |
| +// parent of the spelled directory when it has none (the plan's rule), so |
| +// systemd's "../private/..." pops from the link's directory to the root |
| +// and no further. |
| +type fleetPins struct { |
| + slash *authority.Dir |
| + pins *authority.Pins |
| + roots map[string]*authority.Root |
| +} |
| + |
| +func newFleetPins() (*fleetPins, error) { |
| + slash, err := authority.Open(slashPath) |
| + if err != nil { |
| + return nil, err |
| + } |
| + p := &fleetPins{slash: slash, roots: map[string]*authority.Root{}} |
| + p.pins = &authority.Pins{Slash: slash, Platform: platformFacts} |
| + return p, nil |
| +} |
| + |
| +func (p *fleetPins) Close() { |
| + for _, r := range p.roots { |
| + r.Close() |
| + } |
| + p.slash.Close() |
| +} |
| + |
| +// comps splits a spelled absolute path into its components beneath the |
| +// pinned slash; a path not beneath it is not something the fleet can look at. |
| +func (p *fleetPins) comps(path string) ([]string, bool) { |
| + path = filepath.Clean(path) |
| + base := filepath.Clean(slashPath) |
| + if base != "/" { |
| + if path != base && !strings.HasPrefix(path, base+"/") { |
| + return nil, false |
| + } |
| + path = strings.TrimPrefix(path, base) |
| + } |
| + var out []string |
| + for _, c := range strings.Split(path, "/") { |
| + if c != "" { |
| + out = append(out, c) |
| + } |
| + } |
| + return out, true |
| +} |
| + |
| +// root is the pinned root at the joined components, built once; no |
| +// components means the pinned slash itself, pinned deliberately. |
| +func (p *fleetPins) root(comps []string) (*authority.Root, string) { |
| + key := strings.Join(comps, "/") |
| + if r, ok := p.roots[key]; ok { |
| + return r, "" |
| + } |
| + var r *authority.Root |
| + var err error |
| + if len(comps) == 0 { |
| + r, err = authority.SlashRoot(p.slash, platformFacts) |
| + } else { |
| + r, err = authority.OpenRoot(p.slash, key, platformFacts) |
| + } |
| + if err != nil { |
| + var ee *authority.EdgeError |
| + switch { |
| + case errors.As(err, &ee) && errors.Is(ee.Err, authority.ErrNotPlatform): |
| + return nil, "a directory in its path is not the platform's (it belongs to a user other than root or the platform's, or it can be written by its group or by anyone): " + ee.Name |
| + case errors.As(err, &ee) && errors.Is(ee.Err, authority.ErrLink): |
| + return nil, "a directory in its path is a link above where a link may be: " + ee.Name |
| + default: |
| + return nil, "a directory in its path cannot be looked at" |
| + } |
| + } |
| + // the same rule again by name, through the owners seam the tests use |
| + // (authFacts is the identity on a box): every component of the root's |
| + // chain, slash excluded, is the platform's and not group or other |
| + // writable |
| + for d := r.Dir; d != nil && d != p.slash; d = d.Parent() { |
| + if f := authFacts(d.Name(), d.Facts()); !platformUID(f.UID) || f.Mode&0o022 != 0 { |
| + r.Close() |
| + return nil, "a directory in its path is not the platform's (it belongs to a user other than root or the platform's, or it can be written by its group or by anyone): " + d.Name() |
| + } |
| + } |
| + p.roots[key] = r |
| + p.pins.Roots = append(p.pins.Roots, r) |
| + return r, "" |
| +} |
| + |
| +// walk is Walk with the pinned slash's spelling applied: on a box the |
| +// slash is "/" and an absolute target is walked as spelled; under a test's |
| +// pinned slash an absolute target beneath it is walked from there. |
| +func (p *fleetPins) walk(d *authority.Dir, dest string) (authority.Result, error) { |
| + if base := filepath.Clean(slashPath); base != "/" && filepath.IsAbs(dest) { |
| + if dest == base { |
| + dest = "/" |
| + } else if strings.HasPrefix(dest, base+"/") { |
| + dest = strings.TrimPrefix(dest, base) |
| + } |
| + } |
| + return d.Walk(dest, p.pins) |
| +} |
| + |
| +// tenantRoot acquires the directory a path spells, through the platform |
| +// walk to its root and then the platform-link predicate on descriptors |
| +// (exactly resolvePlatformLinks's rule: a link is followed when the link |
| +// and its directory are the platform's, its target holds no further link |
| +// and no ".." past its leading run, and the target's parent is the |
| +// platform's). The result is confined: a relative walk from it or beneath |
| +// it cannot leave it. The caller releases what was acquired with the |
| +// returned func; the root stays with the pins. The reason, when the |
| +// directory cannot be reached, is a string the inventory renders. |
| +func (p *fleetPins) tenantRoot(dir string) (*authority.Dir, func(), string) { |
| + comps, ok := p.comps(dir) |
| + if !ok { |
| + return nil, nil, "its directory is not beneath the pinned root" |
| + } |
| + var owned []interface{ Close() error } |
| + var results []*authority.Result |
| + release := func() { |
| + for i := len(results) - 1; i >= 0; i-- { |
| + results[i].Close() |
| + } |
| + for i := len(owned) - 1; i >= 0; i-- { |
| + owned[i].Close() |
| + } |
| + } |
| + // the first link in the spelling decides the root: the parent of the |
| + // link's directory, or the parent of the spelled directory |
| + linkAt := -1 |
| + { |
| + cur := p.slash |
| + var probe []*authority.Dir |
| + for i, c := range comps { |
| + child, err := cur.Child(c, nil) |
| + if err != nil { |
| + var ee *authority.EdgeError |
| + if errors.As(err, &ee) && errors.Is(ee.Err, authority.ErrLink) { |
| + linkAt = i |
| + } |
| + break |
| + } |
| + probe = append(probe, child) |
| + cur = child |
| + } |
| + for i := len(probe) - 1; i >= 0; i-- { |
| + probe[i].Close() |
| + } |
| + } |
| + rootN := len(comps) - 1 |
| + if linkAt >= 0 { |
| + rootN = linkAt - 1 |
| + } |
| + if rootN < 0 { |
| + rootN = 0 |
| + } |
| + r, why := p.root(comps[:rootN]) |
| + if why != "" { |
| + return nil, nil, why |
| + } |
| + cur := r.Dir |
| + for _, c := range comps[rootN:] { |
| + child, err := cur.Child(c, nil) |
| + if err == nil { |
| + owned = append(owned, child) |
| + cur = child |
| + continue |
| + } |
| + var ee *authority.EdgeError |
| + if !errors.As(err, &ee) || !errors.Is(ee.Err, authority.ErrLink) { |
| + release() |
| + return nil, nil, "a directory in its path cannot be looked at" |
| + } |
| + // a link: the platform's, in the platform's directory, to a |
| + // directory beneath the platform's, through no further link |
| + if df := authFacts(cur.Name(), cur.Facts()); !platformUID(df.UID) || df.Mode&0o022 != 0 { |
| + release() |
| + return nil, nil, "a link in its path is in a directory that is not the platform's" |
| + } |
| + _, lf, lerr := cur.Lookat(c) |
| + if lerr != nil { |
| + release() |
| + return nil, nil, "a link in its path cannot be looked at" |
| + } |
| + if !platformUID(authFacts(c, lf).UID) { |
| + release() |
| + return nil, nil, "a link in its path is not the platform's" |
| + } |
| + target, rerr := cur.Readlink(c) |
| + if rerr != nil { |
| + release() |
| + return nil, nil, "a link in its path cannot be read" |
| + } |
| + res, werr := p.walk(cur, target) |
| + switch { |
| + case errors.Is(werr, authority.ErrAscentAfterName): |
| + release() |
| + return nil, nil, "a link in its path leads through another link: " + target + " steps up after a named component" |
| + case werr != nil: |
| + release() |
| + return nil, nil, "a link in its path leads through another link: " + target + " cannot be looked at" |
| + case res.Kind == authority.Link: |
| + res.Close() |
| + release() |
| + return nil, nil, "a link in its path leads through another link: " + target + " is a link" |
| + case res.Kind != authority.Directory: |
| + res.Close() |
| + release() |
| + return nil, nil, "a link in its path does not lead to a directory" |
| + } |
| + results = append(results, &res) |
| + if parent := res.Dir.Parent(); parent != nil { |
| + if pf := authFacts(parent.Name(), parent.Facts()); !platformUID(pf.UID) || pf.Mode&0o022 != 0 { |
| + release() |
| + return nil, nil, "a link in its path leads to a directory that is not under the platform's" |
| + } |
| + } |
| + cur = res.Dir |
| + } |
| + cur.Confine() |
| + return cur, release, "" |
| +} |
diff --git a/internal/ops/linktarget_other.go b/internal/ops/linktarget_other.go| deleted file mode 100644 |
| index f6cdced30..000000000 |
| --- a/internal/ops/linktarget_other.go |
| +++ /dev/null |
| @@ -1,26 +0,0 @@ |
| -//go:build !unix |
| - |
| -package ops |
| - |
| -import ( |
| - "os" |
| - "path/filepath" |
| -) |
| - |
| -type targetKind struct { |
| - dir, regular bool |
| -} |
| - |
| -// statTarget on a platform without fstatat: the replicator does not run |
| -// here (the header read refuses too); the concatenated spelling is looked |
| -// at for completeness. |
| -func statTarget(dir, dest string) (targetKind, error) { |
| - if !filepath.IsAbs(dest) { |
| - dest = dir + string(filepath.Separator) + dest |
| - } |
| - fi, err := os.Stat(dest) |
| - if err != nil { |
| - return targetKind{}, err |
| - } |
| - return targetKind{dir: fi.IsDir(), regular: fi.Mode().IsRegular()}, nil |
| -} |
diff --git a/internal/ops/linktarget_unix.go b/internal/ops/linktarget_unix.go| deleted file mode 100644 |
| index f3bcc5aa1..000000000 |
| --- a/internal/ops/linktarget_unix.go |
| +++ /dev/null |
| @@ -1,59 +0,0 @@ |
| -//go:build unix |
| - |
| -package ops |
| - |
| -import ( |
| - "os" |
| - "path/filepath" |
| - |
| - "golang.org/x/sys/unix" |
| -) |
| - |
| -// targetKind is what a link's target is, by one look at it: a directory, a |
| -// regular file, something else, or nothing (err is os.ErrNotExist then). |
| -type targetKind struct { |
| - dir, regular bool |
| -} |
| - |
| -// statTarget looks at a link's target as the link spells it, in this |
| -// process's mount namespace: an absolute target as it is; a relative one |
| -// relative to the directory the link is in, through a descriptor opened by |
| -// that directory's path at this moment (not the descriptor the walk listed |
| -// from: the directory is resolved again from its whole path, and a link in |
| -// any component of it is followed, as the tree's own root is reached |
| -// through one; a directory, or a link to one, put in its place between the |
| -// listing and this open is looked at instead, which can omit or misread |
| -// this one link for this one look, and so can change whether a build is |
| -// refused for it; no database is streamed or attributed through the link |
| -// either way: stated open as EE2), with |
| -// fstatat, so the kernel resolves every ".." physically, from wherever the |
| -// component before it led, and the argument handed to it is the target |
| -// alone, so the link's directory adds nothing to the length the kernel |
| -// sees (the target itself is bounded by what a link can hold). The open |
| -// asks for a directory and nothing else: a FIFO, a device or a file a |
| -// tenant swaps into the directory's place between the listing and this |
| -// look is refused by the kernel before any driver's open (ENOTDIR), and |
| -// nothing here can block on it or become its controlling terminal. The |
| -// link is never read again. |
| -func statTarget(dir, dest string) (targetKind, error) { |
| - if filepath.IsAbs(dest) { |
| - fi, err := os.Stat(dest) |
| - if err != nil { |
| - return targetKind{}, err |
| - } |
| - return targetKind{dir: fi.IsDir(), regular: fi.Mode().IsRegular()}, nil |
| - } |
| - fd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NONBLOCK|unix.O_NOCTTY|unix.O_CLOEXEC, 0) |
| - if err != nil { |
| - return targetKind{}, &os.PathError{Op: "open", Path: dir, Err: err} |
| - } |
| - defer unix.Close(fd) |
| - var st unix.Stat_t |
| - if err := unix.Fstatat(fd, dest, &st, 0); err != nil { |
| - if err == unix.ENOENT { |
| - return targetKind{}, os.ErrNotExist |
| - } |
| - return targetKind{}, &os.PathError{Op: "stat", Path: dest, Err: err} |
| - } |
| - return targetKind{dir: st.Mode&unix.S_IFMT == unix.S_IFDIR, regular: st.Mode&unix.S_IFMT == unix.S_IFREG}, nil |
| -} |
diff --git a/internal/ops/linktarget_unix_test.go b/internal/ops/linktarget_unix_test.go| deleted file mode 100644 |
| index 2af42969c..000000000 |
| --- a/internal/ops/linktarget_unix_test.go |
| +++ /dev/null |
| @@ -1,50 +0,0 @@ |
| -//go:build unix |
| - |
| -package ops |
| - |
| -import ( |
| - "errors" |
| - "os" |
| - "path/filepath" |
| - "testing" |
| - "time" |
| - |
| - "golang.org/x/sys/unix" |
| -) |
| - |
| -// The directory a relative target is looked at from is opened as a |
| -// directory and nothing else: a FIFO a tenant swaps into its place between |
| -// the listing and the look is refused at once (ENOTDIR), where a plain |
| -// open would wait for a writer that never comes and hang the watch and |
| -// Close behind it. (Review seventeen, Opus P2-1.) |
| -func TestStatTargetRefusesADirectorySwappedForAFIFO(t *testing.T) { |
| - root := platformDir(t) |
| - fifo := filepath.Join(root, "cache") |
| - if err := unix.Mkfifo(fifo, 0o666); err != nil { |
| - t.Fatal(err) |
| - } |
| - done := make(chan error, 1) |
| - go func() { |
| - _, err := statTarget(fifo, "live.sqlite") |
| - done <- err |
| - }() |
| - select { |
| - case err := <-done: |
| - // Refused by the open itself (O_DIRECTORY), not by the look through |
| - // a descriptor the open handed back: a device in the directory's |
| - // place would otherwise have had its driver's open run. |
| - var pe *os.PathError |
| - if !errors.Is(err, unix.ENOTDIR) || !errors.As(err, &pe) || pe.Op != "open" { |
| - t.Fatalf("a FIFO where the directory was: %v; want the open refused with ENOTDIR", err) |
| - } |
| - case <-time.After(5 * time.Second): |
| - t.Fatal("the look at a FIFO did not return: the open blocked") |
| - } |
| - // A directory still answers, by the target's own spelling. |
| - dir := filepath.Join(root, "u") |
| - writeFile(t, filepath.Join(dir, "live.sqlite")) |
| - k, err := statTarget(dir, "./live.sqlite") |
| - if err != nil || !k.regular || k.dir { |
| - t.Fatalf("a file through its directory: %+v %v", k, err) |
| - } |
| -} |
diff --git a/internal/ops/litestream_owners.go b/internal/ops/litestream_owners.go| index 22a161ef5..399c57234 100644 |
| --- a/internal/ops/litestream_owners.go |
| +++ b/internal/ops/litestream_owners.go |
| @@ -3,6 +3,7 @@ package ops |
| import ( |
| "errors" |
| "fmt" |
| + "github.com/carlosframework/platform/internal/authority" |
| "io" |
| "io/fs" |
| "os" |
| @@ -648,18 +649,27 @@ const strictTreeMaxDepth = 32 |
| // header; an empty file is passed over, it holds nothing); |
| // - a directory that cannot be read, and one deeper than |
| // strictTreeMaxDepth, is a problem; |
| -// - a link is never walked into or opened; it is classified by where it |
| -// leads (one Stat). One that is named like a database, or that leads |
| -// to a directory, is a problem: the tenant's own software would follow |
| -// it, so there may be a database behind it. One whose target cannot |
| -// be looked at (a permission refused, a dead mount) is a problem too. |
| -// One that leads to a regular file under any other name is reported |
| -// in skipped, with its target, so a database the tenant reaches |
| -// through a link of its own (current -> live.sqlite) is named to the |
| -// caller, never passed over, and a link re-pointed later reads as a |
| -// change to the running fleet and to the Accept list at the next |
| -// build alike (the target is quoted, and a link entry is compared |
| -// as written). The links tenant software makes in numbers (a venv's, a |
| +// - a link is never walked into or opened; it is classified by where its |
| +// captured spelling leads, walked on retained descriptors from the |
| +// directory that listed it (internal/authority: a relative target |
| +// cannot leave the tenant's root, an absolute one goes from the |
| +// pinned slash into a pinned root). One that is named like a |
| +// database, or that leads to a directory in the tree, is a problem: |
| +// the tenant's own software would follow it, so there may be a |
| +// database behind it. One whose target is itself a link is a problem |
| +// (a link to a link that may lead to a database: it is refused, not |
| +// followed). One that leads out of the tree (above the root, to |
| +// another root, through a component that is not the platform's) is a |
| +// problem (a link out of the tree). One that leads to something that |
| +// is neither a file nor a directory is a problem. One whose target |
| +// cannot be looked at (a permission refused, a dead mount) is a |
| +// problem too. One that leads to a regular file in the tree under any |
| +// other name is reported in skipped, with its target, so a database |
| +// the tenant reaches through a link of its own (current -> |
| +// live.sqlite) is named to the caller, never passed over, and a link |
| +// re-pointed later reads as a change to the running fleet and to the |
| +// Accept list at the next build alike (the target is quoted, and a |
| +// link entry is compared as written). The links tenant software makes in numbers (a venv's, a |
| // node_modules' .bin) are all named this way: whether one tenant's |
| // unaccepted links should refuse the whole box's next build, as they |
| // do today through Accept, or that tenant's databases alone, is to be |
| @@ -674,98 +684,129 @@ const strictTreeMaxDepth = 32 |
| // behind it); and FIFOs, sockets and devices, and links to them (not |
| // files; a database cannot be one). |
| func strictTreeDBs(root string) (dbs, skipped, problems []string) { |
| - var walk func(dir string, depth int, hidden bool) |
| - walk = func(dir string, depth int, hidden bool) { |
| - entries, err := os.ReadDir(dir) |
| + pins, err := newFleetPins() |
| + if err != nil { |
| + return nil, nil, []string{root + ": cannot be read"} |
| + } |
| + defer pins.Close() |
| + top, release, why := pins.tenantRoot(root) |
| + if why != "" { |
| + return nil, nil, []string{root + ": cannot be read (" + why + ")"} |
| + } |
| + defer release() |
| + var walk func(d *authority.Dir, dir string, depth int, hidden bool) |
| + walk = func(d *authority.Dir, dir string, depth int, hidden bool) { |
| + entries, err := d.List() |
| if err != nil { |
| - if depth > 0 && errors.Is(err, fs.ErrNotExist) { |
| - return |
| - } |
| problems = append(problems, dir+": cannot be read") |
| return |
| } |
| for _, e := range entries { |
| - name := e.Name() |
| + name := e.Name |
| p := filepath.Join(dir, name) |
| - fi, err := os.Lstat(p) |
| - if errors.Is(err, fs.ErrNotExist) { |
| - continue |
| - } |
| - if err != nil { |
| - problems = append(problems, p+": cannot be looked at") |
| - continue |
| - } |
| - switch { |
| - case fi.Mode()&os.ModeSymlink != 0: |
| - // Classified by where its target's spelling leads (one stat |
| - // of that spelling, as the kernel resolves it in this |
| - // process's namespace; the link is never walked into or |
| - // opened). |
| - // A link entry is named with its path and target, each |
| - // quoted behind a word no path begins with, so a link |
| - // re-pointed after it was accepted reads as a change and |
| - // no name, of a link or of a file, reads as another entry. |
| - dest, rerr := os.Readlink(p) |
| - if errors.Is(rerr, fs.ErrNotExist) || errors.Is(rerr, syscall.EINVAL) { |
| - continue // gone, or no longer a link, under the look: churn |
| - } |
| + switch e.Kind { |
| + case authority.Link: |
| + // Classified by where its captured spelling leads, walked |
| + // from the directory that listed it on retained descriptors |
| + // (relative from this directory and never above the tenant's |
| + // root; absolute from the pinned slash into a pinned root); |
| + // the link is never walked into or opened, and every class |
| + // is named: nothing disappears through an unnamed case. |
| + dest, rerr := d.Readlink(name) |
| if rerr != nil { |
| + var ee *authority.EdgeError |
| + if errors.As(rerr, &ee) && (errors.Is(ee.Err, syscall.ENOENT) || errors.Is(ee.Err, syscall.EINVAL)) { |
| + continue // gone, or no longer a link, under the look: churn |
| + } |
| problems = append(problems, p+": a link that cannot be read") |
| continue |
| } |
| - // The entry names the target as read, and the target is |
| - // classified by that very spelling (from the link's own |
| - // directory when relative, through a descriptor opened by |
| - // that directory's path, as a directory and nothing else: |
| - // statTarget), never by reading the link again: what the |
| - // entry says and what was looked at are one string, whatever |
| - // the link is re-pointed to meanwhile, and the kernel |
| - // resolves a ".." in it physically, in this process's mount |
| - // namespace (an isolated instance's own namespace, with its |
| - // private /tmp and read-only bind mounts, may resolve the |
| - // same spelling elsewhere or nowhere; stated as open). |
| - target, terr := statTarget(dir, dest) |
| - // A problem names the path first (nothing compares a |
| - // problem with an Accept list); a skipped link is the |
| - // injective entry. |
| + res, werr := pins.walk(d, dest) |
| + quoted := p + " -> " + strconv.Quote(dest) |
| switch { |
| - case sqliteName(name) || (terr == nil && target.dir): |
| - problems = append(problems, p+" -> "+strconv.Quote(dest)+": a link that may lead to a database") |
| - case terr != nil && !errors.Is(terr, fs.ErrNotExist): |
| - problems = append(problems, p+" -> "+strconv.Quote(dest)+": a link whose target cannot be looked at") |
| - case terr == nil && target.regular: |
| + case sqliteName(name): |
| + problems = append(problems, quoted+": a link that may lead to a database") |
| + case errors.Is(werr, authority.ErrAscentPastRoot), errors.Is(werr, authority.ErrOutside), errors.Is(werr, authority.ErrNotPlatform): |
| + problems = append(problems, quoted+": a link out of the tree") |
| + case werr != nil: |
| + problems = append(problems, quoted+": a link whose target cannot be looked at") |
| + case res.Kind == authority.Absent: |
| + // nothing behind it: churn |
| + case res.Kind == authority.Link: |
| + problems = append(problems, quoted+": a link to a link that may lead to a database") |
| + case res.Kind == authority.Other: |
| + problems = append(problems, quoted+": a link to something that is neither a file nor a directory") |
| + case !res.Within(top): |
| + problems = append(problems, quoted+": a link out of the tree") |
| + case res.Kind == authority.Directory: |
| + problems = append(problems, quoted+": a link that may lead to a database") |
| + default: // a regular file within the tree: named, never passed over |
| skipped = append(skipped, linkEntry(p, dest)) |
| } |
| - case fi.IsDir(): |
| + res.Close() |
| + case authority.Directory: |
| if depth+1 > strictTreeMaxDepth { |
| problems = append(problems, p+": too deep to walk") |
| continue |
| } |
| - walk(p, depth+1, hidden || strings.HasPrefix(name, ".") || depth+1 > treeDBWalkDepth) |
| - case !fi.Mode().IsRegular() || fi.Size() == 0: |
| - case strings.HasSuffix(name, ".db") && !hidden && !strings.HasPrefix(name, "."): |
| - dbs = append(dbs, p) |
| - case sqliteName(name): |
| - skipped = append(skipped, p) |
| - default: |
| - // Any other regular file is read for SQLite's header: a |
| - // database under some other name is a database, a sidecar |
| - // name (-wal, -shm, -journal) included, since a real |
| - // sidecar never begins with the database header. One that |
| - // cannot be read is a place a database could be. |
| - switch isDB, rerr := sqliteHeader(p, fi); { |
| - case rerr != nil: |
| + child, cerr := d.Child(name, &e.Facts) |
| + if cerr != nil { |
| + var ee *authority.EdgeError |
| + if errors.As(cerr, &ee) && (errors.Is(ee.Err, authority.ErrChanged) || errors.Is(ee.Err, authority.ErrLink) || errors.Is(ee.Err, syscall.ENOENT) || errors.Is(ee.Err, syscall.ENOTDIR)) { |
| + continue // replaced under the look: churn, the next look sees what is there |
| + } |
| problems = append(problems, p+": cannot be read") |
| - case isDB: |
| + continue |
| + } |
| + walk(child, p, depth+1, hidden || strings.HasPrefix(name, ".") || depth+1 > treeDBWalkDepth) |
| + child.Close() |
| + case authority.Regular: |
| + switch { |
| + case e.Facts.Size == 0: |
| + case strings.HasSuffix(name, ".db") && !hidden && !strings.HasPrefix(name, "."): |
| + dbs = append(dbs, p) |
| + case sqliteName(name): |
| skipped = append(skipped, p) |
| + default: |
| + // Any other regular file is read for SQLite's header: a |
| + // database under some other name is a database, a sidecar |
| + // name (-wal, -shm, -journal) included, since a real |
| + // sidecar never begins with the database header. One that |
| + // cannot be read is a place a database could be. |
| + switch isDB, rerr := sqliteHeaderAt(d, name, e.Facts); { |
| + case rerr != nil: |
| + problems = append(problems, p+": cannot be read") |
| + case isDB: |
| + skipped = append(skipped, p) |
| + } |
| } |
| } |
| } |
| } |
| - walk(root, 0, false) |
| + walk(top, filepath.Clean(root), 0, false) |
| return dbs, skipped, problems |
| } |
| +// sqliteHeaderAt is sqliteHeader through a retained directory: the file is |
| +// opened by name relative to it, without following a link and without |
| +// blocking, and must be the regular file the listing looked at. |
| +func sqliteHeaderAt(d *authority.Dir, name string, looked authority.Facts) (bool, error) { |
| + f, err := d.OpenRegular(name, looked) |
| + if err != nil { |
| + return false, err |
| + } |
| + defer f.Close() |
| + var head [16]byte |
| + n, err := io.ReadFull(f, head[:]) |
| + if err == io.EOF || err == io.ErrUnexpectedEOF { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + return n == len(head) && string(head[:]) == "SQLite format 3\x00", nil |
| +} |
| + |
| // sqliteName reports whether a file name is one SQLite databases go by, |
| // in any case. |
| func sqliteName(name string) bool { |
diff --git a/internal/ops/litestream_owners_test.go b/internal/ops/litestream_owners_test.go| index 86d5451c6..499a5cd39 100644 |
| --- a/internal/ops/litestream_owners_test.go |
| +++ b/internal/ops/litestream_owners_test.go |
| @@ -3,6 +3,7 @@ package ops |
| import ( |
| "os" |
| "path/filepath" |
| + "strconv" |
| "strings" |
| "testing" |
| @@ -26,9 +27,24 @@ func platformDir(t *testing.T) string { |
| if err := os.Chmod(dir, 0o755); err != nil { |
| t.Fatal(err) |
| } |
| + // the pinned slash for this test is the directory the test's temp |
| + // directories share (made 0700 by the testing package), since the |
| + // platform walk requires every component beneath the slash to be the |
| + // platform's and a developer's temp root is group-writable |
| + pinSlash(t, filepath.Dir(dir)) |
| return dir |
| } |
| +func pinSlash(t *testing.T, dir string) { |
| + t.Helper() |
| + if slashPath == dir { |
| + return |
| + } |
| + prev := slashPath |
| + slashPath = dir |
| + t.Cleanup(func() { slashPath = prev }) |
| +} |
| + |
| func writeFile(t *testing.T, p string) string { |
| t.Helper() |
| mkdirs(t, filepath.Dir(p)) |
| @@ -377,8 +393,12 @@ func TestFleetDBOwnersCheckTheFileAndItsSiblings(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, ok := ownedByPath(inv)[f.db]; ok || !named(inv.Unowned, f.db, tc.why) { |
| - t.Fatalf("owned %+v, unowned %v; want the database unowned, saying %q", inv.Owned, inv.Unowned, tc.why) |
| + // unowned with the reason, or, when the tree's own root fails |
| + // the platform walk (the directory above the tenant's), a |
| + // problem on the tree with the reason: either way the database |
| + // is not owned and the reason is said |
| + if _, ok := ownedByPath(inv)[f.db]; ok || !(named(inv.Unowned, f.db, tc.why) || named(inv.Problems, f.state, tc.why)) { |
| + t.Fatalf("owned %+v, unowned %v, problems %v; want the database unowned, saying %q", inv.Owned, inv.Unowned, inv.Problems, tc.why) |
| } |
| }) |
| } |
| @@ -655,13 +675,20 @@ func TestExplicitClaimsOutsideTheGlobsAreInventoried(t *testing.T) { |
| } |
| } |
| -// A link in a unit tree that leads to a regular file under a name that is |
| -// not a database's (current -> live.sqlite) is named to the caller as |
| -// skipped, never walked into or opened and never passed over. (Review |
| -// ten, Astra 8.) |
| +// A link in a unit tree that leads to a regular file in the tree under a |
| +// name that is not a database's (current -> live.sqlite) is named to the |
| +// caller as skipped, never walked into or opened and never passed over |
| +// (review ten, Astra 8). One that leads to a regular file outside the |
| +// tree is a problem, a link out of the tree, since the pinned-roots design: |
| +// the walk on retained descriptors does not follow a target out of the |
| +// tenant's root (design review twenty, Astra 5; the design's Part A). |
| func TestStrictTreeNamesALinkToAFile(t *testing.T) { |
| root := platformDir(t) |
| - target := writeFile(t, filepath.Join(platformDir(t), "elsewhere", "live.sqlite")) |
| + outside := writeFile(t, filepath.Join(platformDir(t), "elsewhere", "live.sqlite")) |
| + target := writeFile(t, filepath.Join(root, "store", "live.sqlite")) |
| + if err := os.Symlink(outside, filepath.Join(root, "away")); err != nil { |
| + t.Fatal(err) |
| + } |
| if err := os.Symlink(target, filepath.Join(root, "current")); err != nil { |
| t.Fatal(err) |
| } |
| @@ -669,8 +696,11 @@ func TestStrictTreeNamesALinkToAFile(t *testing.T) { |
| t.Fatal(err) |
| } |
| dbs, skipped, problems := strictTreeDBs(root) |
| - if len(dbs) != 0 || len(problems) != 0 || !contains(skipped, linkEntry(filepath.Join(root, "current"), target)) || containsPrefix(skipped, filepath.Join(root, "dangling")) { |
| - t.Fatalf("dbs %v skipped %v problems %v; want the link to a file skipped with its target and the dangling one ignored", dbs, skipped, problems) |
| + if len(dbs) != 0 || !contains(skipped, linkEntry(filepath.Join(root, "current"), target)) || containsPrefix(skipped, filepath.Join(root, "dangling")) { |
| + t.Fatalf("dbs %v skipped %v problems %v; want the link to a file in the tree skipped with its target and the dangling one ignored", dbs, skipped, problems) |
| + } |
| + if len(problems) != 1 || !named(problems, filepath.Join(root, "away"), "a link out of the tree") || containsPrefix(skipped, filepath.Join(root, "away")) { |
| + t.Fatalf("a link to a file outside the tree: skipped %v problems %v; want it a problem, out of the tree, and not skipped", skipped, problems) |
| } |
| // Re-pointed, the entry reads differently, to the running fleet's |
| // render and to the Accept list at the next build alike |
| @@ -678,7 +708,7 @@ func TestStrictTreeNamesALinkToAFile(t *testing.T) { |
| if err := os.Remove(filepath.Join(root, "current")); err != nil { |
| t.Fatal(err) |
| } |
| - other := writeFile(t, filepath.Join(platformDir(t), "elsewhere", "other.sqlite")) |
| + other := writeFile(t, filepath.Join(root, "store", "other.sqlite")) |
| if err := os.Symlink(other, filepath.Join(root, "current")); err != nil { |
| t.Fatal(err) |
| } |
| @@ -686,8 +716,9 @@ func TestStrictTreeNamesALinkToAFile(t *testing.T) { |
| if !contains(skipped, linkEntry(filepath.Join(root, "current"), other)) || contains(skipped, linkEntry(filepath.Join(root, "current"), target)) { |
| t.Fatalf("re-pointed: skipped %v; want the new target named", skipped) |
| } |
| - // A target with a ".." is quoted, not folded: the entry names exactly |
| - // the target as written. |
| + // A target with a ".." after a named component is refused by the walk |
| + // (the component before it could be made a link later), and named as |
| + // written, quoted, as a problem: the entry is never folded. |
| if err := os.Remove(filepath.Join(root, "current")); err != nil { |
| t.Fatal(err) |
| } |
| @@ -697,27 +728,32 @@ func TestStrictTreeNamesALinkToAFile(t *testing.T) { |
| if err := os.Symlink(odd, filepath.Join(root, "current")); err != nil { |
| t.Fatal(err) |
| } |
| - _, skipped, _ = strictTreeDBs(root) |
| - if !contains(skipped, linkEntry(filepath.Join(root, "current"), odd)) || contains(skipped, linkEntry(filepath.Join(root, "current"), other)) { |
| - t.Fatalf("a target spelt through ..: skipped %v; want it named as written", skipped) |
| + _, skipped, problems = strictTreeDBs(root) |
| + if !named(problems, filepath.Join(root, "current")+" -> "+strconv.Quote(odd), "cannot be looked at") || contains(skipped, linkEntry(filepath.Join(root, "current"), other)) || containsPrefix(skipped, filepath.Join(root, "current")) { |
| + t.Fatalf("a target spelt through ..: skipped %v problems %v; want it refused and named as written", skipped, problems) |
| } |
| // A target holding a line break is quoted too, and the file behind it |
| // is named as the one file it is. |
| - broken := writeFile(t, filepath.Join(platformDir(t), "elsewhere", "a\nb.sqlite")) |
| + broken := writeFile(t, filepath.Join(root, "store", "a\nb.sqlite")) |
| if err := os.Symlink(broken, filepath.Join(root, "newline")); err != nil { |
| t.Fatal(err) |
| } |
| _, skipped, _ = strictTreeDBs(root) |
| - if !contains(skipped, linkEntry(filepath.Join(root, "newline"), broken)) || strings.Contains(strings.Join(skipped, "|"), "\n") { |
| + if !contains(skipped, linkEntry(filepath.Join(root, "newline"), broken)) { |
| t.Fatalf("a target with a line break: skipped %q; want it quoted", skipped) |
| } |
| + for _, e := range skipped { |
| + if isLinkEntry(e) && strings.Contains(e, "\n") { |
| + t.Fatalf("a link entry with a raw line break: %q", e) |
| + } |
| + } |
| // A relative target with a ".." after a component that is itself a |
| // link to a directory outside the tree: the kernel steps up from where |
| // that link led, so the spelling is resolved as written, from the |
| // link's directory (fstatat on unix), never cleaned (a cleaned join |
| // would look beside the link and find nothing). (Review fifteen, Opus |
| // P3-1.) |
| - outside := platformDir(t) |
| + outside = platformDir(t) |
| mkdirs(t, filepath.Join(outside, "o"), filepath.Join(outside, "hidden", "sub"), filepath.Join(outside, "hidden", "dbs")) |
| if err := os.Symlink(filepath.Join(outside, "hidden", "sub"), filepath.Join(outside, "o", "lnk")); err != nil { |
| t.Fatal(err) |
diff --git a/internal/ops/litestream_owners_unix_test.go b/internal/ops/litestream_owners_unix_test.go| index 6603dd04f..2389ae6a7 100644 |
| --- a/internal/ops/litestream_owners_unix_test.go |
| +++ b/internal/ops/litestream_owners_unix_test.go |
| @@ -5,11 +5,13 @@ package ops |
| import ( |
| "os" |
| "path/filepath" |
| + "strconv" |
| "strings" |
| "syscall" |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/authority" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -32,6 +34,18 @@ func withOwners(t *testing.T, uids map[string]uint32) { |
| return f, ok |
| } |
| t.Cleanup(func() { factsOf = real }) |
| + realAuth := authFacts |
| + authFacts = func(name string, f authority.Facts) authority.Facts { |
| + key := name |
| + if f.Mode&syscall.S_IFMT == syscall.S_IFLNK { |
| + key = "link:" + key |
| + } |
| + if uid, set := uids[key]; set { |
| + f.UID = uid |
| + } |
| + return f |
| + } |
| + t.Cleanup(func() { authFacts = realAuth }) |
| } |
| // A unit tenant with a user of its own: its file's chain must be its own |
| @@ -559,3 +573,86 @@ func TestATenantsFileInAPlatformDirectoryIsRefusedByBothTenantClaims(t *testing. |
| t.Fatalf("the platform's own file: %q", why) |
| } |
| } |
| + |
| +// Every link class the pinned-roots design names is rendered as written |
| +// and none is silent: a directory target and a database-named link (may |
| +// lead to a database), a regular file in the tree (skipped, with its |
| +// target), a regular file under another pinned root and an ascent past the |
| +// tenant's root and an absolute target outside every pin (out of the |
| +// tree), a link to a link, a FIFO target (neither a file nor a directory), |
| +// a target through a ".." after a name (cannot be looked at), and an |
| +// absent target (churn). |
| +func TestStrictTreeNamesEveryLinkClass(t *testing.T) { |
| + plat := platformDir(t) |
| + root := filepath.Join(plat, "tenant") |
| + other := filepath.Join(plat, "other") |
| + mkdirs(t, filepath.Join(root, "d"), other, filepath.Join(root, "x")) |
| + inTree := writeFile(t, filepath.Join(root, "store", "live.sqlite")) |
| + theirs := writeFile(t, filepath.Join(other, "theirs.sqlite")) |
| + elsewhere := writeFile(t, filepath.Join(platformDir(t), "elsewhere", "e.sqlite")) |
| + if err := syscall.Mkfifo(filepath.Join(root, "fifo"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + links := map[string]string{ |
| + "to-dir": "d", |
| + "named.db": "store/live.sqlite", |
| + "current": "store/live.sqlite", |
| + "sibling": theirs, |
| + "ascent": "../other/theirs.sqlite", |
| + "far": elsewhere, |
| + "to-link": "current", |
| + "to-fifo": "fifo", |
| + "dotdot": "x/../store/live.sqlite", |
| + "dangling": "nothing", |
| + "abs-in-tree": inTree, |
| + } |
| + for name, target := range links { |
| + if err := os.Symlink(target, filepath.Join(root, name)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + dbs, skipped, problems := strictTreeDBs(root) |
| + if len(dbs) != 0 { |
| + t.Fatalf("dbs %v", dbs) |
| + } |
| + at := func(name string) string { return filepath.Join(root, name) + " -> " + strconv.Quote(links[name]) } |
| + for name, why := range map[string]string{ |
| + "to-dir": "a link that may lead to a database", |
| + "named.db": "a link that may lead to a database", |
| + "sibling": "a link out of the tree", |
| + "ascent": "a link out of the tree", |
| + "far": "a link out of the tree", |
| + "to-link": "a link to a link that may lead to a database", |
| + "to-fifo": "a link to something that is neither a file nor a directory", |
| + "dotdot": "a link whose target cannot be looked at", |
| + } { |
| + if !named(problems, at(name), why) { |
| + t.Errorf("%s: want %q among problems %v", name, why, problems) |
| + } |
| + } |
| + for _, name := range []string{"current", "abs-in-tree"} { |
| + if !contains(skipped, linkEntry(filepath.Join(root, name), links[name])) { |
| + t.Errorf("%s: want the link to a file in the tree skipped with its target: %v", name, skipped) |
| + } |
| + } |
| + if containsPrefix(problems, filepath.Join(root, "dangling")) || containsPrefix(skipped, filepath.Join(root, "dangling")) { |
| + t.Errorf("a dangling link is churn, not named: skipped %v problems %v", skipped, problems) |
| + } |
| + if len(problems) != 8 { |
| + t.Errorf("%d problems, want 8: %v", len(problems), problems) |
| + } |
| + // the files themselves, named as today |
| + for _, p := range []string{inTree} { |
| + if !contains(skipped, p) { |
| + t.Errorf("%s not in skipped %v", p, skipped) |
| + } |
| + } |
| + // Astra 2's layout: a tenant-owned component above the platform's |
| + // refuses the walk at the root, no tree is built over it |
| + withOwners(t, map[string]uint32{"owned": 44001}) |
| + mkdirs(t, filepath.Join(plat, "owned", "sub", "t")) |
| + _, _, problems = strictTreeDBs(filepath.Join(plat, "owned", "sub", "t")) |
| + if len(problems) != 1 || !named(problems, filepath.Join(plat, "owned", "sub", "t"), "not the platform's") { |
| + t.Fatalf("a tenant-owned component above the tree: %v", problems) |
| + } |
| +} |