diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/compose/input.go b/vendor/git.novox.be/novox/mesh-sdk/go/compose/input.go index e98a7fee..853eab7a 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/compose/input.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/compose/input.go @@ -70,7 +70,8 @@ type Input struct { // Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential // that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not - // made yet has no grant until the send that makes it. + // made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each + // machine (go/facts, from format 2), which carry who each credential is for and never the credential. Issued []Issued // OwnSecrets is the own secrets held on each machine (the module_secret rows): who made each, and the key it is sealed to. // Their sealed values are optional. @@ -147,7 +148,7 @@ type Node struct { // Pins is the provider each provision was pinned to for it (inv.PinsFor). Pins map[string]catalogue.Chosen // Bound is where each of its consumers of a provision that keeps data was last sent (inv.BindingsFor), by consumer - // module and provision. + // module and provision. An assigner fills it from the facts snapshot's Bindings of the machine (go/facts, from format 2). Bound map[string]map[string]catalogue.Chosen // Settings is its settings layers by module (inv.SettingsFor); a module with none is absent. Settings map[string][]catalogue.Layer diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/facts/facts.go b/vendor/git.novox.be/novox/mesh-sdk/go/facts/facts.go new file mode 100644 index 00000000..c6333cd9 --- /dev/null +++ b/vendor/git.novox.be/novox/mesh-sdk/go/facts/facts.go @@ -0,0 +1,415 @@ +// 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. +// +// Copied from mesh-controller internal/facts at 6c2a8d62 (R4b, issue 1380); the one change is each machine's +// Grants and Bindings, from format 2. +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. +// +// Format 2 is the first to carry a machine's Grants and Bindings (GrantsFrom): a reader of format 1 refuses a +// snapshot of 2 rather than judge a change to a credential or a binding without them, and a reader of 2 reads a +// snapshot of 1 as one whose machines have none. MediaType stays as it is: it names the kind of document, and the +// format inside it says which fields it carries. +const Format = 2 + +// GrantsFrom is the first format that carries each machine's Grants and Bindings (novox/hq ADR 0446 section 2): the +// credentials made for the consumers of what the machine provides, and where each of its consumers of a provision is +// bound. A snapshot of an earlier format says nothing of them, which is not the same as saying there are none. +const GrantsFrom = 2 + +// 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"` + // Grants are the pair credentials made for the consumers of what this machine provides (mesh-controller's + // inv.SecretsFrom for it): who each is for, never the credential, sealed or not. Written from format GrantsFrom; an + // assigner reads them as compose.Input.Issued. + Grants []Grant `json:"grants,omitempty"` + // Bindings are where each consumer on this machine was last bound to a provision that keeps data (mesh-controller's + // inv.BindingsFor for it), by consumer module and provision. Written from format GrantsFrom; an assigner reads them + // as compose.Node.Bound. + Bindings []Binding `json:"bindings,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"` +} + +// Grant is one pair credential made for a consumer of a provision this machine provides: the provision, the machine and +// module that consume it, and the local name the consumer takes it under. The machine is a pseudonym. Nothing of the +// credential is here, not even sealed (compose.Issued without its ForProvider). +type Grant struct { + Provision string `json:"provision"` + ConsumerNode string `json:"consumer-node"` + ConsumerModule string `json:"consumer-module"` + Local string `json:"local,omitempty"` +} + +// Binding is where one consumer module on this machine was last bound for a provision: the provider's machine (a +// pseudonym) and module (compose.Node.Bound). +type Binding struct { + ConsumerModule string `json:"consumer-module"` + Provision string `json:"provision"` + ProviderNode string `json:"provider-node"` + ProviderModule string `json:"provider-module"` +} + +// 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.Grants, func(a, b int) bool { + x, y := m.Grants[a], m.Grants[b] + return x.Provision+"\x00"+x.ConsumerNode+"\x00"+x.ConsumerModule+"\x00"+x.Local < + y.Provision+"\x00"+y.ConsumerNode+"\x00"+y.ConsumerModule+"\x00"+y.Local + }) + sort.Slice(m.Bindings, func(a, b int) bool { + x, y := m.Bindings[a], m.Bindings[b] + return x.ConsumerModule+"\x00"+x.Provision < y.ConsumerModule+"\x00"+y.Provision + }) + 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/vendor/git.novox.be/novox/mesh-sdk/go/facts/scrub.go b/vendor/git.novox.be/novox/mesh-sdk/go/facts/scrub.go new file mode 100644 index 00000000..febc1f52 --- /dev/null +++ b/vendor/git.novox.be/novox/mesh-sdk/go/facts/scrub.go @@ -0,0 +1,362 @@ +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) } + +// Grant is a grant with the consumer's machine under its pseudonym, as a pin's machine is: the one place a grant is +// built from a real name. +func (s *Scrubber) Grant(provision, consumerNode, consumerModule, local string) Grant { + return Grant{Provision: provision, ConsumerNode: s.Machine(consumerNode), ConsumerModule: consumerModule, Local: local} +} + +// Binding is a binding with the provider's machine under its pseudonym. +func (s *Scrubber) Binding(consumerModule, provision, providerNode, providerModule string) Binding { + return Binding{ConsumerModule: consumerModule, Provision: provision, ProviderNode: s.Machine(providerNode), ProviderModule: providerModule} +} + +// 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] +}