280 lines
9.1 KiB
Go
280 lines
9.1 KiB
Go
// Command paths holds the catalogue's modules to talking over the bus (novox/hq ADR 0260): no module
|
|
// names a path that another module's manifest places under /run, /var/lib, /usr/local/lib or /etc.
|
|
// Such a path is that module's state or configuration on its machine, and a second module reading or
|
|
// writing it is two modules talking through a file, which the bus exists to replace.
|
|
//
|
|
// A module owns every `path` of its resources under those roots. Every other module's manifest, and
|
|
// every file of its own but its documentation, its tests and its binaries, is searched for each as a
|
|
// whole path: followed by nothing, a slash, or a character no path takes. A path a container sees
|
|
// inside itself (the target of a `volumes` entry) is the container's, and is not searched.
|
|
//
|
|
// **A drop-in directory is no module's.** A directory whose name ends in `.d` (`/etc/sysctl.d`,
|
|
// `/etc/systemd/system/<unit>.d`, `/etc/X11/xorg.conf.d`) is a place several modules each put their own
|
|
// file, so naming it is not reaching into one module's state; the files in it are each their placer's.
|
|
//
|
|
// Two lists beside this file narrow it, each line a path or a finding and its reason after a `#`:
|
|
//
|
|
// - shared: paths every program on a machine reads as the machine's, not as a message from their
|
|
// owner — /etc/hosts, which every resolver reads.
|
|
// - known: the findings standing on the day the roots were widened (2026-10-08). The check fails on a
|
|
// finding not listed, and on a listed one that no longer occurs, so the list only shrinks.
|
|
//
|
|
// **Neither list may grow unseen.** With `-base <dir>` holding the base branch's copies of both, a line
|
|
// the change adds is refused unless its reason says `allow:` and why — so an addition is a decision a
|
|
// reviewer reads in the diff, not a way past the check.
|
|
//
|
|
// **What it cannot see:** a path built in code from pieces (`filepath.Join("/etc", name)`), one read
|
|
// from the environment or a setting, and one reached through a shell the module runs. It reads text,
|
|
// not what a program does; review and the runtime's grants cover the rest.
|
|
//
|
|
// go run . <catalogue root>
|
|
//
|
|
// Exits 1 on a finding.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// stateRoots are where a machine keeps a module's state and configuration.
|
|
var stateRoots = []string{"/run/", "/var/lib/", "/usr/local/lib/", "/etc/"}
|
|
|
|
// Finding is one module naming another's path.
|
|
type Finding struct {
|
|
Module, File, Path, Owner string
|
|
}
|
|
|
|
func (f Finding) String() string {
|
|
return fmt.Sprintf("%s names %s in %s, and the path is %s's; modules talk over the bus (novox/hq ADR 0260)",
|
|
f.Module, f.Path, f.File, f.Owner)
|
|
}
|
|
|
|
// Key is how a finding is listed in `known`: the module, the path and the file.
|
|
func (f Finding) Key() string { return f.Module + " " + f.Path + " " + f.File }
|
|
|
|
// containerTargets drops what a container sees inside itself from a manifest before it is searched:
|
|
// the target half of every `volumes` entry of a container resource.
|
|
func containerTargets(m map[string]any) {
|
|
resources, _ := m["resources"].([]any)
|
|
for _, raw := range resources {
|
|
r, ok := raw.(map[string]any)
|
|
if !ok || r["type"] != "container" {
|
|
continue
|
|
}
|
|
volumes, _ := r["volumes"].([]any)
|
|
for i, v := range volumes {
|
|
if s, ok := v.(string); ok {
|
|
if src, _, two := strings.Cut(s, ":"); two {
|
|
volumes[i] = src
|
|
}
|
|
}
|
|
if o, ok := v.(map[string]any); ok {
|
|
delete(o, "target")
|
|
delete(o, "container")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check is every finding in the catalogue at root, before the lists narrow it.
|
|
func Check(root string) ([]Finding, error) {
|
|
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
owner := map[string]string{}
|
|
modules := map[string]string{}
|
|
for _, f := range files {
|
|
raw, err := os.ReadFile(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var m struct {
|
|
Module string `json:"module"`
|
|
Resources []map[string]any `json:"resources"`
|
|
}
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", f, err)
|
|
}
|
|
modules[m.Module] = filepath.Dir(f)
|
|
for _, r := range m.Resources {
|
|
p, _ := r["path"].(string)
|
|
if r["type"] == "directory" && strings.HasSuffix(filepath.Base(p), ".d") {
|
|
continue // a drop-in directory: several modules' place, no one module's
|
|
}
|
|
for _, root := range stateRoots {
|
|
if strings.HasPrefix(p, root) && len(p) > len(root) {
|
|
owner[strings.TrimRight(p, "/")] = m.Module
|
|
}
|
|
}
|
|
}
|
|
}
|
|
paths := make([]string, 0, len(owner))
|
|
for p := range owner {
|
|
paths = append(paths, p)
|
|
}
|
|
sort.Strings(paths)
|
|
patterns := map[string]*regexp.Regexp{}
|
|
for _, p := range paths {
|
|
patterns[p] = regexp.MustCompile(regexp.QuoteMeta(p) + `(?:$|[^A-Za-z0-9._-])`)
|
|
}
|
|
names := make([]string, 0, len(modules))
|
|
for n := range modules {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
var out []Finding
|
|
for _, module := range names {
|
|
err := filepath.WalkDir(modules[module], func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
name := d.Name()
|
|
if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, "_test.go") ||
|
|
name == "go.sum" || name == "package-lock.json" {
|
|
return nil
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if bytes.IndexByte(raw, 0) >= 0 {
|
|
return nil // a binary: what it says is in its source
|
|
}
|
|
if name == "module.json" {
|
|
var m map[string]any
|
|
if json.Unmarshal(raw, &m) == nil {
|
|
containerTargets(m)
|
|
raw, _ = json.Marshal(m)
|
|
}
|
|
}
|
|
text := string(raw)
|
|
for _, p := range paths {
|
|
if owner[p] != module && patterns[p].MatchString(text) {
|
|
rel, _ := filepath.Rel(root, path)
|
|
out = append(out, Finding{Module: module, File: rel, Path: p, Owner: owner[p]})
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// list reads one of the lists beside this check: each line's entry before a `#`, trimmed, with its
|
|
// reason after it.
|
|
func list(path string) (map[string]bool, map[string]string, error) {
|
|
out, why := map[string]bool{}, map[string]string{}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer f.Close()
|
|
s := bufio.NewScanner(f)
|
|
for s.Scan() {
|
|
line, reason, _ := strings.Cut(s.Text(), "#")
|
|
if line = strings.TrimSpace(line); line != "" {
|
|
out[line] = true
|
|
why[line] = strings.TrimSpace(reason)
|
|
}
|
|
}
|
|
return out, why, s.Err()
|
|
}
|
|
|
|
// Grown is every entry a list has that its base copy does not, and whose reason does not say `allow:`.
|
|
func Grown(name string, now map[string]bool, why map[string]string, base map[string]bool) []string {
|
|
var out []string
|
|
for entry := range now {
|
|
if !base[entry] && !strings.Contains(why[entry], "allow:") {
|
|
out = append(out, fmt.Sprintf("checks/paths/%s gains %q, which the base branch's copy does not hold; "+
|
|
"say why after `# allow:` on its line, or keep it out", name, entry))
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Judge narrows the findings by the lists: what is left to refuse, and the known findings that no
|
|
// longer occur, which must leave the list.
|
|
func Judge(found []Finding, shared, known map[string]bool) (refused []Finding, gone []string) {
|
|
seen := map[string]bool{}
|
|
for _, f := range found {
|
|
if shared[f.Path] {
|
|
continue
|
|
}
|
|
seen[f.Key()] = true
|
|
if !known[f.Key()] {
|
|
refused = append(refused, f)
|
|
}
|
|
}
|
|
for k := range known {
|
|
if !seen[k] {
|
|
gone = append(gone, k)
|
|
}
|
|
}
|
|
sort.Strings(gone)
|
|
return refused, gone
|
|
}
|
|
|
|
func main() {
|
|
root, baseDir := ".", ""
|
|
args := os.Args[1:]
|
|
if len(args) >= 2 && args[0] == "-base" {
|
|
baseDir, args = args[1], args[2:]
|
|
}
|
|
if len(args) > 0 {
|
|
root = args[0]
|
|
}
|
|
found, err := Check(root)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(2)
|
|
}
|
|
here := filepath.Join(root, "checks", "paths")
|
|
shared, sharedWhy, err1 := list(filepath.Join(here, "shared"))
|
|
known, knownWhy, err2 := list(filepath.Join(here, "known"))
|
|
if err1 != nil || err2 != nil {
|
|
fmt.Fprintln(os.Stderr, "the lists beside the check cannot be read:", err1, err2)
|
|
os.Exit(2)
|
|
}
|
|
var grown []string
|
|
if baseDir == "" {
|
|
fmt.Fprintln(os.Stderr, "NOT COMPARED: no base branch's lists were given (-base <dir>), so additions to them were not judged; that is a failure, not a pass (issue 1295)")
|
|
os.Exit(2)
|
|
} else {
|
|
baseShared, _, err1 := list(filepath.Join(baseDir, "shared"))
|
|
baseKnown, _, err2 := list(filepath.Join(baseDir, "known"))
|
|
if err1 != nil || err2 != nil {
|
|
fmt.Fprintln(os.Stderr, "the base branch's lists cannot be read:", err1, err2)
|
|
os.Exit(2)
|
|
}
|
|
grown = append(Grown("shared", shared, sharedWhy, baseShared), Grown("known", known, knownWhy, baseKnown)...)
|
|
}
|
|
refused, gone := Judge(found, shared, known)
|
|
for _, g := range grown {
|
|
fmt.Println(g)
|
|
}
|
|
for _, f := range refused {
|
|
fmt.Println(f)
|
|
}
|
|
for _, k := range gone {
|
|
fmt.Printf("%q is in checks/paths/known and no longer occurs: take it out, so it cannot come back\n", k)
|
|
}
|
|
if len(refused) > 0 || len(gone) > 0 || len(grown) > 0 {
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("paths: no module names another's state on its machine (%d known, listed to go)\n", len(known))
|
|
}
|