From b5c5594ea7892c63e88095248c5e4ae2f16a95c1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 15:31:47 +0100 Subject: [PATCH 1/9] licences_switches answers the old field names of carried entries (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 39 bus-and-switch §37, §12 and the lead's answer C5 (hq 45ee2475). --- .../claude-licence-manager/cmd/tools/tools.go | 16 +++++++++++++++ .../cmd/tools/tools_test.go | 20 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/modules/claude-licence-manager/cmd/tools/tools.go b/modules/claude-licence-manager/cmd/tools/tools.go index 6f4e647fb..ed4f9e2d4 100644 --- a/modules/claude-licence-manager/cmd/tools/tools.go +++ b/modules/claude-licence-manager/cmd/tools/tools.go @@ -92,6 +92,21 @@ func (r *Reader) Tools() []stdio.Tool { } } +// carriedForm gives an entry carried over from the old table `audit` (§12) the fields of §37: the old manager wrote a +// switch as `consumer` (one), `licence` (the one moved to), `from` and `by`, and no `consumers` or `to`. +func carriedForm(e map[string]any) { + if _, ok := e["to"]; !ok { + if l, ok := e["licence"]; ok { + e["to"] = l + } + } + if _, ok := e["consumers"]; !ok { + if c, ok := e["consumer"]; ok { + e["consumers"] = []any{c} + } + } +} + // Switches are the audit's switches, newest first: of a licence (from or to it) and a consumer when named, none before // since, at most limit (§37). func (r *Reader) Switches(licence, consumer string, since time.Time, limit int) ([]map[string]any, error) { @@ -118,6 +133,7 @@ func (r *Reader) Switches(licence, consumer string, since time.Time, limit int) if json.Unmarshal(all[i].Value, &e) != nil { continue } + carriedForm(e) at, _ := time.Parse(time.RFC3339Nano, fmt.Sprint(e["at"])) if at.Before(since) { continue diff --git a/modules/claude-licence-manager/cmd/tools/tools_test.go b/modules/claude-licence-manager/cmd/tools/tools_test.go index 6df0bf48b..15c03e577 100644 --- a/modules/claude-licence-manager/cmd/tools/tools_test.go +++ b/modules/claude-licence-manager/cmd/tools/tools_test.go @@ -229,6 +229,26 @@ func TestSwitchesAnswersBothCauses(t *testing.T) { } } +// design 39 bus-and-switch §37, §12: a switch carried over from the old table keeps the old field names (consumer, +// licence) and is answered with `to` and `consumers`, and found by them. +func TestSwitchesAnswersCarriedEntries(t *testing.T) { + rt := newRuntime(t) + rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-11T21:10:33Z", "consumer": "novox", + "licence": "d", "from": "c", "by": "jschoubben@novox.be"}) + r := rt.reader() + for _, args := range []map[string]any{nil, {"consumer": "novox"}, {"licence": "d"}} { + got := run(t, r, "licences_switches", args).([]any) + if len(got) != 1 { + t.Fatalf("%v: %v", args, got) + } + e := got[0].(map[string]any) + if e["to"] != "d" || e["from"] != "c" || e["by"] != "jschoubben@novox.be" || + len(e["consumers"].([]any)) != 1 || e["consumers"].([]any)[0] != "novox" { + t.Fatalf("%v: %v", args, e) + } + } +} + // design 39 bus-and-switch §40/AC1, B18, design 39 parts §3: a value set at node level is answered with its level, the // rest with the default, each with its value and default, and an invalid one saying why, read from the settings file; // mesh-config not answering is an error, never the default. From ee6d7c284da93a710f1e7d8582176b448bda12dc Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 15:40:10 +0100 Subject: [PATCH 2/9] The carried-switch test names no real node or address (review round 1) --- modules/claude-licence-manager/cmd/tools/tools_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/claude-licence-manager/cmd/tools/tools_test.go b/modules/claude-licence-manager/cmd/tools/tools_test.go index 15c03e577..0245e9895 100644 --- a/modules/claude-licence-manager/cmd/tools/tools_test.go +++ b/modules/claude-licence-manager/cmd/tools/tools_test.go @@ -233,17 +233,17 @@ func TestSwitchesAnswersBothCauses(t *testing.T) { // licence) and is answered with `to` and `consumers`, and found by them. func TestSwitchesAnswersCarriedEntries(t *testing.T) { rt := newRuntime(t) - rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-11T21:10:33Z", "consumer": "novox", - "licence": "d", "from": "c", "by": "jschoubben@novox.be"}) + rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-11T21:10:33Z", "consumer": "n1", + "licence": "d@example.org", "from": "c@example.org", "by": "a@example.org"}) r := rt.reader() - for _, args := range []map[string]any{nil, {"consumer": "novox"}, {"licence": "d"}} { + for _, args := range []map[string]any{nil, {"consumer": "n1"}, {"licence": "d@example.org"}} { got := run(t, r, "licences_switches", args).([]any) if len(got) != 1 { t.Fatalf("%v: %v", args, got) } e := got[0].(map[string]any) - if e["to"] != "d" || e["from"] != "c" || e["by"] != "jschoubben@novox.be" || - len(e["consumers"].([]any)) != 1 || e["consumers"].([]any)[0] != "novox" { + if e["to"] != "d@example.org" || e["from"] != "c@example.org" || e["by"] != "a@example.org" || + len(e["consumers"].([]any)) != 1 || e["consumers"].([]any)[0] != "n1" { t.Fatalf("%v: %v", args, e) } } From c41387f271ebf644962bbd12fe5139ad027399c5 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 15:44:50 +0100 Subject: [PATCH 3/9] S1 list-secret-place: mesh-files lists a place a secret is kept, entries only, marked secret-place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1409. A list of a place the mesh keeps a secret, or of a machine's credential places, at any depth, answers each entry's name, type, owner, group, mode, size and a link's target held to the content rule, never content, and says secret-place; a process's environ, mem, fd, map_files, root and cwd stay refused. Lines touched cite ADR 0394 §7. Builds: design 39 parts D3 (§15), bullets 2 and 5 --- modules/mesh-files/README.md | 2 +- .../mesh-files/cmd/mesh-files/files_test.go | 16 ++++-- modules/mesh-files/cmd/mesh-files/main.go | 3 +- modules/mesh-files/cmd/mesh-files/reads.go | 53 ++++++++++++++++--- .../mesh-files/cmd/mesh-files/reads_test.go | 14 ++++- modules/mesh-files/cmd/mesh-files/refuse.go | 8 ++- 6 files changed, 79 insertions(+), 17 deletions(-) diff --git a/modules/mesh-files/README.md b/modules/mesh-files/README.md index b2e492154..812a4b183 100644 --- a/modules/mesh-files/README.md +++ b/modules/mesh-files/README.md @@ -58,7 +58,7 @@ an override the override and the owned paths it went through. The caller is the ## What it refuses - A place a secret is kept (`MESH_SECRET_PLACES`, and the known credential files in `refuse.go`) is refused by - name for `read`, `list` and `search`. Copying or moving such a place is allowed: nothing is printed. + name for `read` and `search`; `list` answers its entries, never content, marked `secret-place`. Copying or moving such a place is allowed: nothing is printed. - A copy or move to or from `:/path` is refused: copying between nodes comes in step 2 (issue 903). - A write without a reason; a path that is not absolute; a mode that is not a mode. diff --git a/modules/mesh-files/cmd/mesh-files/files_test.go b/modules/mesh-files/cmd/mesh-files/files_test.go index d21eee723..027b54bb5 100644 --- a/modules/mesh-files/cmd/mesh-files/files_test.go +++ b/modules/mesh-files/cmd/mesh-files/files_test.go @@ -24,6 +24,7 @@ type fakeRoot struct { calls []string cat map[string]string out map[string]string // the answer to a whole call, "bin arg arg" + fail map[string]error // the error of a whole call err error } @@ -31,6 +32,9 @@ func (r *fakeRoot) Run(bin string, args ...string) ([]byte, error) { r.mu.Lock() defer r.mu.Unlock() r.calls = append(r.calls, bin+" "+strings.Join(args, " ")) + if e, ok := r.fail[bin+" "+strings.Join(args, " ")]; ok { + return nil, e + } if o, ok := r.out[bin+" "+strings.Join(args, " ")]; ok { return []byte(o), nil } @@ -699,11 +703,15 @@ func TestTheReadsFallBackToRoot(t *testing.T) { if g.root.called("/usr/bin/cat -- " + locked + "/.ssh/id_rsa") { t.Error("root read a credential file") } - // a secret place is refused by name, and root is never asked - g.root.calls = nil - if _, err := g.List(g.path("home/.ssh")); err == nil { - t.Error("a secret place was listed") + // a secret place is listed, as root lists it, its entries marked and never their content (ADR 0394 §7); a path + // that is not there under a directory the account may not enter is said missing (design 39 parts §15) + secretDir := locked + "/.ssh" + g.root.out["/usr/bin/find -H "+secretDir+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\x00"] = + "f\t1000\t1000\t600\t9\t\tid_rsa\x00" + if ls, err := g.List(secretDir); err != nil || ls.Secret == "" || len(ls.Entries) != 1 || ls.Entries[0].Name != "id_rsa" { + t.Errorf("a secret place was not listed through root: %+v %v", ls, err) } + g.root.calls = nil if _, err := g.Read(locked+"/.ssh/id_rsa", ReadAsk{}); err == nil || len(g.root.calls) != 0 { t.Errorf("a credential file was read: %v %v", err, g.root.calls) } diff --git a/modules/mesh-files/cmd/mesh-files/main.go b/modules/mesh-files/cmd/mesh-files/main.go index c6b7af439..2805919d7 100644 --- a/modules/mesh-files/cmd/mesh-files/main.go +++ b/modules/mesh-files/cmd/mesh-files/main.go @@ -287,7 +287,8 @@ func rawReadTools(r Reader) []stdio.Tool { func(a map[string]any) (any, error) { return r.Readlink(arg(a, "path")) }), verb("list", "Every entry of one directory with its type, owner, group, mode, size and, for a link, its "+ "target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is "+ - "refused by name.", + "listed too, at any depth, and marked secret-place: its entries, never their content; what reaches a "+ + "process's memory or files (/proc//environ, mem, fd, map_files, root, cwd) is refused by name.", object(map[string]any{"path": str("an absolute path to a directory")}, "path"), func(a map[string]any) (any, error) { return r.List(arg(a, "path")) }), verb("read", "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more "+ diff --git a/modules/mesh-files/cmd/mesh-files/reads.go b/modules/mesh-files/cmd/mesh-files/reads.go index db438d4db..754a2e8a0 100644 --- a/modules/mesh-files/cmd/mesh-files/reads.go +++ b/modules/mesh-files/cmd/mesh-files/reads.go @@ -100,6 +100,12 @@ var errRefusedByName = errors.New("refused by name") // (/proc/self/fd) is judged by name like any path asked. A deleted file, or one the kernel names no path for, is // refused. func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileInfo, error) { + return r.openJudgedIn(path, nofollow, dir, false) +} + +// openJudgedIn is openJudged where a list may open a place a secret is kept (listing it reads no content; +// novox/hq ADR 0394 §7): what reaches a process's memory or files stays refused. +func (r Reader) openJudgedIn(path string, nofollow, dir, listing bool) (*os.File, fs.FileInfo, error) { stat := os.Stat if nofollow { stat = os.Lstat @@ -136,9 +142,9 @@ func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileIn return nil, nil, fmt.Errorf("%s is refused: the kernel names no path for what was opened, so it cannot be "+ "judged by name (novox/hq ADR 0394 §5)", path) } - if why := r.Refused(real); why != "" { + if why := r.Refused(real); why != "" && !(listing && !reachesProcess(real)) { f.Close() - return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0394 §5): %w", path, real, why, errRefusedByName) + return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0394 §7): %w", path, real, why, errRefusedByName) } return f, info, nil } @@ -478,6 +484,9 @@ type ListAnswer struct { Count int `json:"count"` Cut string `json:"cut,omitempty"` Withheld []Withheld `json:"withheld,omitempty"` + // Secret says why a read under the place is refused, when the directory is a place a secret is kept: its + // entries are answered, never their content (novox/hq ADR 0394 §7). + Secret string `json:"secret-place,omitempty"` } // refusedRead is a refusal by name, before any read: the path asked and where it leads are both judged, so a @@ -494,17 +503,45 @@ func (r Reader) refusedRead(path string) error { return nil } +// listedPlace judges a directory asked to be listed, and where it leads: a place a secret is kept is listed +// (its entries, never content) and said so; what reaches a process's memory or files is refused by name +// (novox/hq ADR 0394 §7). +func (r Reader) listedPlace(path string) (string, error) { + var secret string + judge := func(p, how string) error { + why := r.Refused(p) + switch { + case why == "": + case reachesProcess(p): + return fmt.Errorf("%s%s is refused by name: %s (novox/hq ADR 0394 §7)", path, how, why) + case secret == "": + secret = why + } + return nil + } + if err := judge(path, ""); err != nil { + return "", err + } + if leads, err := filepath.EvalSymlinks(path); err == nil && leads != path { + if err := judge(leads, " leads to "+leads+", which"); err != nil { + return "", err + } + } + return secret, nil +} + func (r Reader) List(raw string) (ListAnswer, error) { path, err := absolute(raw) if err != nil { return ListAnswer{}, err } - if err := r.refusedRead(path); err != nil { + secret, err := r.listedPlace(path) + if err != nil { return ListAnswer{}, err } names := r.names() var all []Entry - dir, _, err := r.openJudged(path, false, true) + dir, _, err := r.openJudgedIn(path, false, true, true) switch { case err != nil && r.Root != nil && errors.Is(err, fs.ErrPermission): if all, err = r.listAsRoot(path, names); err != nil { @@ -540,15 +577,15 @@ func (r Reader) List(raw string) (ListAnswer, error) { } all = append(all, en) } - return r.finishList(path, all, len(entries)), nil + return r.finishList(path, all, len(entries), secret), nil } - return r.finishList(path, all, len(all)), nil + return r.finishList(path, all, len(all), secret), nil } // finishList bounds a list and holds each link's target to the content rule. total is how many entries the // directory holds. -func (r Reader) finishList(path string, all []Entry, total int) ListAnswer { - a := ListAnswer{Path: path, Count: total, Entries: []Entry{}} +func (r Reader) finishList(path string, all []Entry, total int, secret string) ListAnswer { + a := ListAnswer{Path: path, Count: total, Entries: []Entry{}, Secret: secret} if total > MaxEntries { a.Cut = fmt.Sprintf("cut at %d of %d entries (the bound of one list), in name order", MaxEntries, total) } diff --git a/modules/mesh-files/cmd/mesh-files/reads_test.go b/modules/mesh-files/cmd/mesh-files/reads_test.go index 162e5f720..5e5913d4c 100644 --- a/modules/mesh-files/cmd/mesh-files/reads_test.go +++ b/modules/mesh-files/cmd/mesh-files/reads_test.go @@ -85,8 +85,18 @@ func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) { t.Errorf("%s was read: %v", p, err) } } - if _, err := r.List(vault); err == nil || !strings.Contains(err.Error(), "refused by name") { - t.Errorf("the vault's directory was listed: %v", err) + // a list of a place a secret is kept answers its entries, never content, marked (ADR 0394 §7) + if a, err := r.List(vault); err != nil || a.Secret == "" || a.Count != 1 || a.Entries[0].Name != "ledger" { + t.Errorf("the vault's directory was not listed as a secret place: %+v %v", a, err) + } + if a, err := r.List(filepath.Join(vault, "ledger")); err != nil || a.Secret == "" || a.Count != 1 { + t.Errorf("beneath the vault's directory: %+v %v", a, err) + } + if a, err := r.List(filepath.Join(dir, "home/ops")); err != nil || a.Secret != "" { + t.Errorf("a plain directory is marked: %+v %v", a, err) + } + if _, err := r.List("/proc/self/fd"); err == nil || !strings.Contains(err.Error(), "refused by name") { + t.Errorf("a process's open files were listed: %v", err) } link := filepath.Join(dir, "tmp/innocent") write(t, filepath.Join(dir, "tmp/x"), "") diff --git a/modules/mesh-files/cmd/mesh-files/refuse.go b/modules/mesh-files/cmd/mesh-files/refuse.go index df2d55865..afee44679 100644 --- a/modules/mesh-files/cmd/mesh-files/refuse.go +++ b/modules/mesh-files/cmd/mesh-files/refuse.go @@ -7,7 +7,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/outward" ) -// What is refused by name, before any read (novox/hq ADR 0394 §5), and what an answer withholds (§4). +// What is refused by name, before any read (novox/hq ADR 0394 §7), and what an answer withholds (§4). // // **Two lists of places.** The places the mesh knows a secret is kept on this node — every module's own-secret // directory, every credential file and grant directory, the vault's directories — come from the controller, @@ -103,6 +103,12 @@ func (r Refuser) Refused(path string) string { return "" } +// reachesProcess says whether a path is an entry of a process that reaches its memory, environment or files. +func reachesProcess(path string) bool { + parts := strings.Split(strings.TrimPrefix(filepath.Clean(path), "/"), "/") + return len(parts) >= 3 && parts[0] == "proc" && procPrivate[parts[2]] +} + func within(path, place string) bool { return path == place || strings.HasPrefix(path, strings.TrimSuffix(place, "/")+"/") } From fdd33e12343d75d35d4480e6bad15cf29b96aeed Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 15:44:51 +0100 Subject: [PATCH 4/9] S2 stat-says-missing: mesh-files stat says a path root finds missing does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1409. A stat through root of a path that is not there answered that the file modes refuse it; it says it does not exist, so design 39 parts §15's "gone" can be read. Builds: design 39 parts D3 (§15), bullet 3 --- modules/mesh-files/cmd/mesh-files/files_test.go | 5 +++++ modules/mesh-files/cmd/mesh-files/reads.go | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/modules/mesh-files/cmd/mesh-files/files_test.go b/modules/mesh-files/cmd/mesh-files/files_test.go index 027b54bb5..73e6b9068 100644 --- a/modules/mesh-files/cmd/mesh-files/files_test.go +++ b/modules/mesh-files/cmd/mesh-files/files_test.go @@ -711,6 +711,11 @@ func TestTheReadsFallBackToRoot(t *testing.T) { if ls, err := g.List(secretDir); err != nil || ls.Secret == "" || len(ls.Entries) != 1 || ls.Entries[0].Name != "id_rsa" { t.Errorf("a secret place was not listed through root: %+v %v", ls, err) } + g.root.fail = map[string]error{"/usr/bin/stat -c " + statFormat + " -- " + in + "/gone": errors.New( + "sudo -n /usr/bin/stat: stat: cannot statx '" + in + "/gone': No such file or directory")} + if st, err := g.Stat([]string{in + "/gone"}, false); err != nil || !strings.Contains(st.Paths[0].Stopped, "does not exist") { + t.Errorf("a missing path under a closed directory: %+v %v", st, err) + } g.root.calls = nil if _, err := g.Read(locked+"/.ssh/id_rsa", ReadAsk{}); err == nil || len(g.root.calls) != 0 { t.Errorf("a credential file was read: %v %v", err, g.root.calls) diff --git a/modules/mesh-files/cmd/mesh-files/reads.go b/modules/mesh-files/cmd/mesh-files/reads.go index 754a2e8a0..fc9c67f3e 100644 --- a/modules/mesh-files/cmd/mesh-files/reads.go +++ b/modules/mesh-files/cmd/mesh-files/reads.go @@ -314,6 +314,10 @@ var rootTypes = map[string]string{"regular file": "file", "regular empty file": func (r Reader) componentAsRoot(p string, names accounts, mounts map[string]Mount) (Component, bool) { out, err := r.Root.Run("/usr/bin/stat", "-c", statFormat, "--", p) f := strings.Split(strings.TrimSpace(string(out)), "|") + if err != nil && strings.Contains(err.Error(), "No such file or directory") { + // root reached the place and it is not there: said as missing, not as refused by the file modes. + return Component{Path: p, Unreachd: why(fs.ErrNotExist)}, true + } if err != nil || len(f) != 6 { return Component{}, false } From 2a9827ce187c5991cba1fa1268b5b66cd7d09919 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 15:50:45 +0100 Subject: [PATCH 5/9] Review 1 of S1: list's served text ends with the account sentence only; a secret-shaped link target in a secret place is withheld MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1409. readTools no longer appends the refusal sentence to list. A row in TestSecretPlacesAreRefusedByNameBeforeAnyRead holds a link's target in a secret place to the content rule. S2's match on sudo's English message is left as it is: the root path sets no locale, and sudo's environment handling decides whether LC_ALL reaches stat. Builds: design 39 parts D3 (§15), bullets 2 and 4 --- modules/mesh-files/cmd/mesh-files/main.go | 8 +++++--- modules/mesh-files/cmd/mesh-files/reads_test.go | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/modules/mesh-files/cmd/mesh-files/main.go b/modules/mesh-files/cmd/mesh-files/main.go index 2805919d7..79d07b694 100644 --- a/modules/mesh-files/cmd/mesh-files/main.go +++ b/modules/mesh-files/cmd/mesh-files/main.go @@ -171,7 +171,9 @@ func object(props map[string]any, required ...string) map[string]any { // The descriptions of the reads are the controller's (internal/catalogue/files.go): the reads' own, with // where they are read as restated for the operator's account. -const asAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " + +const readsAs = "as the node's operator account, through sudo -n where its rights do not reach" + +const asAccount = readsAs + "; a place a " + "secret is kept, or a known credential file, is refused by name" func boolArg(a map[string]any, k string) bool { return arg(a, k) == "true" } @@ -266,7 +268,7 @@ func tools(f *Files) []stdio.Tool { func readTools(r Reader) []stdio.Tool { ts := rawReadTools(r) for i := range ts { - if !strings.Contains(ts[i].Description, asAccount) { + if !strings.Contains(ts[i].Description, readsAs) { ts[i].Description += " Read " + asAccount + "." } } @@ -288,7 +290,7 @@ func rawReadTools(r Reader) []stdio.Tool { verb("list", "Every entry of one directory with its type, owner, group, mode, size and, for a link, its "+ "target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is "+ "listed too, at any depth, and marked secret-place: its entries, never their content; what reaches a "+ - "process's memory or files (/proc//environ, mem, fd, map_files, root, cwd) is refused by name.", + "process's memory or files (/proc//environ, mem, fd, map_files, root, cwd) is refused by name. Read "+readsAs+".", object(map[string]any{"path": str("an absolute path to a directory")}, "path"), func(a map[string]any) (any, error) { return r.List(arg(a, "path")) }), verb("read", "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more "+ diff --git a/modules/mesh-files/cmd/mesh-files/reads_test.go b/modules/mesh-files/cmd/mesh-files/reads_test.go index 5e5913d4c..399098097 100644 --- a/modules/mesh-files/cmd/mesh-files/reads_test.go +++ b/modules/mesh-files/cmd/mesh-files/reads_test.go @@ -95,6 +95,21 @@ func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) { if a, err := r.List(filepath.Join(dir, "home/ops")); err != nil || a.Secret != "" { t.Errorf("a plain directory is marked: %+v %v", a, err) } + // a link in a secret place: the entry is answered, its target held to the content rule (ADR 0394 §8) + if err := os.Symlink(aToken, filepath.Join(vault, "ledger/tokenlink")); err != nil { + t.Fatal(err) + } + if a, err := r.List(filepath.Join(vault, "ledger")); err != nil || a.Secret == "" || len(a.Withheld) != 1 || + a.Withheld[0].At != "a link's target" || strings.Contains(a.Entries[0].Link+a.Entries[1].Link, aToken) { + t.Errorf("a secret-shaped target in a secret place: %+v %v", a, err) + } + // the served text of list says it lists a secret place, and no longer that it refuses one + for _, tl := range readTools(r) { + if tl.Name == "list" && (strings.Contains(tl.Description, "known credential file, is refused") || + !strings.HasSuffix(tl.Description, "where its rights do not reach.")) { + t.Errorf("the list text: %s", tl.Description) + } + } if _, err := r.List("/proc/self/fd"); err == nil || !strings.Contains(err.Error(), "refused by name") { t.Errorf("a process's open files were listed: %v", err) } From ab1eb0cb4781512c08aa5fe50ae4cbf2a6031aaf Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 15:51:02 +0100 Subject: [PATCH 6/9] Review 1 of S1: the served-text row finds the list tool by its full name; list's text is the contract's word for word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 39 parts D3 (§15), bullets 2 and 4. --- modules/mesh-files/cmd/mesh-files/main.go | 4 ++-- modules/mesh-files/cmd/mesh-files/reads_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/mesh-files/cmd/mesh-files/main.go b/modules/mesh-files/cmd/mesh-files/main.go index 79d07b694..68c6eb232 100644 --- a/modules/mesh-files/cmd/mesh-files/main.go +++ b/modules/mesh-files/cmd/mesh-files/main.go @@ -288,8 +288,8 @@ func rawReadTools(r Reader) []stdio.Tool { object(map[string]any{"path": str("an absolute path")}, "path"), func(a map[string]any) (any, error) { return r.Readlink(arg(a, "path")) }), verb("list", "Every entry of one directory with its type, owner, group, mode, size and, for a link, its "+ - "target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is "+ - "listed too, at any depth, and marked secret-place: its entries, never their content; what reaches a "+ + "target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept, at any depth "+ + "beneath it, is listed too and marked secret-place: its entries, never their content; what reaches a "+ "process's memory or files (/proc//environ, mem, fd, map_files, root, cwd) is refused by name. Read "+readsAs+".", object(map[string]any{"path": str("an absolute path to a directory")}, "path"), func(a map[string]any) (any, error) { return r.List(arg(a, "path")) }), diff --git a/modules/mesh-files/cmd/mesh-files/reads_test.go b/modules/mesh-files/cmd/mesh-files/reads_test.go index 399098097..bb94ef3d4 100644 --- a/modules/mesh-files/cmd/mesh-files/reads_test.go +++ b/modules/mesh-files/cmd/mesh-files/reads_test.go @@ -105,7 +105,7 @@ func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) { } // the served text of list says it lists a secret place, and no longer that it refuses one for _, tl := range readTools(r) { - if tl.Name == "list" && (strings.Contains(tl.Description, "known credential file, is refused") || + if strings.HasSuffix(tl.Name, ".list") && (strings.Contains(tl.Description, "known credential file, is refused") || !strings.HasSuffix(tl.Description, "where its rights do not reach.")) { t.Errorf("the list text: %s", tl.Description) } From 3bb8ace833d11070401dbc306627cfaf8fa3bee7 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 15:55:16 +0100 Subject: [PATCH 7/9] U6, U7 provisioner: a bounded retiring ask id, a refused ask said, the Composition comment corrected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retiring ask's id is made from the key's digest and the time (at most 64 characters), so a long key is asked. An ask the router's rules refuse is said in the key's why and in provisioner_health. The Composition comment says what the code does: an unpublished module keeps only its own plan and keys. Builds: design 47 fix-unpublished-placed-module U6, U7, ADR 0593 §8 --- .../cmd/mesh-provisioner/holder.go | 60 ++++++++++++++++--- .../cmd/mesh-provisioner/holder_test.go | 36 +++++++++++ .../cmd/mesh-provisioner/holdertools.go | 11 ++-- 3 files changed, 94 insertions(+), 13 deletions(-) diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go index bd7519586..7cb0c03ba 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go @@ -28,6 +28,8 @@ package main import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -110,8 +112,8 @@ type Composer func(ctx context.Context, node string, keptAt map[string]string) C // Composition is a node's plans at its wanted builds and at its newest, and where the time went. type Composition struct { AtWants, Newest HeldPlans - // Unpublished is each module whose composition at the build it is read at is not published yet: the node's picture - // is not whole, so no retirement is judged on it (issue 940). + // Unpublished is each module whose composition at the build it is read at is not published yet: its own plan and keys + // are kept as they were, and nothing else on the node waits for it (issue 940). Unpublished []string // UnpublishedNewest is each module whose composition at its newest build (which is not its wanted build) is not // published yet: the newest build's needs are unread, so no key only that build requires is judged (issue 1966). @@ -1102,6 +1104,25 @@ const askHeldWithin = time.Minute // askWaits is what a retiring key's why says while its ask waits for an open one to end. const askWaits = "its ask waits until fewer than 3 of mesh-provisioner's asks stand open, the router showing at most 3 per asker" +// askRefusedNote opens what a retiring key's why says once its ask is refused: the ask is not raised, and the key says +// so here, and provisioner_health names the key with those words. +const askRefusedNote = "its ask is refused: " + +// askID is a retiring ask's id: letters, digits and - only, of bounded length (the router refuses over 64), made from the +// key by its digest and the time, so one key's asks differ and no key's length matters. +func askID(key string, at time.Time) string { + sum := sha256.Sum256([]byte(key)) + return "retire-" + hex.EncodeToString(sum[:8]) + "-" + fmt.Sprint(at.Unix()) +} + +// withoutAskNotes is why without what ask() said of its ask (askWaits, askRefusedNote). +func withoutAskNotes(why string) string { + if i := strings.Index(why, askRefusedNote); i >= 0 { + why = why[:i] + } + return strings.TrimSuffix(strings.TrimSuffix(why, askWaits), "; ") +} + // errAskWaits is an ask held back by askOpenAtMost: no fault, raised once one ends. var errAskWaits = errors.New(askWaits) @@ -1127,21 +1148,26 @@ func (h *Holder) ask(p *meshprovisionerclient.Provision, key string) error { } if h.openAsks() >= askOpenAtMost { if !strings.Contains(p.Why, askWaits) { - p.Why = strings.TrimPrefix(p.Why+"; "+askWaits, "; ") + p.Why = strings.TrimPrefix(withoutAskNotes(p.Why)+"; "+askWaits, "; ") } return errAskWaits } now := h.Now().UTC() - id := "retire-" + strings.ReplaceAll(key, ".", "-") + "-" + fmt.Sprint(now.Unix()) + id := askID(key, now) a := retiringAsk(key, *p, now, id) - if err := occ.Check(a, now); err != nil { + // A refused ask is said in the key's why, where the operator reads why it stands, and on provisioner_health. + refused := func(err error) error { + p.Why = strings.TrimPrefix(withoutAskNotes(p.Why)+"; "+askRefusedNote+err.Error(), "; ") return err } + if err := occ.Check(a, now); err != nil { + return refused(err) + } if err := h.Asks.Publish(oc.AcceptSubjectAsk(Module), a, id); err != nil { - return fmt.Errorf("the retiring ask of %s could not be published: %w", key, err) + return refused(fmt.Errorf("the retiring ask of %s could not be published: %w", key, err)) } p.Ask = id - p.Why = strings.TrimSuffix(strings.TrimSuffix(p.Why, askWaits), "; ") + p.Why = withoutAskNotes(p.Why) return nil } @@ -1372,6 +1398,22 @@ func (h *Holder) Faults() map[string]string { return out } +// RefusedAsks is each retiring key whose ask the router's rules refuse, as its key with its why: read from the keys +// themselves, so a restart keeps it (ask). +func (h *Holder) RefusedAsks() map[string]string { + out := map[string]string{} + keys, _ := h.Store.Keys(ProvisionsState) + for _, k := range keys { + if e, err := h.Store.Get(ProvisionsState, k); err == nil && e != nil { + if p, err := meshprovisionerclient.Decode(e.Value); err == nil && p.State == meshprovisionerclient.Retiring && + p.Ask == "" && strings.Contains(p.Why, askRefusedNote) { + out[k] = p.Why + } + } + } + return out +} + // LastingHolds is each module held unpublished at one build for longer than unpublishedFor, as "." with // the hold's words, which name its build, and since when. func (h *Holder) LastingHolds() map[string]string { @@ -1546,8 +1588,8 @@ func orSaid(words ...string) string { // builds a module is read at the fingerprint keptAt names, else at the indexer's newest (FP2); at the newest, each placed // module at FP2. A key whose engine refused the module holds it with the engine's words; a key not published yet holds // it at its wanted build (its last plan kept, as for a refusal) and at its newest too (issue 1966), whose build the engine -// writes once it is done (ADR 0593 §6), and says it unpublished, so the pass judges no retirement on the node (issue -// 940). A placed module no build of which is known is held too. A plan absent from a key is a module that hands the +// writes once it is done (ADR 0593 §6), and says it unpublished, so only that module's own plan and keys are kept as they were and nothing else on the +// node waits for it (issue 940). A placed module no build of which is known is held too. A plan absent from a key is a module that hands the // provisioner nothing. func FromCompositions(s Store) Composer { return func(_ context.Context, node string, keptAt map[string]string) Composition { diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go index f1d67b485..63376d196 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "fmt" assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" meshseat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" @@ -35,6 +36,8 @@ type router struct { cancelled []string // failCancel is a router that refuses a cancel. failCancel bool + // refuses is a router whose rules refuse every ask, with these words. + refuses string } func (r *router) Publish(subject string, body any, id string) error { @@ -49,6 +52,9 @@ func (r *router) Publish(subject string, body any, id string) error { delete(r.records, c.ID) return nil } + if r.refuses != "" { + return errors.New(r.refuses) + } r.asked[id] = body.(oc.Ask) if !r.drop { r.records[id], _ = json.Marshal(map[string]any{"state": "open", "ask": body}) @@ -870,3 +876,33 @@ func TestAnAskTheRouterDoesNotHoldIsRaisedAgain(t *testing.T) { } } } + +// Issue 1966, U7 (design 47 fix-unpublished-placed-module): a retiring ask's id is bounded and made from the key, so a +// long key is asked; an ask the router refuses is said in the key's why and in provisioner_health, and the key is +// asked once the refusal ends. +func TestARetiringAskIsBoundedAndARefusedOneIsSaid(t *testing.T) { + m := newMesh() + long := "claude-licence-manager-ab" // with this key the old id "retire-g14-...-" is 65 characters + key := "g14." + long + ".postgres-database" + m.atWant["g14"] = map[string]catalogue.Plan{long: planOf(pgDB), "dunst": planOf(oidc)} + m.compose(t, "g14") + m.atWant["g14"] = map[string]catalogue.Plan{"dunst": planOf(oidc)} + m.r.refuses = "its id is refused" + c := m.compose(t, "g14") + p := m.state(t, key) + if p.Ask != "" || !strings.Contains(p.Why, askRefusedNote) || !strings.Contains(p.Why, "its id is refused") || !strings.Contains(strings.Join(c.Problems, " "), "its id is refused") { + t.Errorf("a refused ask: ask %q, why %q, problems %v", p.Ask, p.Why, c.Problems) + } + if a := m.h.health(); a.Healthy || !strings.Contains(a.Why, key) || !strings.Contains(a.Why, "its id is refused") { + t.Errorf("health with a refused ask: %+v", a) + } + m.r.refuses = "" + m.compose(t, "g14") + p = m.state(t, key) + if len(p.Ask) == 0 || len(p.Ask) > 64 || !occ.UsableID(p.Ask) || strings.Contains(p.Why, askRefusedNote) { + t.Errorf("the ask once the refusal ends: %q, why %q", p.Ask, p.Why) + } + if a := m.h.health(); !a.Healthy { + t.Errorf("health once the ask is raised: %+v", a) + } +} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holdertools.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holdertools.go index 778e488f2..227695642 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holdertools.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holdertools.go @@ -91,17 +91,17 @@ func holderTools(h *Holder) []stdio.Tool { "a pass held back a retirement of most of a node's consumers at once as a fault of the mesh (issue 940), " + "naming the node, the consumers and the likely cause, or while a module's composition has stayed " + "unpublished at one build, wanted or newest, for longer than 45 minutes (longer than a build takes), naming the module, " + - "the build and since when.", + "the build and since when, or while a retiring provision's ask is refused by the operator channel's rules, naming the key and the refusal.", Run: func(map[string]any) (any, error) { return h.health(), nil }}, } } // health is the holder's runtime health: unhealthy while a retirement is held back as a fault of the mesh. func (h *Holder) health() health.Answer { - faults, holds := h.Faults(), h.LastingHolds() - if len(faults)+len(holds) == 0 { + faults, holds, refused := h.Faults(), h.LastingHolds(), h.RefusedAsks() + if len(faults)+len(holds)+len(refused) == 0 { return health.Healthy("No pass holds back a retirement of most of a node's consumers, and no module's " + - "composition has stayed unpublished at one build for longer than " + unpublishedFor.String() + ".") + "composition has stayed unpublished at one build for longer than " + unpublishedFor.String() + ", and no retiring ask is refused.") } var said []string for _, n := range sortedKeys(faults) { @@ -110,6 +110,9 @@ func (h *Holder) health() health.Answer { for _, k := range sortedKeys(holds) { said = append(said, k+": "+holds[k]+"; its plan and its provisions are kept as they were.") } + for _, k := range sortedKeys(refused) { + said = append(said, k+" is retiring and "+refused[k]+".") + } return health.Unhealthy(strings.Join(said, " ")) } From 22bbb9c2c09f9416df1af32ded939892f806db0f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 16:32:48 +0100 Subject: [PATCH 8/9] Keep a check's answer within the steps state: leave out the taken versions A catalog check's answer of 264712 bytes was refused by the steps state (256 KiB per value) and its end written failed without it, so the checker lost the biggest member's answer and could not preview it. When a passed or failed end is refused as too large, finish writes it once more without the fetch's taken versions (fetch.packages, which no reader judges), the step's reason saying how many were left out, why, and that the module's journal names each; still too large, it ends failed as before. Quick fix for issue 1921 (note 91); the thorough fix is design 47 G A-290 (an answer over the bound kept as the object output.). Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../buildstep/digestshape_test.go | 66 +++++++++++++++++++ modules/mesh-build-go/buildstep/step.go | 48 ++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/modules/mesh-build-go/buildstep/digestshape_test.go b/modules/mesh-build-go/buildstep/digestshape_test.go index af9511d0d..9b2aedc12 100644 --- a/modules/mesh-build-go/buildstep/digestshape_test.go +++ b/modules/mesh-build-go/buildstep/digestshape_test.go @@ -12,6 +12,7 @@ import ( "time" sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + "git.novox.be/novox/mesh-sdk/go/kvlimit" ) // credentialField is the node runtime's rule for a state value (mesh-tools node-tools/internal/bus/state.go, novox/hq @@ -181,6 +182,71 @@ func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) { } } +// 1921 note 91: given a check's answer over what the steps state takes per value, the bulk of it the fetch's taken +// versions, when finish writes it, then the end is written passed without the taken versions, its levels and the rest of +// its fetch kept, the reason saying how many were left out and why; an over-large answer with no taken versions, and one +// still too large without them, end failed as before. +func TestAnAnswerOverTheStateBoundLeavesOutItsTakenVersions(t *testing.T) { + fastEnds(t) + answer := func(taken int) map[string]any { + var packages []map[string]any + for i := range taken { + packages = append(packages, map[string]any{"dir": fmt.Sprintf("modules/m%d", i), "package": "git.novox.be/novox/mesh-sdk/go", "said": "the ask names it"}) + } + return map[string]any{ + "fetch": map[string]any{"workspace": "/w", "changed": []string{"modules/m0/module.json"}, "packages": packages}, + "levels": []any{map[string]any{"level": "unit", "tests": []any{map[string]any{"passed": true}}}}, + } + } + for _, tc := range []struct { + name string + taken int + limit int + wantState string + }{ + {"its taken versions left out", 40, 1500, sdkstep.StepPassed}, + {"no taken versions to leave out", 0, 200, sdkstep.StepFailed}, + {"still too large without them", 40, 200, sdkstep.StepFailed}, + } { + t.Run(tc.name, func(t *testing.T) { + var stored []sdkstep.Step + put := func(key string, value any) (uint64, error) { + raw := value.(json.RawMessage) + if err := kvlimit.Check("the state steps", key, len(raw), int64(tc.limit)); err != nil && strings.Contains(string(raw), `"result"`) { + return 0, err + } + st, _ := sdkstep.DecodeStep(raw) + stored = append(stored, st) + return 1, nil + } + if err := finish(put, "shanks.a4", sdkstep.Step{ID: "a4", Tool: "check ask", Node: "shanks", State: sdkstep.StepRunning, Started: time.Now()}, answer(tc.taken), nil, t.Logf); err != nil { + t.Fatal(err) + } + if len(stored) != 1 || stored[0].State != tc.wantState { + t.Fatalf("stored %+v, want one end %s", stored, tc.wantState) + } + s := stored[0] + if tc.wantState == sdkstep.StepFailed { + if len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") { + t.Errorf("the end %+v, want the small failed end", s) + } + return + } + var a sdkstep.CheckAnswer + if err := json.Unmarshal(s.Result, &a); err != nil { + t.Fatal(err) + } + if len(a.Fetch.Packages) != 0 || a.Fetch.Workspace != "/w" || len(a.Fetch.Changed) != 1 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 { + t.Errorf("the stored answer %s, want its fetch and levels without the taken versions", s.Result) + } + if !strings.Contains(s.Reason, "the fetch's 40 taken versions are left out") || !strings.Contains(s.Reason, "journal names each") || + !strings.Contains(s.Reason, "bytes the bucket takes") { + t.Errorf("the reason %q, want how many were left out, why and where each is said", s.Reason) + } + }) + } +} + // 1930/AC2: given an ask whose end the state refuses, when it runs, then the ask is acknowledged only once its small // failed end is written; when no end can be written at all, it is given back, never acknowledged as done. func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) { diff --git a/modules/mesh-build-go/buildstep/step.go b/modules/mesh-build-go/buildstep/step.go index 47403fdfb..2bdeadc9f 100644 --- a/modules/mesh-build-go/buildstep/step.go +++ b/modules/mesh-build-go/buildstep/step.go @@ -10,10 +10,12 @@ import ( "errors" "fmt" "log" + "strings" "time" buildrun "git.novox.be/novox/mesh-delivery/sdk/buildrun" sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + "git.novox.be/novox/mesh-sdk/go/kvlimit" ) // Put writes one key of a state: the steps state.Put's shape (go/state.go). @@ -102,6 +104,24 @@ func finish(put Put, key string, step sdkstep.Step, result any, err error, logf if werr == nil { return nil } + if errors.Is(werr, kvlimit.ErrTooLarge) { + // A check's answer over what the state takes per value is written once more without the fetch's taken versions, + // which no reader judges, the reason saying so (issue 1921, note 91; the thorough fix is design 47 G A-290). + if smaller, left, ok := withoutTakenVersions(step.Result); ok { + less := step + less.Result = smaller + said := fmt.Sprintf("the fetch's %d taken versions are left out of the stored answer: the answer was over what the steps state takes per value (%v); the module's journal names each", left, werr) + less.Reason = strings.TrimPrefix(step.Reason+"; "+said, "; ") + if lerr := writeEnd(put, key, less); lerr == nil { + if logf != nil { + logf("the end of the step %s (%s) was written without its fetch's %d taken versions: %v", key, step.State, left, werr) + } + return nil + } else { + werr = lerr + } + } + } if logf != nil { logf("the end of the step %s (%s) could not be written in %d tries: %v", key, step.State, EndAttempts, werr) } @@ -149,6 +169,34 @@ func protect(ctx context.Context, run func(ctx context.Context) (any, error)) (r return run(ctx) } +// withoutTakenVersions is a check's answer raw without its fetch's taken versions (`fetch.packages`) and how many it +// held; ok is false when the answer holds none, so nothing would be smaller. +func withoutTakenVersions(raw json.RawMessage) (json.RawMessage, int, bool) { + var answer map[string]json.RawMessage + if len(raw) == 0 || json.Unmarshal(raw, &answer) != nil || answer["fetch"] == nil { + return nil, 0, false + } + var fetch map[string]json.RawMessage + if json.Unmarshal(answer["fetch"], &fetch) != nil { + return nil, 0, false + } + var taken []json.RawMessage + if json.Unmarshal(fetch["packages"], &taken) != nil || len(taken) == 0 { + return nil, 0, false + } + delete(fetch, "packages") + f, err := json.Marshal(fetch) + if err != nil { + return nil, 0, false + } + answer["fetch"] = f + out, err := json.Marshal(answer) + if err != nil { + return nil, 0, false + } + return out, len(taken), true +} + func write(put Put, key string, step sdkstep.Step) error { raw, err := sdkstep.EncodeStep(step) if err != nil { From c29ef0d95ea705f04cf3b085dc8400e06aaf63d0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 16:34:24 +0100 Subject: [PATCH 9/9] mesh-files: find writes the NUL itself, so a list or search through root runs at all (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live on novox 2026-11-12: node-files.list of a root-only directory answered 'fork/exec /usr/bin/sudo: invalid argument'. The kernel refuses a program argument that holds a NUL byte, and listAsRoot's -printf format and searchBelowAsRoot's ended in a literal \x00 since 5d98bcc0 (2026-10-16), so no list or search through root ever ran on a node. find writes \0 itself. The stand-in root in the tests refuses an argument holding a NUL, as exec does, so every root call is held to it; it found the search's instance. Builds: design 39 parts §15 and the lead's answer D3 (list through root of a place a secret is kept). --- modules/mesh-files/cmd/mesh-files/files_test.go | 12 +++++++++--- modules/mesh-files/cmd/mesh-files/reads.go | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/modules/mesh-files/cmd/mesh-files/files_test.go b/modules/mesh-files/cmd/mesh-files/files_test.go index 73e6b9068..aa809d1e3 100644 --- a/modules/mesh-files/cmd/mesh-files/files_test.go +++ b/modules/mesh-files/cmd/mesh-files/files_test.go @@ -32,6 +32,12 @@ func (r *fakeRoot) Run(bin string, args ...string) ([]byte, error) { r.mu.Lock() defer r.mu.Unlock() r.calls = append(r.calls, bin+" "+strings.Join(args, " ")) + for _, a := range append([]string{bin}, args...) { + if strings.ContainsRune(a, 0) { + // The kernel refuses a program argument holding a NUL byte, as exec does on a node. + return nil, fmt.Errorf("fork/exec /usr/bin/sudo: invalid argument") + } + } if e, ok := r.fail[bin+" "+strings.Join(args, " ")]; ok { return nil, e } @@ -672,7 +678,7 @@ func TestTheReadsFallBackToRoot(t *testing.T) { t.Errorf("readlink through root: %+v %v", rl, err) } // list - g.root.out["/usr/bin/find -H "+in+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\x00"] = + g.root.out["/usr/bin/find -H "+in+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\\0"] = "f\t1000\t998\t640\t2\t\tf\x00d\t1000\t1000\t755\t4096\t\tsub dir\x00l\t1000\t1000\t777\t1\tf\tl\x00" ls, err := g.List(in) if err != nil || ls.Count != 3 { @@ -689,7 +695,7 @@ func TestTheReadsFallBackToRoot(t *testing.T) { t.Errorf("entries: %+v", ls.Entries) } // search: the files are listed by find, the secret ones skipped by name, the rest read through cat - g.root.out["/usr/bin/find -H "+locked+" -maxdepth 5 -xdev -type f -printf %p\x00"] = + g.root.out["/usr/bin/find -H "+locked+" -maxdepth 5 -xdev -type f -printf %p\\0"] = in + "/f\x00" + locked + "/.ssh/id_rsa\x00" g.root.cat[in+"/f"] = "one\nneedle here\n" g.root.reach(in + "/f") @@ -706,7 +712,7 @@ func TestTheReadsFallBackToRoot(t *testing.T) { // a secret place is listed, as root lists it, its entries marked and never their content (ADR 0394 §7); a path // that is not there under a directory the account may not enter is said missing (design 39 parts §15) secretDir := locked + "/.ssh" - g.root.out["/usr/bin/find -H "+secretDir+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\x00"] = + g.root.out["/usr/bin/find -H "+secretDir+" -mindepth 1 -maxdepth 1 -printf %y\t%U\t%G\t%m\t%s\t%l\t%f\\0"] = "f\t1000\t1000\t600\t9\t\tid_rsa\x00" if ls, err := g.List(secretDir); err != nil || ls.Secret == "" || len(ls.Entries) != 1 || ls.Entries[0].Name != "id_rsa" { t.Errorf("a secret place was not listed through root: %+v %v", ls, err) diff --git a/modules/mesh-files/cmd/mesh-files/reads.go b/modules/mesh-files/cmd/mesh-files/reads.go index fc9c67f3e..e68452047 100644 --- a/modules/mesh-files/cmd/mesh-files/reads.go +++ b/modules/mesh-files/cmd/mesh-files/reads.go @@ -610,7 +610,7 @@ func (r Reader) finishList(path string, all []Entry, total int, secret string) L // a name cannot confuse it: the fields end with a NUL, and the name is the last. func (r Reader) listAsRoot(path string, names accounts) ([]Entry, error) { out, err := r.Root.Run("/usr/bin/find", "-H", path, "-mindepth", "1", "-maxdepth", "1", "-printf", - "%y\t%U\t%G\t%m\t%s\t%l\t%f\x00") + "%y\t%U\t%G\t%m\t%s\t%l\t%f\\0") // find writes the NUL: an argument may not hold one if err != nil { return nil, fmt.Errorf("%s cannot be listed, not even through sudo: %v", path, err) } @@ -967,7 +967,7 @@ func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) { // and searches each: the names are judged first, so no credential file is read. func (r Reader) searchBelowAsRoot(p string, depth int, needle string, fold bool, bound int, deadline time.Time, a *SearchAnswer) error { out, err := r.Root.Run("/usr/bin/find", "-H", p, "-maxdepth", strconv.Itoa(depth), "-xdev", "-type", "f", - "-printf", "%p\x00") + "-printf", "%p\\0") // find writes the NUL: an argument may not hold one if err != nil { a.Unreadable++ return nil