Delete internal/facts: the snapshot's type is mesh-sdk's go/facts (issue 1390, 4 of 4)
Its tests moved with it: mesh-sdk's go/facts holds the same ones, plus the grants of format 2.
This commit is contained in:
@@ -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 + ")"
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user