diff --git a/checks/readable/go.mod b/checks/readable/go.mod new file mode 100644 index 00000000..6226f1c1 --- /dev/null +++ b/checks/readable/go.mod @@ -0,0 +1,5 @@ +module checks/readable + +go 1.26.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 diff --git a/checks/readable/go.sum b/checks/readable/go.sum new file mode 100644 index 00000000..f8bfbd2b --- /dev/null +++ b/checks/readable/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/checks/readable/main.go b/checks/readable/main.go new file mode 100644 index 00000000..8fa81fba --- /dev/null +++ b/checks/readable/main.go @@ -0,0 +1,333 @@ +// Command readable refuses a catalogue where a module reads another module's state that the owner does not announce +// readable (novox/hq ADR 0495 clause 3, issue 1790): the controller grants a read only from the owner's announcement, so +// such a read is refused on the live mesh, said only in the controller's journal. The sweep of 2026-10-30 (issue 774) +// found three: gitea's `bound` (read by mesh-provisioner, so no forge account was bound), disk-load's `readings` (read by +// restic, nfs-server and mounts) and restic's `good` (read by the self-check). The merge gate's check of reads against +// the owner's manifest went with clause 5, and announcetest holds a module to its own list, not to its readers. +// +// An owner announces from its code. A Go module announces when its code imports mesh-sdk's go/announce: every +// announce.State literal there is read, its Name a string, a constant of the package, or a constant of mesh-sdk this +// check knows (Known). A TypeScript module announces when its code says the event `states` or calls the SDK's keeps: +// every `{ name: "...", readable: true }` there is read. An owner that announces nothing from code is read as before +// on the mesh, and is not judged; neither is an owner outside the catalogue (another repository's). +// +// go run . +// +// Exits 1 on a finding. +package main + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +// Known are the mesh-sdk constants an announcement may name a state by, by the selector the code writes. +var Known = map[string]string{ + "health.GoodState": health.GoodState, + "health.ReadingsState": health.ReadingsState, +} + +const announcePkg = `"git.novox.be/novox/mesh-sdk/go/announce"` + +// Announced is what one owner's code announces: each state, readable or not. +type Announced map[string]bool + +// Findings is every refusal in the catalogue at root, sorted. +func Findings(root string) ([]string, error) { + manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + dirs := map[string]string{} + reads := map[string][]string{} + for _, path := range manifests { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var m struct { + Module string `json:"module"` + Reads []json.RawMessage `json:"reads"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + dirs[m.Module] = filepath.Dir(path) + for _, r := range m.Reads { + var s string + if json.Unmarshal(r, &s) == nil { + reads[m.Module] = append(reads[m.Module], s) + } + } + } + var out []string + owners := map[string]Announced{} + for reader, rs := range reads { + for _, r := range rs { + owner, state, ok := strings.Cut(r, ".") + dir, inCatalogue := dirs[owner] + if !ok || !inCatalogue || owner == reader { + continue + } + a, seen := owners[owner] + if !seen { + var problems []string + a, problems, err = AnnouncedBy(dir) + if err != nil { + return nil, err + } + owners[owner] = a + for _, p := range problems { + out = append(out, owner+": "+p) + } + } + if a == nil { + continue // announces nothing from code: read as before + } + readable, keeps := a[state] + switch { + case !keeps: + out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code does not announce: the read is refused on the mesh", reader, owner, state, owner)) + case !readable: + out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code announces not readable: the read is refused on the mesh; announce it readable", reader, owner, state, owner)) + } + } + } + sort.Strings(out) + return dedupe(out), nil +} + +func dedupe(in []string) []string { + var out []string + for i, s := range in { + if i == 0 || s != in[i-1] { + out = append(out, s) + } + } + return out +} + +var ( + tsAnnounces = regexp.MustCompile(`emit\(\s*"states"|\bkeeps\(`) + tsState = regexp.MustCompile(`\{\s*name:\s*"([a-z0-9][a-z0-9-]*)"\s*(,\s*readable:\s*(true|false)\s*)?\}`) +) + +// AnnouncedBy is what the module in dir announces from its code, nil when it announces nothing; problems are +// announcements whose state's name cannot be read. +func AnnouncedBy(dir string) (Announced, []string, error) { + var out Announced + var problems []string + pkgs := map[string][]*ast.File{} + fset := token.NewFileSet() + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if n := d.Name(); path != dir && (n == "node_modules" || n == "dist" || n == "test" || n == "testdata" || strings.HasPrefix(n, ".")) { + return filepath.SkipDir + } + return nil + } + name := d.Name() + switch { + case strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go"): + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return err + } + pkgs[filepath.Dir(path)] = append(pkgs[filepath.Dir(path)], f) + case strings.HasSuffix(name, ".ts") && !strings.HasSuffix(name, ".d.ts") && !strings.HasSuffix(name, ".test.ts"): + raw, err := os.ReadFile(path) + if err != nil { + return err + } + if !tsAnnounces.Match(raw) && !strings.Contains(string(raw), "KEPT") { + return nil + } + for _, m := range tsState.FindAllStringSubmatch(string(raw), -1) { + if out == nil { + out = Announced{} + } + out[m[1]] = out[m[1]] || m[3] == "true" + } + } + return nil + }) + if err != nil { + return nil, nil, err + } + for _, files := range pkgs { + imports := false + consts := map[string]ast.Expr{} + for _, f := range files { + for _, im := range f.Imports { + if im.Path.Value == announcePkg && (im.Name == nil || im.Name.Name == "announce") { + imports = true + } + } + for _, decl := range f.Decls { + g, ok := decl.(*ast.GenDecl) + if !ok || g.Tok != token.CONST { + continue + } + for _, spec := range g.Specs { + v := spec.(*ast.ValueSpec) + for i, n := range v.Names { + if i < len(v.Values) { + consts[n.Name] = v.Values[i] + } + } + } + } + } + if !imports { + continue + } + if out == nil { + out = Announced{} + } + for _, f := range files { + ast.Inspect(f, func(n ast.Node) bool { + lit, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + states := []*ast.CompositeLit{} + switch { + case isAnnounceState(lit.Type): + states = append(states, lit) + case isStateSlice(lit.Type): + for _, e := range lit.Elts { + if c, ok := e.(*ast.CompositeLit); ok && (c.Type == nil || isAnnounceState(c.Type)) { + states = append(states, c) + } + } + default: + return true + } + for _, s := range states { + name, readable, problem := stateOf(s, consts) + if problem != "" { + problems = append(problems, fmt.Sprintf("%s: %s", fset.Position(s.Pos()), problem)) + continue + } + out[name] = out[name] || readable + } + return false + }) + } + } + return out, problems, nil +} + +func isAnnounceState(e ast.Expr) bool { + s, ok := e.(*ast.SelectorExpr) + if !ok { + return false + } + x, ok := s.X.(*ast.Ident) + return ok && x.Name == "announce" && s.Sel.Name == "State" +} + +func isStateSlice(e ast.Expr) bool { + a, ok := e.(*ast.ArrayType) + return ok && isAnnounceState(a.Elt) +} + +// stateOf is one announce.State literal's name and whether it is readable (Readable true, or Readers named). +func stateOf(lit *ast.CompositeLit, consts map[string]ast.Expr) (string, bool, string) { + var name string + var readable bool + named := false + for _, e := range lit.Elts { + kv, ok := e.(*ast.KeyValueExpr) + if !ok { + return "", false, "an announce.State without field names cannot be read; name its fields" + } + key, _ := kv.Key.(*ast.Ident) + if key == nil { + continue + } + switch key.Name { + case "Name": + v, ok := resolve(kv.Value, consts, 0) + if !ok { + return "", false, fmt.Sprintf("the state's name %s cannot be read: name it by a string, a constant of the package, or a mesh-sdk constant in checks/readable's Known", exprString(kv.Value)) + } + name, named = v, true + case "Readable": + if id, ok := kv.Value.(*ast.Ident); ok { + readable = readable || id.Name == "true" + } + case "Readers": + readable = true + } + } + if !named { + return "", false, "an announce.State without a Name" + } + return name, readable, "" +} + +func resolve(e ast.Expr, consts map[string]ast.Expr, depth int) (string, bool) { + if depth > 8 { + return "", false + } + switch v := e.(type) { + case *ast.BasicLit: + if v.Kind == token.STRING { + s, err := strconv.Unquote(v.Value) + return s, err == nil + } + case *ast.Ident: + if c, ok := consts[v.Name]; ok { + return resolve(c, consts, depth+1) + } + case *ast.SelectorExpr: + s, ok := Known[exprString(v)] + return s, ok + } + return "", false +} + +func exprString(e ast.Expr) string { + switch v := e.(type) { + case *ast.Ident: + return v.Name + case *ast.SelectorExpr: + return exprString(v.X) + "." + v.Sel.Name + case *ast.BasicLit: + return v.Value + } + return fmt.Sprintf("%T", e) +} + +func main() { + root := "." + if len(os.Args) > 1 { + root = os.Args[1] + } + found, err := Findings(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + for _, f := range found { + fmt.Println(f) + } + if len(found) > 0 { + os.Exit(1) + } +} diff --git a/checks/readable/main_test.go b/checks/readable/main_test.go new file mode 100644 index 00000000..3a15e322 --- /dev/null +++ b/checks/readable/main_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A read of another module's state is refused here when the owner's code announces that state not readable, or not at +// all (the sweep of 2026-10-30, issue 774); an owner that announces nothing from code, or lives in another repository, +// is not judged. +func TestFindings(t *testing.T) { + const goOwner = "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/announce\"\n\n" + for _, row := range []struct { + name string + files map[string]string + want string // empty: clean + }{ + {"go, readable by a constant", map[string]string{ + "modules/r/module.json": `{"module":"r","reads":["o.s"]}`, + "modules/o/module.json": `{"module":"o"}`, + "modules/o/cmd/o/main.go": goOwner + "const S = \"s\"\n\nvar _ = announce.State{Name: S, Readable: true}\n", + "modules/o/cmd/o/main_test.go": goOwner + "var _ = announce.State{Name: \"s\"}\n"}, ""}, + {"go, not readable", map[string]string{ + "modules/r/module.json": `{"module":"r","reads":["o.s"]}`, + "modules/o/module.json": `{"module":"o"}`, + "modules/o/cmd/o/main.go": goOwner + "var Kept = []announce.State{{Name: \"t\", Readable: true}, {Name: \"s\"}}\n"}, + "r reads o.s, which o's code announces not readable"}, + {"go, a known mesh-sdk name and readers", map[string]string{ + "modules/r/module.json": `{"module":"r","reads":["o.good","o.s"]}`, + "modules/o/module.json": `{"module":"o"}`, + "modules/o/cmd/o/main.go": goOwner + "var _ = []announce.State{{Name: health.GoodState, Readable: true}, {Name: \"s\", Readers: []string{\"r\"}}}\n"}, ""}, + {"go, a name that cannot be read", map[string]string{ + "modules/r/module.json": `{"module":"r","reads":["o.s"]}`, + "modules/o/module.json": `{"module":"o"}`, + "modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: other.S, Readable: true}\n"}, + "the state's name other.S cannot be read"}, + {"go, a state not announced", map[string]string{ + "modules/r/module.json": `{"module":"r","reads":["o.s"]}`, + "modules/o/module.json": `{"module":"o"}`, + "modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: \"t\"}\n"}, + "r reads o.s, which o's code does not announce"}, + {"typescript, not readable", map[string]string{ + "modules/r/module.json": `{"module":"r","reads":["o.bound"]}`, + "modules/o/module.json": `{"module":"o"}`, + "modules/o/kept.ts": `export const KEPT = [{ name: "announced" }, { name: "bound" }];`}, + "r reads o.bound, which o's code announces not readable"}, + {"typescript, readable", map[string]string{ + "modules/r/module.json": `{"module":"r","reads":["o.bound"]}`, + "modules/o/module.json": `{"module":"o"}`, + "modules/o/index.ts": `await keeps([{ name: "bound", readable: true }]);`}, ""}, + {"an owner that announces nothing, and one outside the catalogue", map[string]string{ + "modules/r/module.json": `{"module":"r","reads":["o.s","mesh-controller.lease"]}`, + "modules/o/module.json": `{"module":"o"}`, + "modules/o/cmd/o/main.go": "package main\n\nvar _ = 1\n"}, ""}, + } { + t.Run(row.name, func(t *testing.T) { + root := t.TempDir() + for path, text := range row.files { + p := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + got, err := Findings(root) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(got, "\n") + if row.want == "" && len(got) != 0 || row.want != "" && !strings.Contains(joined, row.want) { + t.Errorf("findings %q, want %q", joined, row.want) + } + }) + } +} + +// The catalogue itself: every state a module reads of another module that announces from code is announced readable. +func TestTheCatalogueReadsOnlyWhatItsOwnersAnnounceReadable(t *testing.T) { + got, err := Findings(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + for _, f := range got { + t.Error(f) + } +} diff --git a/checks/readable/reads-outside b/checks/readable/reads-outside new file mode 100644 index 00000000..6cf17846 --- /dev/null +++ b/checks/readable/reads-outside @@ -0,0 +1,6 @@ +# What these tests read outside their package, as paths from the repository's root (issue 1936). +# every manifest, for what it reads; and every module's Go and TypeScript code, for the states it announces. +modules/*/module.json +modules/*/cmd +modules/*/*.go +modules/*/*.ts diff --git a/modules/disk-load/cmd/disk-load/main.go b/modules/disk-load/cmd/disk-load/main.go index 2eeff0b8..578539fc 100644 --- a/modules/disk-load/cmd/disk-load/main.go +++ b/modules/disk-load/cmd/disk-load/main.go @@ -24,7 +24,8 @@ import ( func logf(format string, args ...any) { fmt.Fprintf(os.Stderr, "[disk-load] "+format+"\n", args...) } func main() { - if err := stdio.Keeps(announce.State{Name: StateReadings}); err != nil { + // Readable: restic, nfs-server, mounts and the self-check read it (their manifests' `reads`); checks/readable holds it. + if err := stdio.Keeps(announce.State{Name: StateReadings, Readable: true}); err != nil { logf("%v", err) os.Exit(1) } diff --git a/modules/gitea/kept.ts b/modules/gitea/kept.ts index fccc5514..5f83f73a 100644 --- a/modules/gitea/kept.ts +++ b/modules/gitea/kept.ts @@ -1,10 +1,11 @@ // The states this module keeps on the bus and whether other modules may read them (novox/hq ADR 0495 clause 2; issue // 1790): the module says it itself, from its code, as it starts (index.ts calls keeps with this list), and a reader is -// granted what it said (clause 3). A state is readable when another module's manifest reads it; none reads gitea's. -// test/kept.test.ts holds the list to the manifest's `state`. +// granted what it said (clause 3). A state is readable when another module's manifest reads it: mesh-provisioner reads +// `bound` (the forge accounts it binds). test/kept.test.ts holds the list to the manifest's `state`, and the catalogue's +// checks/readable every state another manifest reads to a readable announcement. export const KEPT: readonly { readonly name: string; readonly readable?: boolean }[] = [ { name: "announced" }, - { name: "bound" }, + { name: "bound", readable: true }, ]; /** The announcement of these states, in the layout mesh-sdk's go/announce reads (novox/hq ADR 0495 clause 2): version 1, diff --git a/modules/gitea/test/kept.test.ts b/modules/gitea/test/kept.test.ts index 5702f8e1..f0ca9ec9 100644 --- a/modules/gitea/test/kept.test.ts +++ b/modules/gitea/test/kept.test.ts @@ -2,20 +2,21 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { readFileSync } from "node:fs"; -// 1790: the states the module announces are the states its manifest declares, no more, no fewer; none is readable -// because no other module's manifest reads one of them. +// 1790: the states the module announces are the states its manifest declares, no more, no fewer; `bound` is readable, +// because mesh-provisioner's manifest reads it (the sweep of 2026-10-30, issue 774: announced unreadable, the read was +// refused and no forge account was bound). test("the states the module announces are the states its manifest declares", async () => { const { KEPT } = await import("../kept.ts"); const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); const declared = (manifest.state as (string | { name: string })[]).map((s) => (typeof s === "string" ? s : s.name)).sort(); assert.deepEqual(KEPT.map((k) => k.name).sort(), declared); - assert.ok(KEPT.every((k) => !k.readable), "no other module's manifest reads a state of gitea"); + assert.deepEqual(KEPT.filter((k) => k.readable).map((k) => k.name), ["bound"], "bound alone is readable: mesh-provisioner reads it"); }); test("the announcement has the layout mesh-sdk's go/announce reads: version 1, the module, the node, the states sorted by name", async () => { const { statesAnnouncement } = await import("../kept.ts"); - assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound" }] }); - assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound" }] }); + assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound", readable: true }] }); + assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound", readable: true }] }); }); test("index.ts says the announcement as the event `states` before the tools are served", () => { diff --git a/modules/restic/cmd/restic-backups/main.go b/modules/restic/cmd/restic-backups/main.go index 20c0a76b..7da0a7cb 100644 --- a/modules/restic/cmd/restic-backups/main.go +++ b/modules/restic/cmd/restic-backups/main.go @@ -56,7 +56,8 @@ func main() { case "awake": counting(b) case "": - if err := stdio.Keeps(announce.State{Name: health.GoodState}); err != nil { + // Readable: the self-check reads it (its manifest's `reads`); checks/readable holds it. + if err := stdio.Keeps(announce.State{Name: health.GoodState, Readable: true}); err != nil { say("%v", err) os.Exit(1) }