diff --git a/internal/facts/facts.go b/internal/facts/facts.go deleted file mode 100644 index 6526db66..00000000 --- a/internal/facts/facts.go +++ /dev/null @@ -1,366 +0,0 @@ -// Package facts is the mesh's facts snapshot: what a check needs to judge a change against the mesh -// that runs, and nothing it could leak (novox/hq to-be 45 §9, ADR 0227 rule 9). -// -// **Why it exists.** Every check the mesh had was right about the world it was given, and none was -// given the mesh's world: a module passed every test and refused the anchor's whole declaration because -// a real machine's name made its identity 23 characters (issue 263); a manifest passed the catalogue -// check and was refused by the node-engine (issue 236); a resolver answer that glibc forgave was final -// to musl (issue 262). The controller holds those facts. It writes them here, the build seat reads them, -// and a merge check composes every machine of the snapshot with the change applied. -// -// **What it holds, and what it never holds.** Every machine — under a stable pseudonym of the same length -// as its name, because the length is what a name limit meets — with its roles, system, C library, -// architecture, builds, what it reported it can do, what is assigned there, its pins and settings; -// every seat and its holders; every module the mesh holds, as its manifest; the sources the mesh built -// them from; the versions of the bus, the store and the node-engine it runs; and how each machine's -// declaration composes today. **No secret and no address**: a setting whose key or value reads as a -// secret is withheld, an address is replaced by one from a documentation range, and a machine's name, a -// site, an account and a public domain are replaced wherever they appear. So a snapshot can be copied -// into a test, a replay or a pull request without carrying anything of the installation it came from. -package facts - -import ( - "crypto/sha256" - "encoding/json" - "fmt" - "sort" - "time" -) - -// Format is the snapshot's version. A reader refuses one newer than it knows: a field it cannot read -// is a fact it would judge without. -const Format = 1 - -// Repository and Tag are where the controller keeps the newest snapshot in the artifact store, and -// MediaType what it is kept as. -const ( - Repository = "facts" - Tag = "latest" - MediaType = "application/vnd.novox.mesh.facts.v1+json" -) - -// Facts is one snapshot. -type Facts struct { - Format int `json:"facts"` - // Taken is when the controller composed it. - Taken time.Time `json:"taken"` - // Controller is the controller build that composed it — the one the mesh runs, which is the one a - // change to the catalogue must be readable by (version skew). - Controller Build `json:"controller"` - // Versions are what the mesh runs of the things its tests stand in for. - Versions Versions `json:"versions"` - // Sources are the repositories the mesh builds modules from, each with the newest commit it built. - Sources []Source `json:"sources"` - // Machines, in name order of their pseudonyms. - Machines []Machine `json:"machines"` - // Seats are every seat held, with its holders. - Seats []Seat `json:"seats"` - // Modules are every module the mesh holds, as it holds them. - Modules []Module `json:"modules"` - // Settings are the mesh-wide layer of every module's settings, scrubbed. - Settings []Settings `json:"settings,omitempty"` - // Edges are the build dependencies between modules, as the mesh recorded them — what a merge's - // rebuild width is computed from. - Edges []Edge `json:"edges,omitempty"` - // Beside is the ref each repository is cloned at beside a merge check, by the directory it is found - // under — the commits the mesh runs, the catalogue's main — so a check run by hand reads the siblings - // the build seat reads (novox/hq issue 286). - Beside map[string]string `json:"beside,omitempty"` -} - -// Build names one build of a core component. -type Build struct { - Version string `json:"version,omitempty"` - Commit string `json:"commit,omitempty"` -} - -// Versions are what the mesh runs. -type Versions struct { - // Bus is the bus server's release, as the server tells a client that connects. - Bus string `json:"bus,omitempty"` - // Store is the store's server version, as the store answers it. - Store string `json:"store,omitempty"` - // NodeEngines are the node-engine builds the machines report, each once. - NodeEngines []string `json:"node-engines,omitempty"` - // Toolchains are the toolchain images a merge check runs in, by language, as the mesh holds them — - // with no address: the artifact store the snapshot is read from is where they are pulled from. What - // a repository's merge-check.sh runs in on the build seat, and so what it must run in anywhere else - // (novox/hq issue 286): two releases of one compiler disagree, down to how gofmt lays out a file. - Toolchains map[string]string `json:"toolchains,omitempty"` -} - -// Source is a repository the mesh builds from, and the newest commit it built a module of. -type Source struct { - Repository string `json:"repository"` - Commit string `json:"commit,omitempty"` - Modules int `json:"modules"` -} - -// Machine is one machine, under its pseudonym. -type Machine struct { - Name string `json:"name"` - // Length is the length of its real name, which the pseudonym keeps. - Length int `json:"length"` - // Roles are what it is to the mesh, in words: the control node, the hub, the bus's machine, a - // holder of a mesh seat. What a check names when it fails one. - Roles []string `json:"roles,omitempty"` - // System is the node-engine's system (arch, alpine, …), Libc its C library, as far as the mesh knows. - System string `json:"system,omitempty"` - Libc string `json:"libc,omitempty"` - Architecture string `json:"architecture,omitempty"` - Kernel string `json:"kernel,omitempty"` - // NodeEngine and NodeTools are the builds it runs. - NodeEngine string `json:"node-engine,omitempty"` - NodeTools string `json:"node-tools,omitempty"` - // Contract is the contract of the node-engine's newest health statement (0 when it never stated one): what - // says whether the machine takes handed files (novox/hq issue 969). A check that composed it without would - // judge a moved module the old way where the mesh hands it. - Contract int `json:"contract,omitempty"` - // Capabilities are what it reported it can do; the detail kept only where it is a version. - Capabilities []Capability `json:"capabilities,omitempty"` - // Site, Hub, Public: where it is on the private network — its site (a pseudonym), whether it is the - // hub, whether others can dial it. No address. - Site string `json:"site,omitempty"` - Hub bool `json:"hub,omitempty"` - Public bool `json:"public,omitempty"` - // OnNetwork is whether it has a place on the private network at all. - OnNetwork bool `json:"on-network,omitempty"` - // OutwardLinks are the links it reported as facing outside it, scrubbed: a machine that reported none - // is sent no filter, so a check that raised it without them would compose a machine the mesh does - // not (novox/hq ADR 0140). - OutwardLinks []string `json:"outward-links,omitempty"` - // Adopted is whether the mesh adopted it rather than converged it. - Adopted bool `json:"adopted,omitempty"` - // Account is the operator's login there (a pseudonym of the same length), and AccountHome where its - // home is when that is not the derived one. - Account string `json:"account,omitempty"` - AccountHome string `json:"account-home,omitempty"` - // AgentAccount is the account agents run as there when it is not the operator's (a pseudonym), and - // AgentAccountHome its home when not derived (novox/hq ADR 0266). - AgentAccount string `json:"agent-account,omitempty"` - AgentAccountHome string `json:"agent-account-home,omitempty"` - // PublicDomain is the domain it answers for, its labels replaced. - PublicDomain string `json:"public-domain,omitempty"` - // Assigned is every module assigned there. - Assigned []string `json:"assigned,omitempty"` - // Pins are where it was told a provision comes from. - Pins []Pin `json:"pins,omitempty"` - // Settings are its own layer of each module's settings, scrubbed. - Settings []Settings `json:"settings,omitempty"` - // Accepted are the secrets a person gave the mesh for modules here, by name only: the mesh cannot - // make them, so a check composing this machine gives each a stand-in instead of refusing it. - Accepted []Accepted `json:"accepted,omitempty"` - // Declaration is how its declaration composes today, as the controller that took this saw it. - Declaration Declaration `json:"declaration"` -} - -// Capability is one thing a machine reported it can or cannot do. -type Capability struct { - Name string `json:"name"` - Present bool `json:"present"` - Detail string `json:"detail,omitempty"` -} - -// Pin is one provision a machine was told where to take from. -type Pin struct { - Provision string `json:"provision"` - Machine string `json:"machine"` - Module string `json:"module,omitempty"` -} - -// Accepted is one secret a person gave: the module's own when Provider is empty, else the credential it -// takes from that machine's provider under Local. -type Accepted struct { - Module string `json:"module"` - Name string `json:"name"` - Provider string `json:"provider,omitempty"` - Local string `json:"local,omitempty"` -} - -// Settings is one layer of one module's settings. -type Settings struct { - Module string `json:"module"` - Values map[string]any `json:"values"` -} - -// Declaration is how one machine's declaration composed when the snapshot was taken. -type Declaration struct { - // Composes is whether it composed and the node-engine's validator took it. - Composes bool `json:"composes"` - // Digest is its body's digest — composed as the next push would, so it moves with what the mesh - // would send, and is not the digest of what was last sent. - Digest string `json:"digest,omitempty"` - // Resources are what it declares, as `type:id`, sorted. - Resources []string `json:"resources,omitempty"` - // Problems are why it does not compose or validate, scrubbed. - Problems []string `json:"problems,omitempty"` - // LeftOut are the modules assigned there and left out of it, each with why. - LeftOut map[string]string `json:"left-out,omitempty"` - // Withheld are the consumers its grants leave out because an identity overflows the provision's - // bound (ADR 0225), and Unbound the credentials on record for consumers bound elsewhere (issue 274), - // each said in words. - Withheld []string `json:"withheld,omitempty"` - Unbound []string `json:"unbound,omitempty"` -} - -// Seat is one seat and the machines holding it. -type Seat struct { - Name string `json:"name"` - Scope string `json:"scope"` - Holders []Holder `json:"holders"` -} - -// Holder is one module on one machine holding a seat. -type Holder struct { - Machine string `json:"machine"` - Module string `json:"module"` -} - -// Module is one module the mesh holds. -type Module struct { - Name string `json:"name"` - // Repository and Path are where it is built from; empty for one that came with the controller. - Repository string `json:"repository,omitempty"` - Path string `json:"path,omitempty"` - // Commit is the commit the manifest the mesh holds was read at. - Commit string `json:"commit,omitempty"` - // Provided is a module that came with the controller rather than from a repository. - Provided bool `json:"provided,omitempty"` - // RollOut is its upgrade policy: rolled out when built, or recorded. - RollOut bool `json:"roll-out,omitempty"` - // Reads are the other repositories its build read source from. - Reads []string `json:"reads,omitempty"` - // Sources are the build source its newest build said it read, per repository (novox/hq ADR 0267): a - // context of Reads by name with its paths, or the module's own repository (Own). Absent, the module's - // build reads every file of each of Reads and of its own directory, as before. - Sources []BuildSource `json:"sources,omitempty"` - // Manifest is the module as the mesh holds it: artifacts resolved to the builds it runs. - Manifest json.RawMessage `json:"manifest"` -} - -// BuildSource is the build source a module's build read in one repository (novox/hq ADR 0267). -type BuildSource struct { - Repository string `json:"repository,omitempty"` - Own bool `json:"own,omitempty"` - Paths []string `json:"paths"` -} - -// Edge is one build dependency: From is built standing on To. -type Edge struct { - From string `json:"from"` - To string `json:"to"` - Kind string `json:"kind,omitempty"` -} - -// Sorted puts every list in a fixed order, so two snapshots of one mesh are the same bytes apart from -// when they were taken. -func (f *Facts) Sorted() { - sort.Slice(f.Machines, func(i, j int) bool { return f.Machines[i].Name < f.Machines[j].Name }) - for i := range f.Machines { - m := &f.Machines[i] - sort.Strings(m.Roles) - sort.Strings(m.Assigned) - sort.Slice(m.Capabilities, func(a, b int) bool { return m.Capabilities[a].Name < m.Capabilities[b].Name }) - sort.Slice(m.Pins, func(a, b int) bool { return m.Pins[a].Provision < m.Pins[b].Provision }) - sort.Slice(m.Settings, func(a, b int) bool { return m.Settings[a].Module < m.Settings[b].Module }) - sort.Slice(m.Accepted, func(a, b int) bool { - x, y := m.Accepted[a], m.Accepted[b] - return x.Module+"\x00"+x.Name+"\x00"+x.Provider+"\x00"+x.Local < y.Module+"\x00"+y.Name+"\x00"+y.Provider+"\x00"+y.Local - }) - sort.Strings(m.Declaration.Resources) - } - sort.Slice(f.Seats, func(i, j int) bool { - if f.Seats[i].Name != f.Seats[j].Name { - return f.Seats[i].Name < f.Seats[j].Name - } - return f.Seats[i].Scope < f.Seats[j].Scope - }) - for i := range f.Seats { - h := f.Seats[i].Holders - sort.Slice(h, func(a, b int) bool { - if h[a].Machine != h[b].Machine { - return h[a].Machine < h[b].Machine - } - return h[a].Module < h[b].Module - }) - } - sort.Slice(f.Modules, func(i, j int) bool { return f.Modules[i].Name < f.Modules[j].Name }) - sort.Slice(f.Sources, func(i, j int) bool { return f.Sources[i].Repository < f.Sources[j].Repository }) - sort.Slice(f.Settings, func(i, j int) bool { return f.Settings[i].Module < f.Settings[j].Module }) - sort.Slice(f.Edges, func(i, j int) bool { - if f.Edges[i].From != f.Edges[j].From { - return f.Edges[i].From < f.Edges[j].From - } - return f.Edges[i].To < f.Edges[j].To - }) - sort.Strings(f.Versions.NodeEngines) -} - -// Encode is the snapshot as it is kept: sorted, indented, so a person can read the one the build seat -// read and a diff of two says what moved. -func (f Facts) Encode() ([]byte, error) { - f.Sorted() - return json.MarshalIndent(f, "", " ") -} - -// Content is the digest of everything but when it was taken: two snapshots of an unchanged mesh have -// the same content, which is how the controller tells a change from another day. -func (f Facts) Content() (string, error) { - f.Taken = time.Time{} - body, err := f.Encode() - if err != nil { - return "", err - } - sum := sha256.Sum256(body) - return fmt.Sprintf("sha256:%x", sum), nil -} - -// Decode reads a snapshot, refusing one newer than this reader knows and one that is not a snapshot. -func Decode(body []byte) (Facts, error) { - var f Facts - if err := json.Unmarshal(body, &f); err != nil { - return Facts{}, fmt.Errorf("not a facts snapshot: %w", err) - } - switch { - case f.Format == 0: - return Facts{}, fmt.Errorf("not a facts snapshot: it says no format") - case f.Format > Format: - return Facts{}, fmt.Errorf("a facts snapshot of format %d, and this reads format %d: a newer controller "+ - "took it, and what it says beyond %d would be judged without", f.Format, Format, Format) - } - return f, nil -} - -// Longest is the length of the longest machine name in the snapshot — what a consumer's identity is -// judged on (novox/hq ADR 0225). -func (f Facts) Longest() int { - longest := 0 - for _, m := range f.Machines { - if m.Length > longest { - longest = m.Length - } - } - return longest -} - -// Machine is the machine of that pseudonym. -func (f Facts) Machine(name string) (Machine, bool) { - for _, m := range f.Machines { - if m.Name == name { - return m, true - } - } - return Machine{}, false -} - -// Described is how a check names a machine: its roles, then its pseudonym. -func (m Machine) Described() string { - if len(m.Roles) == 0 { - return "a machine (" + m.Name + ")" - } - out := m.Roles[0] - for _, r := range m.Roles[1:] { - out += ", " + r - } - return out + " (" + m.Name + ")" -} diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go deleted file mode 100644 index bef99593..00000000 --- a/internal/facts/facts_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package facts - -import ( - "fmt" - "net" - "regexp" - "strings" - "testing" - "time" -) - -// A pseudonym keeps what a limit meets — the length, and letters where letters were — and nothing else. -func TestAPseudonymKeepsTheLengthAndShapeAndIsStable(t *testing.T) { - for _, name := range []string{"ace", "g14", "novox", "shanks", "home-server", "a"} { - p := Pseudonym("machine", name) - if len(p) != len(name) { - t.Errorf("%s became %s: %d characters for %d", name, p, len(p), len(name)) - } - if p == name { - t.Errorf("%s was kept as itself", name) - } - if p != Pseudonym("machine", name) { - t.Errorf("%s is not stable", name) - } - for i := range name { - isLetter := func(c byte) bool { return c >= 'a' && c <= 'z' } - isDigit := func(c byte) bool { return c >= '0' && c <= '9' } - if isLetter(name[i]) != isLetter(p[i]) || isDigit(name[i]) != isDigit(p[i]) { - t.Errorf("%s became %s: the shape moved at %d", name, p, i) - } - } - } - if Pseudonym("machine", "ace") == Pseudonym("site", "ace") { - t.Error("a site and a machine of one name share a pseudonym, so a snapshot says they are one thing") - } -} - -// Nothing of the installation survives the scrubber: names, domains, accounts, addresses, mail, secrets. -func TestTheScrubberLeavesNothingOfTheInstallation(t *testing.T) { - s := NewScrubber() - machine := s.Machine("homeserver") - s.Account("jochens") - domain := s.Domain("zurag.be") - if len(domain) != len("zurag.be") || !strings.HasSuffix(domain, ".be") || domain == "zurag.be" { - t.Errorf("the domain became %q", domain) - } - in := map[string]any{ - "hub": "homeserver", - "listen": "10.42.0.7:51820", - "upstream": []any{"192.168.1.135", "fd00::1"}, - "site": "https://grafana.zurag.be/login", - "admin": "jschoubben@gmail.com", - "home": "/home/jochens/.ssh", - "api_key": "sk-live-abcdef", - "nested": map[string]any{"password": "hunter2", "port": float64(5432)}, - "opaque": "a8F3kQ9zL2mX7vB4nC6dE1rT5yU0iO8pA3sD", - "dsn": "postgres://app:s3cr3tpass@db.internal:5432/app", - "pem": "-----BEGIN PRIVATE KEY-----\nMIIB", - "plain": "a-long-plain-module-directory-name", - "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", - "version": "2.11.17", - "homeserver": true, - } - out := s.Values(in) - flat := flatten(out) - for _, leaked := range []string{"homeserver\"", "10.42.0.7", "192.168.1.135", "fd00::1", "zurag", "jschoubben", - "gmail", "jochens", "sk-live", "hunter2", "a8F3kQ9zL2mX7vB4nC6dE1rT5yU0iO8pA3sD", "s3cr3tpass", "MIIB"} { - if strings.Contains(flat, leaked) { - t.Errorf("%q survived the scrubber:\n%s", leaked, flat) - } - } - if out["hub"] != machine { - t.Errorf("a machine named in a setting became %v, not its pseudonym %s", out["hub"], machine) - } - for _, kept := range []string{"a-long-plain-module-directory-name", "2.11.17", "sha256:44136fa3", "5432"} { - if !strings.Contains(flat, kept) { - t.Errorf("%q was scrubbed, and it is not the installation's:\n%s", kept, flat) - } - } - // Every address left is a documentation address. - for _, a := range regexp.MustCompile(`[0-9a-f:.]{7,}`).FindAllString(flat, -1) { - ip := net.ParseIP(strings.Trim(a, ".:")) - if ip == nil { - continue - } - doc := false - for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "2001:db8::/32"} { - _, n, _ := net.ParseCIDR(cidr) - doc = doc || n.Contains(ip) - } - if !doc { - t.Errorf("%s is not a documentation address", a) - } - } - // The same address is always the same stand-in. - if s.Text("10.42.0.7") != s.Text("at 10.42.0.7")[3:] { - t.Error("one address became two stand-ins") - } -} - -func flatten(v any) string { - var b strings.Builder - var walk func(any) - walk = func(v any) { - switch t := v.(type) { - case map[string]any: - for k, e := range t { - b.WriteString(k + "\"=") - walk(e) - b.WriteString("\n") - } - case []any: - for _, e := range t { - walk(e) - b.WriteString(",") - } - case string: - b.WriteString(t + "\"") - default: - b.WriteString(fmt.Sprint(t)) - } - } - walk(v) - return b.String() -} - -// Two snapshots of one mesh, taken apart, are one content. -func TestASnapshotOfAnUnchangedMeshIsTheSameContentAnotherDay(t *testing.T) { - f := Facts{Format: Format, Taken: time.Now(), Machines: []Machine{{Name: "b"}, {Name: "a"}}} - g := f - g.Taken = f.Taken.Add(24 * time.Hour) - g.Machines = []Machine{{Name: "a"}, {Name: "b"}} - a, err := f.Content() - if err != nil { - t.Fatal(err) - } - b, _ := g.Content() - if a != b { - t.Error("the same mesh a day later reads as a change") - } - g.Machines = append(g.Machines, Machine{Name: "c"}) - if c, _ := g.Content(); c == a { - t.Error("a machine added reads as no change") - } -} - -// A reader refuses a snapshot it cannot read whole. -func TestANewerSnapshotIsRefusedNotHalfRead(t *testing.T) { - if _, err := Decode([]byte(`{"facts": 99}`)); err == nil || !strings.Contains(err.Error(), "newer controller") { - t.Errorf("a newer format read as %v", err) - } - if _, err := Decode([]byte(`{"machines": []}`)); err == nil { - t.Error("a document with no format read as a snapshot") - } - f, err := Decode([]byte(`{"facts": 1, "machines": [{"name": "abc", "length": 3}, {"name": "defgh", "length": 5}]}`)) - if err != nil || f.Longest() != 5 { - t.Errorf("read %+v, %v", f, err) - } -} - -// A path that reads as a key is withheld, and keeps a path's shape: an access or a place must be an -// absolute path, and a bare word left a machine the mesh composes refused where a check raised it. -func TestAWithheldPathStaysAPath(t *testing.T) { - s := NewScrubber() - if got := s.Text("/storage/media/Formula1-Season-2026"); got != "/"+Withheld { - t.Errorf("a path reading as a key became %q", got) - } - if got := s.Text("/storage/media/movies"); got != "/storage/media/movies" { - t.Errorf("a plain path became %q", got) - } - if got := s.Text("Hunter2Hunter2Hunter2Hunter2xx"); got != Withheld { - t.Errorf("a key became %q", got) - } -} - -// **Issue 288**: a repository was kept as the URL it was cloned from, the forge's address with it. -func TestARepositoryIsNamedWithoutTheForge(t *testing.T) { - for in, want := range map[string]string{ - "http://forge.internal:3000/owner/repo.git": "owner/repo", - "ssh://git@forge.internal:222/owner/repo": "owner/repo", - "git@forge.internal:owner/repo.git": "owner/repo", - "owner/repo": "owner/repo", - "": "", - } { - if got := RepositoryName(in); got != want { - t.Errorf("%q is named %q, not %q", in, got, want) - } - } -} diff --git a/internal/facts/scrub.go b/internal/facts/scrub.go deleted file mode 100644 index 4615bb34..00000000 --- a/internal/facts/scrub.go +++ /dev/null @@ -1,351 +0,0 @@ -package facts - -import ( - "crypto/sha256" - "fmt" - "net" - "regexp" - "sort" - "strings" -) - -// Withheld is what a value that reads as a secret becomes. A check composing with it gets a stand-in of -// the right kind (a string), never the value. -const Withheld = "withheld" - -// Pseudonym is the stable stand-in for a name: the same length, letters for letters and digits for -// digits, anything else kept where it was. Stable, so two snapshots of one mesh name a machine alike and -// a check's verdict can be compared across them; derived from the name alone, so it needs no key to be -// kept anywhere. -// -// It hides nothing from somebody who can guess the names: that is not its purpose. Its purpose is that a -// snapshot — and every fixture, replay or pull-request comment made from one — carries no name of the -// installation it came from, while every length a limit meets stays the length it was. -func Pseudonym(kind, name string) string { - sum := sha256.Sum256([]byte("novox-mesh-facts\x00" + kind + "\x00" + name)) - out := []byte(name) - for i, c := range out { - b := sum[i%len(sum)] ^ byte(i/len(sum)) - switch { - case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z': - out[i] = 'a' + b%26 - case c >= '0' && c <= '9': - out[i] = '0' + b%10 - } - } - // A name must still read as one: a machine's begins with a letter. - if len(out) > 0 && (out[0] < 'a' || out[0] > 'z') && name[0] >= 'a' && name[0] <= 'z' { - out[0] = 'a' + sum[0]%26 - } - return string(out) -} - -// Scrubber replaces what a snapshot must not carry, wherever it appears in text. -type Scrubber struct { - // replace is every real word and what it becomes, longest first, so a domain is replaced before a - // label inside it. - replace [][2]string - seen map[string]bool -} - -// NewScrubber knows the installation's names: machines, sites, accounts, public domains. -func NewScrubber() *Scrubber { return &Scrubber{seen: map[string]bool{}} } - -// Machine registers a machine's name and answers its pseudonym. -func (s *Scrubber) Machine(name string) string { return s.word("machine", name) } - -// Site registers a site's name and answers its pseudonym. -func (s *Scrubber) Site(name string) string { return s.word("site", name) } - -// Account registers an account's name and answers its pseudonym. -func (s *Scrubber) Account(name string) string { return s.word("account", name) } - -// Domain registers a public domain and answers its stand-in: each label but the last replaced, so the -// shape and every length stay. -func (s *Scrubber) Domain(domain string) string { - if domain == "" { - return "" - } - labels := strings.Split(domain, ".") - for i := range labels { - if i == len(labels)-1 && len(labels) > 1 { - break - } - labels[i] = Pseudonym("domain", labels[i]) - } - out := strings.Join(labels, ".") - s.add(domain, out) - return out -} - -func (s *Scrubber) word(kind, name string) string { - if name == "" { - return "" - } - out := Pseudonym(kind, name) - s.add(name, out) - return out -} - -func (s *Scrubber) add(from, to string) { - if from == "" || s.seen[from] { - return - } - s.seen[from] = true - s.replace = append(s.replace, [2]string{from, to}) - sort.SliceStable(s.replace, func(i, j int) bool { return len(s.replace[i][0]) > len(s.replace[j][0]) }) -} - -// wordBoundary is what may stand beside a name for it to be that name and not part of another word. -func wordBoundary(c byte) bool { - return !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') -} - -// Text is s with every known name replaced, every address put in a documentation range, every address -// of mail replaced, and every run that reads as a secret withheld. -func (s *Scrubber) Text(text string) string { - if text == "" { - return text - } - text = secretRuns(text) - text = emails.ReplaceAllStringFunc(text, func(mail string) string { - if strings.HasPrefix(mail, Withheld+"@") { - return mail // a URL's withheld credentials, not an address of mail - } - return "someone@example.org" - }) - text = addresses(text) - for _, r := range s.replace { - text = replaceWord(text, r[0], r[1]) - } - return text -} - -// replaceWord replaces from where it stands as a word of its own. -func replaceWord(text, from, to string) string { - var b strings.Builder - for { - i := strings.Index(text, from) - if i < 0 { - b.WriteString(text) - return b.String() - } - end := i + len(from) - if (i == 0 || wordBoundary(text[i-1])) && (end == len(text) || wordBoundary(text[end])) { - b.WriteString(text[:i]) - b.WriteString(to) - } else { - b.WriteString(text[:end]) - } - text = text[end:] - } -} - -// Values is a settings layer with every key that names a secret withheld, and every string scrubbed. -func (s *Scrubber) Values(values map[string]any) map[string]any { - out := make(map[string]any, len(values)) - for k, v := range values { - // A key may itself be a name — a map of machines to something. - key := s.Text(k) - if secretKey.MatchString(k) { - out[key] = withheldLike(v) - continue - } - out[key] = s.value(v) - } - return out -} - -func (s *Scrubber) value(v any) any { - switch t := v.(type) { - case string: - return s.Text(t) - case map[string]any: - return s.Values(t) - case []any: - out := make([]any, len(t)) - for i, e := range t { - out[i] = s.value(e) - } - return out - default: - return v - } -} - -// withheldLike is a stand-in of the same kind: a string for a string, a list for a list, so a check -// composing a setting still finds the shape it expects. -func withheldLike(v any) any { - switch t := v.(type) { - case nil: - return nil - case bool, float64, int, int64: - return t - case []any: - out := make([]any, len(t)) - for i, e := range t { - out[i] = withheldLike(e) - } - return out - case map[string]any: - out := map[string]any{} - for k, e := range t { - out[k] = withheldLike(e) - } - return out - default: - return Withheld - } -} - -// secretKey is a setting's key that names a secret. -var secretKey = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`) - -// emails are addresses of mail: a person's name, or an installation's domain, in either half. -var emails = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) - -// secretRun is a run of characters a key, a token or a hash is made of, long enough to be one. -var secretRun = regexp.MustCompile(`[A-Za-z0-9+/=_\-]{24,}`) - -// userinfo is the credentials part of a URL. -var userinfo = regexp.MustCompile(`(://)[^/@\s:]+:[^/@\s]+@`) - -// secretRuns withholds a URL's credentials and every run that reads as a key: long, and mixing letters -// with digits or symbols. A long plain word (a path, a module's name) is left alone. -func secretRuns(text string) string { - if strings.Contains(text, "-----BEGIN") { - return Withheld - } - text = userinfo.ReplaceAllString(text, "${1}"+Withheld+"@") - var b strings.Builder - last := 0 - for _, at := range secretRun.FindAllStringIndex(text, -1) { - run := text[at[0]:at[1]] - b.WriteString(text[last:at[0]]) - last = at[1] - // A digest names an artifact, not a secret. - if strings.HasSuffix(text[:at[0]], "sha256:") { - b.WriteString(run) - continue - } - judged := judgeRun(run) - // A path withheld keeps a path's shape — an absolute one stays absolute — so a check composing a - // place or an access still finds a path where one must be. - if judged == Withheld && strings.HasPrefix(run, "/") { - judged = "/" + Withheld - } - b.WriteString(judged) - } - b.WriteString(text[last:]) - return b.String() -} - -// judgeRun is a run withheld when it reads as a key: long, and mixing letters with digits or symbols. -func judgeRun(run string) string { - { - var lower, upper, digit, symbol bool - for _, c := range run { - switch { - case c >= 'a' && c <= 'z': - lower = true - case c >= 'A' && c <= 'Z': - upper = true - case c >= '0' && c <= '9': - digit = true - default: - symbol = true - } - } - classes := 0 - for _, b := range []bool{lower, upper, digit, symbol} { - if b { - classes++ - } - } - // A sha256 digest names an artifact, not a secret, and a dashed word is a name. - if strings.HasPrefix(run, "sha256") || (!digit && !upper) { - return run - } - if classes >= 3 || (digit && (lower || upper) && len(run) >= 32) { - return Withheld - } - return run - } -} - -// addresses puts every IP address in text into a documentation range (RFC 5737, RFC 3849): the same -// address always becomes the same stand-in, so two settings naming one machine still name one. -func addresses(text string) string { - var b strings.Builder - i := 0 - for i < len(text) { - if !addressChar(text[i]) { - b.WriteByte(text[i]) - i++ - continue - } - j := i - for j < len(text) && addressChar(text[j]) { - j++ - } - b.WriteString(standIn(text[i:j])) - i = j - } - return b.String() -} - -func addressChar(c byte) bool { - return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' || c == '.' || c == ':' -} - -// standIn is the documentation address for a run that is an address, or the run itself. -func standIn(run string) string { - trimmed := strings.TrimRight(run, ".:") - tail := run[len(trimmed):] - ip := net.ParseIP(trimmed) - switch { - case ip == nil: - // A port after an IPv4 address reads as part of the run: try without it. - if host, port, ok := strings.Cut(trimmed, ":"); ok && strings.Count(trimmed, ":") == 1 && - net.ParseIP(host) != nil && strings.Contains(host, ".") { - return standIn(host) + ":" + port + tail - } - return run - case ip.To4() != nil && strings.Contains(trimmed, "."): - sum := sha256.Sum256([]byte("v4\x00" + trimmed)) - ranges := []string{"192.0.2", "198.51.100", "203.0.113"} - return fmt.Sprintf("%s.%d", ranges[sum[0]%3], 1+sum[1]%254) + tail - case strings.Count(trimmed, ":") >= 2: - sum := sha256.Sum256([]byte("v6\x00" + trimmed)) - return fmt.Sprintf("2001:db8::%x:%x", uint16(sum[0])<<8|uint16(sum[1]), uint16(sum[2])<<8|uint16(sum[3])) + tail - } - return run -} - -// RepositoryName is a repository as `owner/repository`, without the forge's address it was cloned from: -// http://forge.internal:3000/owner/repo.git → owner/repo (novox/hq issue 288). What a check matches a -// pull request's repository by is its owner and name, never the forge's address, so nothing is lost. -func RepositoryName(repository string) string { - r := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(repository), "/"), ".git") - if r == "" { - return "" - } - if _, rest, found := strings.Cut(r, "://"); found { - r = rest - if _, path, found := strings.Cut(r, "/"); found { - r = path - } else { - return "" - } - } else if at := strings.Index(r, "@"); at >= 0 { - // scp-like: git@forge:owner/repo - if _, path, found := strings.Cut(r[at+1:], ":"); found { - r = path - } - } - parts := strings.Split(strings.Trim(r, "/"), "/") - if len(parts) >= 2 { - return parts[len(parts)-2] + "/" + parts[len(parts)-1] - } - return parts[len(parts)-1] -}