Compare commits

..
Author SHA1 Message Date
jschoubben 22b70c82b0 Stand in the artifact store in the toolchain test's shelf: R4e's build modules require it (ADR 0513), and the group's composed check judges them at the catalogue's group head
mesh/repo-check head 22b70c82b0ab merged into trunk cdfc63cf102b beside mesh-catalog#457@896bd883 +1 more — pass: THE CHANGE ALTERS ITS OWN CHECK (go/merge…
mesh/merge-gate head 22b70c82b0ab merged into trunk cdfc63cf102b — pass: group feat/r4e composed: every machine composes with the change as it did without …
mesh/delivery covered after its stop: a later walk that contains it was delivered: plan-1792707174627079192 (novox/mesh-sdk at 7aacbc16 into main, done, …
mesh/delivery-group group feat/r4e closed: a member was closed before the group merged
mesh/check failed: unit: ..test: node:internal/modules/esm/resolve:989 | 22b70c82b0ab on 7aacbc162014 | unit 1675 run, 0 kept, 3 skipped | integration…
2026-10-23 00:03:06 +02:00
jschoubben 6aa8e869a3 Name no paths in mesh-sdk's own manifest until R4e is delivered: today's controller judges R4e's group and refuses the word (issue 1758, ADR 0499 §7, ADR 0503 §4)
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 6aa8e869a3b6 merged into trunk cdfc63cf102b beside mesh-catalog#457@896bd883 +1 more — fail: THE CHANGE ALTERS ITS OWN CHECK (go/merge…
mesh/delivery-group group feat/r4e rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: ace: novox/mesh-catalog#457: modules/gitea/go.mod names mesh-sdk 104533e8c0c9, which is neither on mesh-sdk's trunk nor the group's…
2026-10-22 23:57:26 +02:00
jschoubben 104533e8c0 S2: read a manifest for a merge's named tree as the indexer does
mesh/merge-gate head 104533e8c0c9 merged into trunk cdfc63cf102b — fail: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus step; 9 wait(…
mesh/repo-check head 104533e8c0c9 merged into trunk cdfc63cf102b — pass: THE CHANGE ALTERS ITS OWN CHECK (go/merge-check.sh): main's version judged it; the…
mesh/delivery-group group feat/r4e rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: unit: modules/gitea.type-check: index.ts(263,29): error TS2345: Argument of type 'BranchProtection | null | undefined' is not assig…
catalogue.ParseManifestForInputs (issue 1751): a manifest falling back to a predefined key was narrowed by the indexer and not by a build module, two computations of one tree (novox/hq ADR 0499 clause 2).
2026-10-22 21:07:40 +02:00
jschoubben 259bfd0c2b S1: keep no package.json or go.mod outside the named tree in a narrowed build
A file outside the key changed what is built (novox/hq ADR 0499 clause 4); a build that needs one names it among its paths.
2026-10-22 21:07:22 +02:00
jschoubben 875c680d98 Merge remote-tracking branch 'origin/main' into feat/r4e-sdk
# Conflicts:
#	go/catalogue/state.go
#	go/catalogue/state_test.go
#	go/process/state.go
2026-10-22 20:37:49 +02:00
mesh-tools cdfc63cf10 Merge pull request 'Let a process watch, put, list and delete its own state's keys, as a tools bundle can (upgrade safety, issue 1840, ADR 0507 §8)' (#69) from feat/upgrade-safety into main 2026-10-22 18:34:24 +00:00
jschoubben d1af87a83a Leave a narrowed build only its named paths, manifest and locks, in one function for both build modules (issue 1758, ADR 0499 §4) 2026-10-22 18:31:07 +02:00
jschoubben e76a5a31a0 Read a manifest for its build's inputs without the published list of predefined keys (issue 1751, dev-r4e-builder's request)
The indexer and the builder hold no list, so claude-code, dunst and xterm were refused;
ParseManifestForInputs takes each fallback as its value declares it and judges no
config value, refusing everything else ParseManifest refuses.
2026-10-22 18:18:55 +02:00
jschoubben 8892a11768 Merge remote-tracking branch 'origin/main' into feat/upgrade-safety
mesh/merge-gate head 8892a1176800 merged into trunk 4fac7810a1e4 — pass: group feat/upgrade-safety composed: every machine composes with the change as it d…
mesh/repo-check head 8892a1176800 merged into trunk 4fac7810a1e4 beside mesh-catalog#450@86ba54b0 +1 more — pass: its merge-check.sh passed, each part in i…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792694058124013462 (novox/mesh-sdk at cdfc63cf into main, don…
mesh/delivery-group group feat/upgrade-safety merged: every member is merged
mesh/check failed: unit: ..test: node:internal/modules/esm/resolve:241 | 8892a1176800 on 4fac7810a1e4 | unit 3372 run, 0 kept, 7 skipped | integration…
2026-10-22 18:13:24 +02:00
mesh-tools 4fac7810a1 Merge pull request 'Keep a machine that does not resolve on the private network and in the names, so it is refused alone (issue 1833)' (#68) from fix/1833-one-machine-alone into main 2026-10-22 16:10:23 +00:00
jschoubben b07e741c41 Say in the controller seat's verbs what R4e removes and what the checker now does (R4e, dev-r4e-controller's request via the lead; ADR 0501 §5, ADR 0503 §3, ADR 0429 §18)
build, rebuild, forget-checks and delivery-plan stay listed, refused by name; replay
is a dry run only; delivery-check asks nothing; try is the checker's.
2026-10-22 18:07:37 +02:00
jschoubben 72a477eb1c Carry a candidate module's named paths, so the builder's fetch for a candidate names them (issue 1758, ADR 0499 §2, the builder lane's request) 2026-10-22 18:06:49 +02:00
jschoubben 8c3bfad86b Run the run step's isolation proof only where bubblewrap is, under its own tag (issue 1309, S19, the lead's decision)
The try's toolchain container has no bubblewrap; the checker runs the isolation tag on
the build nodes, and merge-check.sh still vets it.
2026-10-22 18:06:10 +02:00
jschoubben 9cb4d59093 Keep each artifact's named paths in the modules value, so the checker needs no manifest to see a change touch them (issue 1758, ADR 0499, lead decision L8) 2026-10-22 18:00:24 +02:00
jschoubben fd2ae83431 Name the error of a fetch whose lock waits for an unpublished package (issue 1587, ADR 0500 §6)
waits for: is the prefix the builder turns into a queued key with Waits; it is not
one of the errors held until the fingerprint changes.
2026-10-22 18:00:02 +02:00
jschoubben 4dd967e62f Let a module that builds no artifact have a done key that can be delivered (issue 1613, the lead's request for the builder lane)
A done key needs its manifest digest, no longer an artifact; Referenced answers ok
for it, since no artifact lacks a reference.
2026-10-22 17:59:55 +02:00
jschoubben ab2ca6e3a7 Push a done build's bundle or archive to the artifact store from the build module, in one shared client (issue 1587, ADR 0498 §1-§2)
buildstep.PushBlob uploads by digest unless the store holds it, waits out a store
refusing connections as mesh-build-image does, and names the store's own words;
BlobReference is the form the key and the controller check.
2026-10-22 17:59:14 +02:00
jschoubben a90f523edc Name the TypeScript package's own files as lib's paths, so a Go-only change moves no TypeScript module (issue 1758, S6, ADR 0499 §6) 2026-10-22 17:47:26 +02:00
jschoubben fc7c4e97e6 Stop committing the Go dependencies, which go.sum already pins (issue 1304, S8)
go/vendor's 810 files go and .gitignore names vendor/, as merge-check.sh already
said; every dependency resolves by go.sum and go mod verify fails by name.
2026-10-22 17:47:06 +02:00
jschoubben f7cd91e49d Accept a modules value with the new fingerprint alone, and say mesh/check is the required status (issue 1851, S7 and S16, ADR 0453 §6, ADR 0503 §3 §5)
FP and None stay readable for build-agent's path until R4g; the gate's status
comment and the checks verb now name mesh/check as what a main requires.
2026-10-22 17:46:56 +02:00
jschoubben 3b3cabd2b0 Prove a run step reaches no key, login, name or socket and leaves the caches unchanged (issue 1309, S19, ADR 0448 check) 2026-10-22 17:46:10 +02:00
jschoubben f8b8f3a666 issue 1840/S2: take a narrowed watch's current values as a set, since the bus hands them in the order of their last revision 2026-10-22 17:45:58 +02:00
jschoubben 1f4ff20488 Keep the shared caches unchanged by a candidate's step, under a throwaway writable layer (issue 1309, S18, ADR 0448)
A trunk build alone writes them; any other step writes into an overlay that dies with it.
2026-10-22 17:45:35 +02:00
jschoubben 39a2144cd6 Run a step's command shut off in bubblewrap, so a change's code reaches no key, login or network (issue 1309, S17, ADR 0448)
buildstep.Isolation and Isolated: its own namespaces with only a loopback, an
environment of exactly what the step is given, the system and toolchain read-only,
the workspace writable, and no home, key, bus login, runtime directory or name service.
2026-10-22 17:45:21 +02:00
jschoubben 2aab86c5cb Delete the copied list of the controller's readable states and the reads check built on it (issue 1790, S12, ADR 0495 §4-§5)
catalogue.ControllerMirrors and StateReadsNothingDeclares go, and the catalogue pass
no longer judges a read against the owner's manifest: an owner's announcement and its
own announcement test hold what it keeps.
2026-10-22 17:43:49 +02:00
jschoubben 33387ac3e5 Give every module the announcement test, on an exported test bus (issue 1790, S11, ADR 0495 §5)
announcetest.Check starts the module against a bus of the test's own and holds its
announcement equal to what its code declares; testbus leaves internal/ so a module's
own integration tests can start it.
2026-10-22 17:43:07 +02:00
jschoubben 57d2f31f05 Let a module's code announce the states it keeps, in Go and TypeScript (issue 1790, S10, ADR 0495 §2 §5)
process.Conn.Keeps, stdio.Keeps and the TypeScript keeps publish the announcement on
the module's own subject; afterwards a state the code did not announce is refused at
the call, so announcement and code cannot drift. The package goes to 0.1.15 for keeps.
2026-10-22 17:42:24 +02:00
jschoubben bb8b3bb43b Define how a module announces the states it keeps, so readers are granted from the owner's word (issue 1790, S9, ADR 0495 §2-§3)
announce.States on the module's own event subject mesh.mod.<m>.event.states, kept by
the controller in mesh-controller_states-announced by module.
2026-10-22 17:34:35 +02:00
jschoubben d13bc6ecc6 Let a key wait queued for a package version, so it is not failed before the package is published (issue 1587, S15, ADR 0500 §6)
waits names <name>@<version> on a queued key only; an older reader reads it as queued.
2026-10-22 17:31:42 +02:00
jschoubben ad1f6e6ac2 Name the build errors in one place, and which are not built again until the source changes (issue 1587, S14, ADR 0498 §2 §4 §5)
not stored, not pinned, version taken and refused kind (moved here from the builder);
FailedUntilChanged keeps an unpinned upstream or a taken version from rebuilding in a
loop, while a failed push is built again.
2026-10-22 17:31:40 +02:00
jschoubben 97895851f8 Check each done artifact's reference against its kind, and say when a key can be delivered (issue 1587, S13, ADR 0498 §1 §5 §6 §8)
A bundle or archive names its artifact-store blob, an image or upstream copy the
registry's reference to its digest (L1), a package name@version; Referenced names
the artifacts without one, since the controller delivers only from a key with all.
2026-10-22 17:31:40 +02:00
jschoubben d14a6d301c Prove on real git that a Go-only commit leaves mesh-sdk's package tree equal (issue 1758, S5, ADR 0499 §2 check) 2026-10-22 17:30:25 +02:00
jschoubben 7cf73ed76b Hand a build's fetch the named paths in a shared request, so no build module reads them from a manifest (issue 1758, ADR 0499 §4, ADR 0495 §1)
buildstep.FetchRequest is what the builder writes and the build modules read: the
repository, commit, ref, base and, for a module naming its paths, its directory and
those paths from the indexer's kept inputs.
2026-10-22 17:29:58 +02:00
jschoubben d32743499c Narrow the merge preview's tree of a module that names its paths (issue 1758, S4, ADR 0499 §2)
ModuleTrees reads each named path's object with one git ls-tree and answers their
named tree, so the preview gives the value the indexer gives; a named path absent at
the commit fails by name rather than pinning a source that does not exist.
2026-10-22 17:29:17 +02:00
jschoubben 8d65cb3016 Carry each named path's git object beside a module's other inputs (issue 1758, S3, ADR 0499 §2-§3)
Forge.Paths, PreviewModule.Paths, KeptInputs2.Paths and buildstep.ModuleTree.Paths,
each optional and read as absent by older readers; a kept or previewed tree that its
named paths do not compute to is refused, so a recompute gives the merge's value.
2026-10-22 17:27:58 +02:00
jschoubben 0c8d0f73fa Compute a module's narrowed tree in one function, so every caller gives the merge's value (issue 1758, S2, ADR 0499 §2)
delivery.NamedTree hashes the manifest digest with each named path's git object;
catalogue.Manifest.NamedPaths gives the union of the paths, only when every artifact
names some. The named: prefix keeps the value apart from a git tree id.
2026-10-22 17:27:02 +02:00
jschoubben dfc2c858d9 Share the launcher rule of a TypeScript bundle, so builder and controller name it alike (R4e, the lead's request for dev-r4e-controller)
The rule mesh-build-node and build-agent each keep their own copy of: x.js is
started by x.serve.mjs beside it; other languages get none (ADR 0193).
2026-10-22 17:25:21 +02:00
jschoubben 06fe1ee8b3 Read the manifest word paths, so a package names the files it is built from (issue 1758, S1, ADR 0499 §1)
A package or a non-Go bundle may name its build source; a Go program, an archive, an
image and an upstream artifact refuse the word by name, as do a path leaving the
module's directory, the whole directory and a pattern.
2026-10-22 17:25:02 +02:00
jschoubben bd7731f358 Count a machine that does not resolve and holds a place as on the network for its name and the mesh's sources too (issue 1833)
mesh/repo-check head bd7731f3588c merged into trunk 5652312f09d2 beside mesh-controller#437@28a278c1 — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head bd7731f3588c merged into trunk 5652312f09d2 — pass: group fix/1833-one-machine-alone composed: every machine composes with the change …
mesh/delivery delivered
mesh/delivery-group group fix/1833-one-machine-alone merged: every member is merged
mesh/check failed: unit: ..test: node:internal/modules/esm/resolve:241 | bd7731f3588c on 4fac7810a1e4 | unit 1634 run, 0 kept, 3 skipped | integration…
The tunnel kept such a machine while the names and the packet filter's sources from the mesh, read from their own
unchecked resolve, dropped it when that pass refused it too (a seat claimed twice): every other machine was sent the
hub as a peer without its name or its address. onTheNetwork keeps it by the same rule as network().
2026-10-22 15:26:48 +02:00
jschoubben c154bdedd3 Keep a machine that does not resolve on the private network at its place, so one machine's modules refuse only its own declaration (issue 1833)
A machine whose own set could not be resolved was left off the private network; when it was the hub, the network
could not be composed and every machine's declaration failed. Its placement (key and address) is its last good place
and does not change when its modules fail, so it stays on the network and only its own declaration is refused. A
refused machine with no place is left off, and a network that cannot be composed from what remains still fails.
2026-10-22 15:03:17 +02:00
jschoubben 5ea6c215a4 issue 1840/S1: let a process delete a key, as a tools bundle can 2026-10-22 14:36:24 +02:00
jschoubben 2b8ada5eb9 Merge remote-tracking branch 'origin/main' into feat/upgrade-safety 2026-10-22 14:24:43 +02:00
mesh-tools 5652312f09 Merge pull request 'Make the route proxy's bus helpers public in mesh-sdk (issue 1633, ADR 0480 §4)' (#67) from feat/r4d into main 2026-10-22 12:19:16 +00:00
jschoubben 076e1f7947 issue 1840/S2: let a process watch a state, current values first then each change, as a tools bundle can 2026-10-22 14:16:59 +02:00
jschoubben 26517ac00b issue 1840/S1: let a process put a key whole and list a state's keys, as a tools bundle can 2026-10-22 14:16:13 +02:00
jschoubben 637c33b7f8 issue 1840/S1c: name declared-packages among the controller's readable states, so a manifest may read it 2026-10-22 14:15:33 +02:00
jschoubben c61212eba7 Make the membership, its subject, stream, bare address and the pin public, so the route proxy leaves the controller's module (issue 1633)
mesh/delivery delivered
mesh/merge-gate head c61212eba77c merged into trunk c6e669f71e8e — pass: group feat/r4d composed: every machine composes with the change as it did without …
mesh/repo-check head c61212eba77c merged into trunk c6e669f71e8e — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery-group group feat/r4d merged: every member is merged
mesh/check failed: unit: ..test: node:internal/modules/esm/resolve:241 | c61212eba77c on 5652312f09d2 | unit 1629 run, 0 kept, 3 skipped | integration…
2026-10-22 09:51:01 +02:00
mesh-tools c6e669f71e Merge pull request 'The checker runs beside today's check: the SDK part (layouts, shared build-run parts, merge and module trees)' (#66) from feat/the-checker into main 2026-10-21 21:46:39 +00:00
jschoubben 580da61a91 Name plans-building among the controller's readable states, for the checker's read (issue 1687, ADR 0483 clause 2)
mesh/repo-check head 580da61a9107 merged into trunk 07a65927c362 beside mesh-catalog#423@12ff1cfb +1 more — pass: its merge-check.sh passed, each part in i…
mesh/merge-gate head 580da61a9107 merged into trunk 07a65927c362 — pass: group feat/the-checker composed: every machine composes with the change as it did …
mesh/delivery delivered
mesh/delivery-group group feat/the-checker merged: every member is merged
2026-10-21 22:44:52 +02:00
889 changed files with 4774 additions and 497281 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules/
dist/
vendor/
+126
View File
@@ -0,0 +1,126 @@
// Package announce is how a module says on the bus which states it keeps and which of them others may read (novox/hq
// ADR 0495 clauses 2 and 3; issue 1790): the subject it says it on, the value it says, and where the controller keeps
// the last one said by each module. What a module announced is the truth of what it keeps; a reader is granted a read
// from it, never from a list or from the owner's manifest.
//
// A module announces from its code (process.Conn.Keeps, stdio.Keeps, the TypeScript SDK's keeps), never from its
// manifest, which only the core reads (clause 1). A module that keeps no state on the bus announces nothing.
package announce
import (
"encoding/json"
"fmt"
"regexp"
"sort"
)
// Version is the version of the value this package writes.
const Version = 1
// Event is the event type of an announcement, under the module's own namespace.
const Event = "states"
// Subject is where module announces what it keeps: its own event subject, mesh.mod.<module>.event.states, which its
// account may already publish to and the events stream keeps.
func Subject(module string) string { return "mesh.mod." + module + ".event." + Event }
// SubjectAll is every module's announcement, what the controller consumes.
const SubjectAll = "mesh.mod.*.event." + Event
// KeptBucket is the controller's bucket of the last announcement of each module, by Key.
const KeptBucket = "mesh-controller_states-announced"
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// Key is module's key in KeptBucket: the module itself, which must be one plain name, since a module's name is part of
// its buckets' names (catalogue.StateProblems).
func Key(module string) (string, error) {
if !name.MatchString(module) {
return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module)
}
return module, nil
}
// State is one state a module keeps, by its local name, and whether others may read it. Others never write it: a
// state another module writes is the owner's own code's business.
type State struct {
Name string `json:"name"`
Readable bool `json:"readable,omitempty"`
}
// States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted
// by name. An empty list says the module keeps nothing.
type States struct {
V int `json:"v"`
Module string `json:"module"`
Node string `json:"node,omitempty"`
States []State `json:"states"`
}
// Keeps says whether the module announced keeping the state.
func (s States) Keeps(state string) bool {
for _, k := range s.States {
if k.Name == state {
return true
}
}
return false
}
// Readable says whether the module announced the state readable by others.
func (s States) Readable(state string) bool {
for _, k := range s.States {
if k.Name == state {
return k.Readable
}
}
return false
}
func (s *States) check() error {
if s.Module == "" {
return fmt.Errorf("announce: an announcement names no module")
}
if _, err := Key(s.Module); err != nil {
return err
}
seen := map[string]bool{}
for _, k := range s.States {
if !name.MatchString(k.Name) {
return fmt.Errorf("announce: %s announces the state %q, and a state is named locally: lower-case letters, digits and hyphens", s.Module, k.Name)
}
if seen[k.Name] {
return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name)
}
seen[k.Name] = true
}
return nil
}
// Encode is s as JSON at Version, its states sorted; it refuses a module or a state no bucket could be named by and a
// state announced twice, naming it.
func Encode(s States) ([]byte, error) {
if err := s.check(); err != nil {
return nil, err
}
s.V = Version
s.States = append([]State{}, s.States...)
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
return json.Marshal(s)
}
// Decode reads an announcement; it refuses another version and what Encode refuses.
func Decode(data []byte) (States, error) {
var s States
if err := json.Unmarshal(data, &s); err != nil {
return States{}, fmt.Errorf("announce: the announcement is not JSON: %w", err)
}
if s.V != Version {
return States{}, fmt.Errorf("announce: the announcement is version %d, and this reads version %d", s.V, Version)
}
if err := s.check(); err != nil {
return States{}, err
}
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
return s, nil
}
+71
View File
@@ -0,0 +1,71 @@
package announce
import (
"strings"
"testing"
)
// A module says on the bus which states it keeps and which of them others may read (novox/hq ADR 0495 clause 2), on its
// own event subject, in a versioned value the controller keeps by module.
func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{
{Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"}}})
if err != nil {
t.Fatal(err)
}
back, err := Decode(data)
if err != nil {
t.Fatal(err)
}
if back.V != Version || back.Module != "mesh-controller" || back.Node != "novox" {
t.Fatalf("decoded %+v", back)
}
var names []string
for _, s := range back.States {
names = append(names, s.Name)
}
if strings.Join(names, ",") != "assignments,own,plans-building" {
t.Fatalf("the states are not sorted: %v", names)
}
if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") {
t.Fatal("Readable does not say what was announced readable")
}
if !back.Keeps("own") || back.Keeps("absent") {
t.Fatal("Keeps does not say what was announced")
}
if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" {
t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll)
}
if KeptBucket != "mesh-controller_states-announced" {
t.Fatalf("the kept bucket is %q", KeptBucket)
}
if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" {
t.Fatalf("Key = %q, %v", k, err)
}
if nothing, err := Encode(States{Module: "quiet"}); err != nil || !strings.Contains(string(nothing), `"states":[]`) {
t.Fatalf("a module keeping nothing: %s, %v", nothing, err)
}
}
func TestAnAnnouncementRefusesWhatNoBucketCouldBe(t *testing.T) {
for _, c := range []struct {
s States
says string
}{
{States{Module: ""}, "no module"},
{States{Module: "a.b"}, `"a.b"`},
{States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`},
{States{Module: "a", States: []State{{Name: ""}}}, `""`},
{States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"},
} {
if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says)
}
}
if _, err := Decode([]byte(`{"v":2,"module":"a","states":[]}`)); err == nil {
t.Error("an unknown version was read")
}
if _, err := Key("a.b"); err == nil {
t.Error("a module with a dot became a key")
}
}
+91
View File
@@ -0,0 +1,91 @@
// Package announcetest is the test each module runs with its own checks to hold that it announces on the bus every
// state it keeps (novox/hq ADR 0495 clause 5; issue 1790): it starts the module against a bus of the test's own and
// compares what the module announced with what its code declares. With the bind guard of process.Conn.Keeps,
// stdio.Keeps and the TypeScript keeps, which refuses a state the code did not announce, announcement and code agree.
//
// It replaces the merge gate's check of one module's reads against another's manifest (clause 5); a test using it
// carries `//go:build integration`, since it starts a bus.
package announcetest
import (
"context"
"fmt"
"reflect"
"sort"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
// Wait is how long Check waits for the module's announcement once it started it.
var Wait = 10 * time.Second
// Check starts a bus of the test's own, with a stream taking module's events, calls start with its address (start
// runs the module, or its part that connects, against it, and answers how to stop it), waits for the module's
// announcement and fails the test unless it is module's and names exactly want (in any order), readable as want says.
// It answers the announcement.
func Check(t testing.TB, module string, want []announce.State, start func(url string) (stop func())) announce.States {
t.Helper()
url := testbus.URL(t)
nc, err := nats.Connect(url)
if err != nil {
t.Fatalf("announcetest: connecting to the test bus: %v", err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), Wait)
defer cancel()
stream, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ANNOUNCETEST", Subjects: []string{"mesh.mod." + module + ".event.>"}})
if err != nil {
t.Fatalf("announcetest: making the events stream: %v", err)
}
stop := start(url)
if stop != nil {
defer stop()
}
got, err := waitFor(ctx, stream, module)
if err != nil {
t.Fatalf("announcetest: %v", err)
}
if problem := differs(got, module, want); problem != "" {
t.Fatalf("announcetest: %s", problem)
}
return got
}
// waitFor reads the stream until module's announcement is in it.
func waitFor(ctx context.Context, stream jetstream.Stream, module string) (announce.States, error) {
for {
msg, err := stream.GetLastMsgForSubject(ctx, announce.Subject(module))
if err == nil {
return announce.Decode(msg.Data)
}
select {
case <-ctx.Done():
return announce.States{}, fmt.Errorf("%s announced nothing on %s within %s", module, announce.Subject(module), Wait)
case <-time.After(50 * time.Millisecond):
}
}
}
// differs says how got is not module's announcement of want, or "".
func differs(got announce.States, module string, want []announce.State) string {
if got.Module != module {
return fmt.Sprintf("the announcement names the module %q, not %q", got.Module, module)
}
w := append([]announce.State{}, want...)
sort.Slice(w, func(i, j int) bool { return w[i].Name < w[j].Name })
g := append([]announce.State{}, got.States...)
if !reflect.DeepEqual(g, w) && !(len(g) == 0 && len(w) == 0) {
return fmt.Sprintf("%s announced %v, and its code declares %v", module, g, w)
}
return ""
}
@@ -0,0 +1,29 @@
//go:build integration
package announcetest
import (
"testing"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/process"
)
// 1790/S11: a module started against the test bus announces through process.Conn.Keeps, and Check reads that
// announcement back against what the code declares.
func TestCheckReadsTheModulesAnnouncementOffARealBus(t *testing.T) {
declared := []announce.State{{Name: "accounts", Readable: true}, {Name: "drafts"}}
got := Check(t, "mesh-mail", declared, func(url string) func() {
conn, err := process.Connect(process.Account{URL: url, Module: "mesh-mail", Node: "novox", User: "u", Password: "p"})
if err != nil {
t.Fatal(err)
}
if err := conn.Keeps(declared...); err != nil {
t.Fatal(err)
}
return conn.Close
})
if got.Node != "novox" {
t.Fatalf("announced from %q", got.Node)
}
}
+32
View File
@@ -0,0 +1,32 @@
package announcetest
import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// The comparison is exact up to order: a state missing, one extra, a readability that differs or another module's
// announcement each fail, naming what differs.
func TestAnAnnouncementDiffersFromTheCodeByNameAndReadability(t *testing.T) {
got := announce.States{Module: "m", States: []announce.State{{Name: "a", Readable: true}, {Name: "b"}}}
if p := differs(got, "m", []announce.State{{Name: "b"}, {Name: "a", Readable: true}}); p != "" {
t.Fatalf("an equal announcement differs: %s", p)
}
for _, want := range [][]announce.State{
{{Name: "a", Readable: true}},
{{Name: "a", Readable: true}, {Name: "b"}, {Name: "c"}},
{{Name: "a"}, {Name: "b"}},
} {
if p := differs(got, "m", want); p == "" || !strings.Contains(p, "declares") {
t.Errorf("want %v: %q", want, p)
}
}
if p := differs(got, "other", nil); !strings.Contains(p, `"other"`) {
t.Errorf("another module's announcement: %q", p)
}
if p := differs(announce.States{Module: "m"}, "m", nil); p != "" {
t.Errorf("a module keeping nothing: %q", p)
}
}
+170
View File
@@ -0,0 +1,170 @@
package buildstep
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"syscall"
"time"
)
// RegistryWait is how long a push waits for an artifact store that refuses connections, longer than its nightly
// collection holds it still (about a minute and a half, issue 457); RegistryFirstPause is the first pause, doubling
// to RegistryMostPause. Variables so a test shortens them.
var (
RegistryWait = 5 * time.Minute
RegistryFirstPause = time.Second
RegistryMostPause = 10 * time.Second
)
// BlobReference is where a pushed bundle or archive is, as a done key names it and the controller delivers from
// (novox/hq ADR 0498 clause 1): artifact-store://<module>/<artifact>/blobs/<digest>, the store's address left out
// (issue 102).
func BlobReference(module, artifact, digest string) string {
return "artifact-store://" + module + "/" + artifact + "/blobs/" + digest
}
// PushBlob puts body into the artifact store at registry (host:port) as a blob of repository (<module>/<artifact>) by
// its digest (`sha256:` and hex), from the build module's own process, after the build step ended (novox/hq ADR 0498
// clauses 1 and 2): a blob the store already holds is done without an upload; otherwise it is uploaded (POST
// blobs/uploads/, then PUT with the digest). A store refusing connections is waited out up to RegistryWait; any other
// refusal fails, naming the store's status and its own words, for the caller's `not stored:` error. Bytes that are not
// their digest are refused before anything is sent.
func PushBlob(ctx context.Context, client *http.Client, registry, repository, digest string, body []byte) error {
sum := sha256.Sum256(body)
if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest {
return fmt.Errorf("the blob is %s, not the digest %s it was to be pushed as", got, digest)
}
if client == nil {
client = http.DefaultClient
}
base := "http://" + registry + "/v2/" + repository + "/blobs/"
var present bool
if err := waitForStore(ctx, registry, func() error {
var err error
present, err = blobPresent(ctx, client, base+digest)
return err
}); err != nil {
return err
}
if present {
return nil
}
var location string
if err := waitForStore(ctx, registry, func() error {
var err error
location, err = startUpload(ctx, client, base+"uploads/")
return err
}); err != nil {
return err
}
target, err := url.Parse(base)
if err != nil {
return err
}
loc, err := target.Parse(location)
if err != nil {
return fmt.Errorf("the store answered the upload's location %q, which is not a URL: %w", location, err)
}
q := loc.Query()
q.Set("digest", digest)
loc.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, loc.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("putting %s of %s: %w", digest, repository, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return storeSaid(resp, "putting "+digest+" of "+repository)
}
return nil
}
func blobPresent(ctx context.Context, client *http.Client, at string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, at, nil)
if err != nil {
return false, err
}
resp, err := client.Do(req)
if err != nil {
return false, err
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
}
return false, fmt.Errorf("asking the store for %s: it answered %s", at, resp.Status)
}
func startUpload(ctx context.Context, client *http.Client, at string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, at, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
return "", storeSaid(resp, "starting an upload at "+at)
}
location := resp.Header.Get("Location")
if location == "" {
return "", fmt.Errorf("starting an upload at %s: the store named no location", at)
}
return location, nil
}
// storeSaid is the store's refusal: its status and the first of its own words.
func storeSaid(resp *http.Response, what string) error {
words, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("%s: the store answered %s: %s", what, resp.Status, strings.TrimSpace(string(words)))
}
// refused is whether an error is a connection refused.
func refused(err error) bool {
return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused"))
}
// waitForStore runs try, and while it fails because the store refuses connections, tries again with a growing pause
// until RegistryWait has passed. A refusal is the one error waited for: nothing was sent, so trying again does nothing
// twice.
func waitForStore(ctx context.Context, registry string, try func() error) error {
err := try()
if !refused(err) {
return err
}
started, pause := time.Now(), RegistryFirstPause
for {
left := RegistryWait - time.Since(started)
if left <= 0 {
return fmt.Errorf("the artifact store at %s refused every connection for %s, longer than its nightly collection holds it still, so it is down, not paused: %w",
registry, time.Since(started).Round(time.Millisecond), err)
}
select {
case <-ctx.Done():
return fmt.Errorf("stopped while waiting for the artifact store at %s: %w (last: %v)", registry, ctx.Err(), err)
case <-time.After(min(pause, left)):
}
pause = min(pause*2, RegistryMostPause)
if err = try(); !refused(err) {
return err
}
}
}
+151
View File
@@ -0,0 +1,151 @@
package buildstep
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
func digestOfBytes(b []byte) string {
h := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(h[:])
}
// registry is an artifact store as far as a blob push needs one: HEAD a blob, start an upload, PUT it with its digest.
type registry struct {
mu sync.Mutex
blobs map[string][]byte
calls []string
put int // the status a PUT answers, 201 when 0
}
func (r *registry) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, req.Method+" "+req.URL.Path)
switch {
case req.Method == http.MethodHead && strings.Contains(req.URL.Path, "/blobs/sha256:"):
if _, ok := r.blobs[req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]]; ok {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
case req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/blobs/uploads/"):
w.Header().Set("Location", strings.TrimSuffix(req.URL.Path, "/")+"/u1?state=x")
w.WriteHeader(http.StatusAccepted)
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/blobs/uploads/u1"):
if r.put != 0 {
w.WriteHeader(r.put)
_, _ = io.WriteString(w, `{"errors":[{"code":"DIGEST_INVALID","message":"provided digest did not match uploaded content"}]}`)
return
}
body, _ := io.ReadAll(req.Body)
d := req.URL.Query().Get("digest")
if req.URL.Query().Get("state") != "x" || d != digestOfBytes(body) {
w.WriteHeader(http.StatusBadRequest)
return
}
r.blobs[d] = body
w.WriteHeader(http.StatusCreated)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// A done build's bundle or archive is pushed to the artifact store by its build module (novox/hq ADR 0498 clauses 1
// and 2): a blob the store already holds is done with no upload; one it lacks is uploaded by digest; its reference is
// the artifact-store form the builder's key and the controller check.
func TestABlobIsPushedOnceByItsDigest(t *testing.T) {
reg := &registry{blobs: map[string][]byte{}}
srv := httptest.NewServer(reg)
defer srv.Close()
addr := strings.TrimPrefix(srv.URL, "http://")
body := []byte("the packed bundle")
d := digestOfBytes(body)
if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil {
t.Fatal(err)
}
if string(reg.blobs[d]) != string(body) {
t.Fatalf("the store holds %q", reg.blobs[d])
}
reg.calls = nil
if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil {
t.Fatal(err)
}
if len(reg.calls) != 1 || !strings.HasPrefix(reg.calls[0], "HEAD ") {
t.Fatalf("a blob the store holds was uploaded again: %v", reg.calls)
}
ref := BlobReference("mail", "code", d)
if ref != "artifact-store://mail/code/blobs/"+d {
t.Fatalf("BlobReference = %q", ref)
}
b := delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("4", 64),
Artifacts: []delivery.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: d, Reference: ref}}}
if _, err := delivery.EncodeBuilderBuild(b); err != nil {
t.Fatalf("the key refuses the reference BlobReference gives: %v", err)
}
}
// A store that refuses the upload fails the push naming the store's own words; bytes that are not the digest are
// refused before anything is sent.
func TestAPushTheStoreRefusesSaysTheStoresWords(t *testing.T) {
reg := &registry{blobs: map[string][]byte{}, put: http.StatusBadRequest}
srv := httptest.NewServer(reg)
defer srv.Close()
addr := strings.TrimPrefix(srv.URL, "http://")
body := []byte("x")
err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes(body), body)
if err == nil || !strings.Contains(err.Error(), "provided digest did not match") || !strings.Contains(err.Error(), "400") {
t.Fatalf("a refused upload said %v", err)
}
reg.calls = nil
err = PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes([]byte("other")), body)
if err == nil || len(reg.calls) != 0 {
t.Fatalf("bytes that are not their digest: %v, calls %v", err, reg.calls)
}
}
// A store refusing connections (its nightly collection holds it still) is waited out, up to RegistryWait (issue 457).
func TestAPushWaitsOutAStoreRefusingConnections(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := l.Addr().String()
l.Close() // refusing now
reg := &registry{blobs: map[string][]byte{}}
go func() {
time.Sleep(300 * time.Millisecond)
l2, err := net.Listen("tcp", addr)
if err != nil {
return
}
_ = http.Serve(l2, reg)
}()
beforeWait, beforePause := RegistryWait, RegistryFirstPause
RegistryWait, RegistryFirstPause = 3*time.Second, 100*time.Millisecond
defer func() { RegistryWait, RegistryFirstPause = beforeWait, beforePause }()
body := []byte("late")
if err := PushBlob(context.Background(), http.DefaultClient, addr, "mail/code", digestOfBytes(body), body); err != nil {
t.Fatalf("a store that came back was not waited for: %v", err)
}
l3, _ := net.Listen("tcp", "127.0.0.1:0")
gone := l3.Addr().String()
l3.Close()
RegistryWait = 300 * time.Millisecond
err = PushBlob(context.Background(), http.DefaultClient, gone, "mail/code", digestOfBytes(body), body)
if err == nil || !strings.Contains(err.Error(), "refused every connection") {
t.Fatalf("a store that stayed down said %v", err)
}
}
+175
View File
@@ -0,0 +1,175 @@
package buildstep
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Bubblewrap is the program a run step runs in (novox/hq ADR 0448): a build module declares its package, which needs
// unprivileged user namespaces (issue 1308).
const Bubblewrap = "bwrap"
// Isolation is how a run step's command is shut off (novox/hq ADR 0448; issue 1309, the lead's decision L2 of R4e: one
// runner in the SDK for every build module). The fetch step holds the forge key and the network and runs none of the
// change's code; the run step runs the change's build and tests in bubblewrap with:
//
// - no network: its own network namespace, with only a loopback, so a test may start a bus or a store of its own but
// resolves no name and reaches no socket of the machine;
// - none of the account: not its home, not the forge key, not a bus login, not the runtime directory (/run), since
// none of them is bound; HOME is a directory in the step's own /tmp unless Env names one;
// - an environment of exactly Env and the runner's env: nothing of the build module's own passes;
// - the system's programs and libraries read-only, and of /etc only what a program needs to run (users, libraries,
// certificates, time zone), with a hosts file naming only the loopback;
// - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step
// downloaded) read-only;
// - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone
// (WriteCaches), so a candidate's code never changes what a later build reads.
type Isolation struct {
Workspace string
Writable []string
ReadOnly []string
Caches []string
WriteCaches bool
Env []string
// Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback.
Hosts string
}
// etcNeeded is what of /etc a program in the step may read: who the users are, where the libraries are, the
// certificates, the time zone. Not resolv.conf, not hosts: the step resolves no name.
var etcNeeded = []string{"/etc/passwd", "/etc/group", "/etc/nsswitch.conf", "/etc/ld.so.cache", "/etc/ld.so.conf",
"/etc/ld.so.conf.d", "/etc/ssl", "/etc/ca-certificates", "/etc/pki", "/etc/localtime", "/etc/os-release", "/etc/alternatives"}
// systemArgs binds the system's programs and libraries read-only: /usr, and /bin, /sbin, /lib, /lib64 as the symlinks
// they are on a merged-usr system, or bound when they are directories. A variable so a unit test fixes it.
var systemArgs = func() []string {
args := []string{"--ro-bind", "/usr", "/usr"}
for _, d := range []string{"/bin", "/sbin", "/lib", "/lib64"} {
info, err := os.Lstat(d)
switch {
case err != nil:
case info.Mode()&os.ModeSymlink != 0:
if target, err := os.Readlink(d); err == nil {
args = append(args, "--symlink", target, d)
}
case info.IsDir():
args = append(args, "--ro-bind", d, d)
}
}
for _, e := range etcNeeded {
args = append(args, "--ro-bind-try", e, e)
}
return args
}
// stepHome is the HOME of a step that is given none: inside its own /tmp, gone with it.
const stepHome = "/tmp/home"
func checkDir(what, dir string) error {
if dir == "" || !strings.HasPrefix(dir, "/") || path.Clean(dir) != dir || dir == "/" {
return fmt.Errorf("buildstep: the %s %q is not an absolute, clean directory other than the root", what, dir)
}
return nil
}
// Command is the bubblewrap command line that runs name with args shut off as iso says, env added to iso.Env. It
// refuses a directory that is not absolute and clean, or is the root, and an environment word without "=".
func (iso Isolation) Command(name string, args ...string) ([]string, error) {
return iso.command(nil, name, args...)
}
func (iso Isolation) command(env []string, name string, args ...string) ([]string, error) {
if err := checkDir("workspace", iso.Workspace); err != nil {
return nil, err
}
for _, group := range []struct {
what string
dirs []string
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}} {
for _, d := range group.dirs {
if err := checkDir(group.what, d); err != nil {
return nil, err
}
}
}
words := append(append([]string{}, iso.Env...), env...)
home := false
for _, w := range words {
k, _, ok := strings.Cut(w, "=")
if !ok || k == "" {
return nil, fmt.Errorf("buildstep: the environment word %q is not NAME=value", w)
}
home = home || k == "HOME"
}
argv := []string{"--unshare-all", "--die-with-parent", "--new-session", "--clearenv",
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", stepHome}
argv = append(argv, systemArgs()...)
for _, d := range iso.ReadOnly {
argv = append(argv, "--ro-bind", d, d)
}
argv = append(argv, cacheArgs(iso.Caches, iso.WriteCaches)...)
argv = append(argv, "--bind", iso.Workspace, iso.Workspace)
for _, d := range iso.Writable {
argv = append(argv, "--bind", d, d)
}
if iso.Hosts != "" {
argv = append(argv, "--ro-bind", iso.Hosts, "/etc/hosts")
}
for _, w := range words {
k, v, _ := strings.Cut(w, "=")
argv = append(argv, "--setenv", k, v)
}
if !home {
argv = append(argv, "--setenv", "HOME", stepHome)
}
argv = append(argv, "--chdir", iso.Workspace, "--", name)
return append(argv, args...), nil
}
// cacheArgs binds the shared warm caches (S18): for a trunk build read-write, so the next build is warm; for any other
// read-only beneath an overlay whose writes go to a tmpfs of the step's own, so the step writes as a build does and
// nothing it wrote outlives it (bubblewrap's --tmp-overlay, an overlay mount in the step's user namespace).
func cacheArgs(caches []string, write bool) []string {
var argv []string
for _, c := range caches {
if write {
argv = append(argv, "--bind", c, c)
continue
}
argv = append(argv, "--overlay-src", c, "--tmp-overlay", c)
}
return argv
}
// loopbackHosts is the hosts file of a step: the loopback and nothing else.
const loopbackHosts = "127.0.0.1 localhost\n::1 localhost\n"
// Isolated is run with every command shut off as iso says (Command), run's env going inside the step: the build modules'
// run steps call their test, build and digest commands through it, never run itself. Each command gets a hosts file of
// its own, naming only the loopback, removed when it ends.
func Isolated(run forgekey.Runner, iso Isolation) forgekey.Runner {
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
dir, err := os.MkdirTemp("", "step-hosts-")
if err != nil {
return forgekey.Ran{Status: 1, Err: "the step's hosts file: " + err.Error()}
}
defer os.RemoveAll(dir)
hosts := filepath.Join(dir, "hosts")
if err := os.WriteFile(hosts, []byte(loopbackHosts), 0o644); err != nil {
return forgekey.Ran{Status: 1, Err: "the step's hosts file: " + err.Error()}
}
iso := iso
iso.Hosts = hosts
argv, err := iso.command(env, name, args...)
if err != nil {
return forgekey.Ran{Status: 1, Err: err.Error()}
}
return run(ctx, nil, Bubblewrap, argv...)
}
}
+30
View File
@@ -0,0 +1,30 @@
package buildstep
import "testing"
// The shared warm caches are read under a throwaway writable layer (novox/hq ADR 0448; issue 1309): a candidate's step
// writes into them as a build does, and nothing it writes outlives it or reaches a later build; only a trunk build
// writes them.
func TestACandidatesStepWritesTheCachesOnlyIntoAThrowawayLayer(t *testing.T) {
fixedSystem(t)
iso := Isolation{Workspace: "/w", Caches: []string{"/var/cache/go-build", "/var/cache/npm"}}
argv, err := iso.Command("go", "test")
if err != nil {
t.Fatal(err)
}
for _, c := range iso.Caches {
if !holds(argv, "--overlay-src", c, "--tmp-overlay", c) {
t.Errorf("the cache %s is not under a throwaway layer: %v", c, argv)
}
if holds(argv, "--bind", c, c) {
t.Errorf("a candidate's step writes the cache %s: %v", c, argv)
}
}
iso.WriteCaches = true
argv, _ = iso.Command("go", "build")
for _, c := range iso.Caches {
if !holds(argv, "--bind", c, c) || holds(argv, "--tmp-overlay", c) {
t.Errorf("a trunk build does not write the cache %s: %v", c, argv)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//go:build integration && isolation
package buildstep
import (
"context"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 1309/S19, the issue's proof: a run step, in bubblewrap as Isolated runs it, reads no key and no login, resolves no
// name, reaches no TCP or unix socket of the machine, finds no home of the account, and leaves the shared cache it
// wrote unchanged; the workspace it writes keeps what it wrote. It needs bubblewrap and unprivileged user namespaces
// (issue 1308) on the machine that runs it, and fails naming them when they are not there.
func TestARunStepReachesNothingAndLeavesTheCachesUnchanged(t *testing.T) {
if _, err := exec.LookPath(Bubblewrap); err != nil {
t.Fatalf("bubblewrap is not installed here, so the isolation of a run step cannot be proved (issue 1309): %v", err)
}
root := t.TempDir()
account := filepath.Join(root, "account")
key, login := filepath.Join(account, ".ssh", "id_ed25519"), filepath.Join(account, "bus-login.json")
writeFile(t, filepath.Dir(key), filepath.Base(key), "PRIVATE KEY")
writeFile(t, account, "bus-login.json", `{"password":"secret"}`)
cacheDir := filepath.Join(root, "cache")
writeFile(t, cacheDir, "warm", "warm\n")
workspace := filepath.Join(root, "work", "tree")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatal(err)
}
probe := filepath.Join(workspace, "probe")
if out, err := exec.Command("go", "build", "-o", probe, "./testdata/isolated-probe").CombinedOutput(); err != nil {
t.Fatalf("building the probe: %v\n%s", err, out)
}
tcp, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer tcp.Close()
socket := filepath.Join(account, "bus.sock")
unix, err := net.Listen("unix", socket)
if err != nil {
t.Fatal(err)
}
defer unix.Close()
run := Isolated(forgekey.ExecRunnerFor(0), Isolation{Workspace: workspace, Caches: []string{cacheDir}})
r := run(context.Background(), []string{"PATH=/usr/bin:/bin"}, probe, key, login, tcp.Addr().String(), socket, "localhost.localdomain.invalid", filepath.Join(cacheDir, "warm"))
if r.Status != 0 || r.Err != "" || strings.TrimSpace(r.Stdout) != "shut off" {
t.Fatalf("the run step was not shut off (status %d, %s):\n%s%s", r.Status, r.Err, r.Stdout, r.Stderr)
}
if b, _ := os.ReadFile(filepath.Join(cacheDir, "warm")); string(b) != "warm\n" {
t.Fatalf("the step changed the shared cache: %q", b)
}
// The workspace is the step's own: what it writes there stays.
r = run(context.Background(), nil, "/bin/sh", "-c", "echo built > out")
if b, err := os.ReadFile(filepath.Join(workspace, "out")); r.Status != 0 || err != nil || string(b) != "built\n" {
t.Fatalf("the step's write to its workspace did not stay (status %d, %s%s): %q %v", r.Status, r.Stderr, r.Err, b, err)
}
}
+115
View File
@@ -0,0 +1,115 @@
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// pairs says whether argv holds the words of want one after another.
func holds(argv []string, want ...string) bool {
for i := 0; i+len(want) <= len(argv); i++ {
match := true
for j, w := range want {
if argv[i+j] != w {
match = false
break
}
}
if match {
return true
}
}
return false
}
func fixedSystem(t *testing.T) {
t.Helper()
before := systemArgs
systemArgs = func() []string { return []string{"--ro-bind", "/usr", "/usr", "--symlink", "usr/bin", "/bin"} }
t.Cleanup(func() { systemArgs = before })
}
// A run step's command runs in bubblewrap (novox/hq ADR 0448; issue 1309): no network, no namespace shared, an
// environment of exactly what the step is given, the workspace the one tree it writes, the toolchain read-only, and
// nothing else of the machine's: not the account's home, not the forge key or a bus login, not the runtime directory,
// and no name service.
func TestARunStepRunsShutOffInBubblewrap(t *testing.T) {
fixedSystem(t)
iso := Isolation{Workspace: "/work/ab12/tree", ReadOnly: []string{"/usr/lib/go"}, Writable: []string{"/work/ab12/out"},
Env: []string{"GOFLAGS=-mod=mod", "PATH=/usr/bin"}, Hosts: "/work/ab12/hosts"}
argv, err := iso.Command("go", "test", "./...")
if err != nil {
t.Fatal(err)
}
for _, want := range [][]string{
{"--unshare-all"}, {"--die-with-parent"}, {"--new-session"}, {"--clearenv"},
{"--proc", "/proc"}, {"--dev", "/dev"}, {"--tmpfs", "/tmp"},
{"--ro-bind", "/usr", "/usr"}, {"--symlink", "usr/bin", "/bin"},
{"--ro-bind", "/usr/lib/go", "/usr/lib/go"},
{"--bind", "/work/ab12/tree", "/work/ab12/tree"}, {"--bind", "/work/ab12/out", "/work/ab12/out"},
{"--ro-bind", "/work/ab12/hosts", "/etc/hosts"},
{"--setenv", "GOFLAGS", "-mod=mod"}, {"--setenv", "PATH", "/usr/bin"}, {"--setenv", "HOME", "/tmp/home"},
{"--chdir", "/work/ab12/tree"},
{"--", "go", "test", "./..."},
} {
if !holds(argv, want...) {
t.Errorf("the command has no %v: %v", want, argv)
}
}
if holds(argv, "--share-net") || strings.Contains(strings.Join(argv, " "), "resolv.conf") || strings.Contains(strings.Join(argv, " "), "/run") {
t.Errorf("the command reaches the network, the name service or the runtime directory: %v", argv)
}
// The workspace is mounted after /tmp's tmpfs, so a workspace under /tmp is not hidden by it.
if strings.Index(strings.Join(argv, "\x00"), "--tmpfs\x00/tmp") > strings.Index(strings.Join(argv, "\x00"), "--bind\x00/work/ab12/tree") {
t.Errorf("the tmpfs of /tmp comes after the workspace: %v", argv)
}
// A HOME the step is given is its own.
iso.Env = append(iso.Env, "HOME=/work/ab12/home")
argv, _ = iso.Command("true")
if holds(argv, "--setenv", "HOME", "/tmp/home") || !holds(argv, "--setenv", "HOME", "/work/ab12/home") {
t.Errorf("the step's own HOME was replaced: %v", argv)
}
}
func TestAnIsolationNamesNoPathItCannotPin(t *testing.T) {
for _, iso := range []Isolation{
{Workspace: ""},
{Workspace: "tree"},
{Workspace: "/"},
{Workspace: "/w", ReadOnly: []string{"relative"}},
{Workspace: "/w", Writable: []string{"/"}},
{Workspace: "/w", Caches: []string{"/a/../b"}},
{Workspace: "/w", Env: []string{"NOEQUALS"}},
} {
if _, err := iso.Command("true"); err == nil {
t.Errorf("%+v was accepted", iso)
}
}
}
// Isolated runs every command through bwrap, with the step's environment inside and a hosts file that names only the
// loopback, removed after.
func TestIsolatedRunsEachCommandThroughBubblewrap(t *testing.T) {
fixedSystem(t)
var ran []string
var hosts string
inner := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
ran = append([]string{name}, args...)
for i, a := range args {
if a == "/etc/hosts" && i > 0 {
hosts = args[i-1]
}
}
return forgekey.Ran{Stdout: "ok"}
}
r := Isolated(inner, Isolation{Workspace: "/w"})(context.Background(), []string{"A=1"}, "go", "build")
if r.Stdout != "ok" || ran[0] != Bubblewrap || !holds(ran, "--setenv", "A", "1") || !holds(ran, "--", "go", "build") {
t.Fatalf("ran %v: %+v", ran, r)
}
if hosts == "" {
t.Fatalf("no hosts file was bound: %v", ran)
}
}
+24
View File
@@ -0,0 +1,24 @@
package buildstep
import "strings"
// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as
// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says.
const LauncherSuffix = ".serve.mjs"
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root: for TypeScript
// every entrypoint `x.js` is started by `x.serve.mjs` beside it; an entrypoint that is not JavaScript and every other
// language, whose build is already executable, get none. The one rule mesh-build-node, build-agent and the controller
// share (R4e), so the name a build writes is the name a reader expects.
func Launchers(language string, entrypoints []string) map[string]string {
out := map[string]string{}
if language != "typescript" {
return out
}
for _, entry := range entrypoints {
if strings.HasSuffix(entry, ".js") {
out[entry] = strings.TrimSuffix(entry, ".js") + LauncherSuffix
}
}
return out
}
+22
View File
@@ -0,0 +1,22 @@
package buildstep
import (
"reflect"
"testing"
)
// A TypeScript bundle's entrypoint x.js is started as x.serve.mjs beside it (novox/hq ADR 0193), the rule mesh-build-node
// and build-agent both apply; an entrypoint that is not .js gets none, and another language gets none.
func TestATypeScriptEntrypointIsStartedByTheLauncherBesideIt(t *testing.T) {
got := Launchers("typescript", []string{"index.js", "tools/x.js", "data.json"})
want := map[string]string{"index.js": "index.serve.mjs", "tools/x.js": "tools/x.serve.mjs"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Launchers(typescript) = %v, want %v", got, want)
}
if got := Launchers("go", []string{"index.js"}); len(got) != 0 {
t.Fatalf("a Go bundle got launchers: %v", got)
}
if got := Launchers("typescript", nil); len(got) != 0 {
t.Fatalf("a bundle with no entrypoint got launchers: %v", got)
}
}
+71
View File
@@ -0,0 +1,71 @@
package buildstep
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
)
// narrowKept are the files of a module's directory a narrowed build keeps beside its named paths: its manifest and
// its lock files, each an input of the module's key (ModuleTree's Manifest and Locks). package.json and go.mod are
// not kept: neither is in the named tree, so a build that needs one names it among its artifact's paths, which puts
// it in the key (novox/hq ADR 0499 clause 4).
var narrowKept = append([]string{"module.json"}, lockFiles...)
// Narrow leaves in the fetched tree only what a build of an artifact that names its paths may see (novox/hq ADR 0499
// clause 4): r.Dir's named paths, its manifest and lock files, and .git; everything else is removed, so a file outside
// them cannot change what is built without changing its key. A named path absent at the tree is an error naming it,
// and nothing is removed then. With no paths in r it changes nothing. Both build modules' fetch steps call it.
func Narrow(tree string, r FetchRequest) error {
if len(r.Paths) == 0 {
return nil
}
keep := map[string]bool{".git": true}
for _, p := range r.Paths {
rel := path.Join(r.Dir, p)
if strings.HasPrefix(rel, "../") || rel == ".." || path.IsAbs(p) {
return fmt.Errorf("the named path %q leaves the module's directory", p)
}
if _, err := os.Lstat(filepath.Join(tree, filepath.FromSlash(rel))); err != nil {
return fmt.Errorf("the named path %q is not in the fetched tree: %w", p, err)
}
keep[rel] = true
}
for _, f := range narrowKept {
keep[path.Join(r.Dir, f)] = true
}
return prune(tree, "", keep)
}
// prune removes from dir (rel within the tree) every entry that is neither kept nor on the way to one.
func prune(root, rel string, keep map[string]bool) error {
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
return err
}
for _, e := range entries {
p := path.Join(rel, e.Name())
if keep[p] {
continue
}
ancestor := false
for k := range keep {
if strings.HasPrefix(k, p+"/") {
ancestor = true
break
}
}
if ancestor && e.IsDir() {
if err := prune(root, p, keep); err != nil {
return err
}
continue
}
if err := os.RemoveAll(filepath.Join(root, filepath.FromSlash(p))); err != nil {
return err
}
}
return nil
}
+82
View File
@@ -0,0 +1,82 @@
package buildstep
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
func filesUnder(t *testing.T, root string) []string {
t.Helper()
var out []string
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
rel, _ := filepath.Rel(root, p)
out = append(out, filepath.ToSlash(rel))
}
return nil
})
sort.Strings(out)
return out
}
// A build of an artifact that names its paths sees only those paths, its manifest and its lock files (novox/hq ADR 0499
// clause 4): Narrow removes everything else of the fetched tree but .git, package.json and go.mod among it, since
// neither is in the named tree nor among the lock files; with no paths it changes nothing.
func TestNarrowLeavesOnlyTheNamedPathsTheManifestAndTheLocks(t *testing.T) {
tree := t.TempDir()
for _, f := range []string{".git/HEAD", "module.json", "package.json", "package-lock.json", "go.sum", "src/index.ts",
"src/deep/a.ts", "test/a.test.ts", "tsconfig.json", "go/delivery/x.go", "README.md", "other/module.json"} {
writeTreeFile(t, tree, f, "x")
}
if err := Narrow(tree, FetchRequest{}); err != nil {
t.Fatal(err)
}
if got := len(filesUnder(t, tree)); got != 12 {
t.Fatalf("a request without paths changed the tree: %d files", got)
}
if err := Narrow(tree, FetchRequest{Dir: ".", Paths: []string{"src", "test", "tsconfig.json"}}); err != nil {
t.Fatal(err)
}
want := ".git/HEAD,go.sum,module.json,package-lock.json,src/deep/a.ts,src/index.ts,test/a.test.ts,tsconfig.json"
if got := strings.Join(filesUnder(t, tree), ","); got != want {
t.Fatalf("the narrowed tree holds %s\nwant %s", got, want)
}
}
func TestNarrowKeepsAModuleDirectorysPathsAndFailsOnAnAbsentOne(t *testing.T) {
tree := t.TempDir()
for _, f := range []string{".git/config", "modules/a/module.json", "modules/a/go.mod", "modules/a/src/x.ts",
"modules/a/notes.md", "modules/b/module.json", "root.txt"} {
writeTreeFile(t, tree, f, "x")
}
err := Narrow(tree, FetchRequest{Dir: "modules/a", Paths: []string{"src", "gone"}})
if err == nil || !strings.Contains(err.Error(), `"gone"`) {
t.Fatalf("an absent named path gave %v", err)
}
if len(filesUnder(t, tree)) != 7 {
t.Fatal("a refused narrowing removed files")
}
if err := Narrow(tree, FetchRequest{Dir: "modules/a", Paths: []string{"src"}}); err != nil {
t.Fatal(err)
}
if got := strings.Join(filesUnder(t, tree), ","); got != ".git/config,modules/a/module.json,modules/a/src/x.ts" {
t.Fatalf("the narrowed tree holds %s", got)
}
}
// A build that needs package.json or go.mod names it among its paths, and so keys on it (ADR 0499 clause 4).
func TestNarrowKeepsAManifestOfTheLanguageOnlyWhenNamed(t *testing.T) {
tree := t.TempDir()
for _, f := range []string{"module.json", "package.json", "go.mod", "src/a.ts"} {
writeTreeFile(t, tree, f, "x")
}
if err := Narrow(tree, FetchRequest{Dir: ".", Paths: []string{"src", "package.json"}}); err != nil {
t.Fatal(err)
}
if got := strings.Join(filesUnder(t, tree), ","); got != "module.json,package.json,src/a.ts" {
t.Fatalf("the narrowed tree holds %s", got)
}
}
+115
View File
@@ -0,0 +1,115 @@
package buildstep
import (
"fmt"
"path"
"regexp"
"strings"
)
var (
requestCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
requestRepositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
)
// FetchRequest is what a build module's fetch tool is asked: the repository and the full commit, the pull request's
// ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, and, for a build of a
// module whose every artifact names its paths, that module's directory and its named paths (novox/hq ADR 0499 clause 4).
//
// **The paths come from the builder, not from a manifest** (ADR 0495 clause 1; the lead's decision L8 of R4e): the
// builder fills Dir and Paths from the indexer's Module value (Path, and KeptInputs2.Paths' keys), and the build module
// gives the build and test steps a workspace holding those paths, the manifest and its lock files. A request without
// them builds from the module's directory, as before.
type FetchRequest struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
Ref string `json:"ref,omitempty"`
Base string `json:"base,omitempty"`
Dir string `json:"dir,omitempty"`
Paths []string `json:"paths,omitempty"`
}
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
func FetchRequestProperties() map[string]any {
return map[string]any{
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
}
}
// Args is r as a tool call's arguments.
func (r FetchRequest) Args() map[string]any {
args := map[string]any{"repository": r.Repository, "commit": r.Commit}
for k, v := range map[string]string{"ref": r.Ref, "base": r.Base, "dir": r.Dir} {
if v != "" {
args[k] = v
}
}
if len(r.Paths) > 0 {
args["paths"] = append([]string(nil), r.Paths...)
}
return args
}
// ParseFetchRequest reads a fetch tool's arguments; arguments it does not know (a build module's own) are left to the
// caller. It refuses a repository that is not <owner>/<name>, a commit or a base that is not 40 hex digits, paths with no
// module directory or a directory with no path, a directory leaving the repository and a path leaving the directory,
// naming each.
func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
var r FetchRequest
r.Repository, _ = args["repository"].(string)
r.Commit, _ = args["commit"].(string)
r.Ref, _ = args["ref"].(string)
r.Base, _ = args["base"].(string)
r.Dir, _ = args["dir"].(string)
switch v := args["paths"].(type) {
case nil:
case []string:
r.Paths = append([]string(nil), v...)
case []any:
for _, p := range v {
s, ok := p.(string)
if !ok {
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
}
r.Paths = append(r.Paths, s)
}
default:
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
}
return r, r.Check()
}
// Check is what ParseFetchRequest refuses.
func (r FetchRequest) Check() error {
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
return fmt.Errorf("%q is not a repository, <owner>/<name>", r.Repository)
}
if !requestCommitOK.MatchString(r.Commit) {
return fmt.Errorf("%q is not a full commit (40 hex digits)", r.Commit)
}
if r.Base != "" && !requestCommitOK.MatchString(r.Base) {
return fmt.Errorf("%q is not a trunk commit (40 hex digits)", r.Base)
}
switch {
case len(r.Paths) > 0 && r.Dir == "":
return fmt.Errorf("the request names paths and names no module directory (dir) they are relative to")
case len(r.Paths) == 0 && r.Dir != "":
return fmt.Errorf("the request names a module directory and no path")
}
if r.Dir != "" && (outside(r.Dir) || strings.HasPrefix(r.Dir, "/")) {
return fmt.Errorf("the module directory %q leaves the repository", r.Dir)
}
for _, p := range r.Paths {
if p == "" || strings.HasPrefix(p, "/") || outside(p) || path.Clean(p) == "." {
return fmt.Errorf("the path %q leaves the module's directory or is not a path in it", p)
}
}
return nil
}
func outside(p string) bool {
c := path.Clean(p)
return c == ".." || strings.HasPrefix(c, "../")
}
+57
View File
@@ -0,0 +1,57 @@
package buildstep
import (
"reflect"
"strings"
"testing"
)
// A build's fetch is handed the paths of a module that names them (novox/hq ADR 0499 clause 4): the builder fills them
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
// clause 1). The request survives the trip through a tool call's arguments.
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
Dir: ".", Paths: []string{"package.json", "src"}}
args := req.Args()
// A tool call's arguments arrive as JSON: lists of any.
args["paths"] = []any{"package.json", "src"}
got, err := ParseFetchRequest(args)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, req) {
t.Fatalf("got %+v, want %+v", got, req)
}
plain, err := ParseFetchRequest(map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40),
"base": strings.Repeat("c", 40), "beside": []any{}})
if err != nil || plain.Paths != nil || plain.Base != strings.Repeat("c", 40) {
t.Fatalf("a request without paths: %+v, %v", plain, err)
}
ok := map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40)}
for _, c := range []struct {
set map[string]any
says string
}{
{map[string]any{"repository": "novox"}, "is not a repository"},
{map[string]any{"repository": "novox/../x"}, "is not a repository"},
{map[string]any{"commit": "abc"}, "is not a full commit"},
{map[string]any{"base": "abc"}, "is not a trunk commit"},
{map[string]any{"paths": []any{"src"}}, "names no module directory"},
{map[string]any{"dir": "modules/a", "paths": []any{"../b"}}, "leaves the module's directory"},
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
} {
args := map[string]any{}
for k, v := range ok {
args[k] = v
}
for k, v := range c.set {
args[k] = v
}
if _, err := ParseFetchRequest(args); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%v: %v, wanted a refusal saying %q", c.set, err, c.says)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
// The probe a run step's isolation is proved with (novox/hq ADR 0448; issue 1309, S19): run inside the step, it says
// on stdout every way it reached what a step must not, and "shut off" when it reached none. Its arguments: the files
// it must not read (the forge key, a bus login), a TCP address and a unix socket of the machine it must not reach, a
// name it must not resolve, and a cache file it writes, which must be unchanged after the step.
package main
import (
"fmt"
"net"
"os"
"time"
)
func main() {
if len(os.Args) != 7 {
fmt.Println("usage: probe <key> <login> <tcp address> <unix socket> <name> <cache file>")
os.Exit(2)
}
key, login, tcp, socket, name, cache := os.Args[1], os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6]
reached := 0
say := func(f string, a ...any) { reached++; fmt.Printf(f+"\n", a...) }
for _, f := range []string{key, login} {
if _, err := os.ReadFile(f); err == nil {
say("read %s", f)
}
}
if c, err := net.DialTimeout("tcp", tcp, 2*time.Second); err == nil {
c.Close()
say("reached %s", tcp)
}
if c, err := net.DialTimeout("unix", socket, 2*time.Second); err == nil {
c.Close()
say("reached %s", socket)
}
if addrs, err := net.LookupHost(name); err == nil {
say("resolved %s to %v", name, addrs)
}
if home, err := os.UserHomeDir(); err == nil {
if entries, err := os.ReadDir(home); err == nil && len(entries) > 0 {
say("the home %s holds %d entries", home, len(entries))
}
}
if err := os.WriteFile(cache, []byte("written by the step\n"), 0o644); err != nil {
say("could not write the cache under its throwaway layer: %v", err)
}
if reached == 0 {
fmt.Println("shut off")
}
}
+57 -1
View File
@@ -6,9 +6,12 @@ import (
"encoding/hex"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -19,12 +22,16 @@ type ModuleTree struct {
Tree string `json:"tree"`
Manifest string `json:"manifest"`
Locks map[string]string `json:"locks,omitempty"`
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
Paths map[string]string `json:"paths,omitempty"`
}
var lockFiles = []string{"go.sum", "package-lock.json"}
// ModuleTrees are the trees, in commit, of every directory of the workspace dir that holds a module.json, by directory
// (the repository's root is "."). The workspace is at commit, so the manifests and the lock files are read from it. env is
// (the repository's root is "."); for a module whose every artifact names its paths, the named tree of those paths and
// their objects (novox/hq ADR 0499 clause 2). The workspace is at commit, so the manifests and the lock files are read from it. env is
// the environment git runs with (nil for none). Both build modules' fetch steps answer it for a merge.
func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, commit string) (map[string]ModuleTree, error) {
r := run(ctx, env, "git", "-C", dir, "ls-files", "--", "module.json", "*/module.json")
@@ -51,6 +58,9 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
return nil, err
}
mt.Manifest = sha256Hex(raw)
if err := narrow(ctx, run, env, dir, commit, mdir, raw, &mt); err != nil {
return nil, err
}
for _, lock := range lockFiles {
if raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(mdir), lock)); err == nil {
if mt.Locks == nil {
@@ -64,4 +74,50 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
return trees, nil
}
// narrow sets mt's Paths and its named Tree when the module's manifest names paths on every artifact (novox/hq ADR 0499
// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview reads the manifest here, as
// the indexer does, with catalogue.ParseManifestForInputs (issue 1751), and no other build step reads paths from one (ADR 0495 clause 1). A manifest that does not parse
// is not narrowed: the indexer gives it none. A named path absent at commit fails, naming the module and the path.
func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, mdir string, raw []byte, mt *ModuleTree) error {
m, err := catalogue.ParseManifestForInputs(raw)
if err != nil {
return nil
}
paths, every := m.NamedPaths()
if !every {
return nil
}
full := make([]string, len(paths))
for i, p := range paths {
full[i] = path.Join(mdir, p)
}
r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, full...)...)
if r.Status != 0 || r.Err != "" {
return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, firstLine(r.Err+" "+r.Stderr))
}
objects := map[string]string{}
for _, l := range strings.Split(r.Stdout, "\x00") {
meta, name, ok := strings.Cut(l, "\t")
fields := strings.Fields(meta)
if !ok || len(fields) != 3 {
continue
}
objects[name] = fields[2]
}
mt.Paths = map[string]string{}
for i, p := range paths {
object := objects[full[i]]
if object == "" {
return fmt.Errorf("the module %s names the path %q, which is not in %s", mdir, p, commit)
}
mt.Paths[p] = object
}
named, err := delivery.NamedTree(mt.Manifest, mt.Paths)
if err != nil {
return fmt.Errorf("the module %s: %w", mdir, err)
}
mt.Tree = named
return nil
}
func sha256Hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
+63
View File
@@ -0,0 +1,63 @@
//go:build integration
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// On a real repository shaped like mesh-sdk (novox/hq ADR 0499 clauses 2 and 6), a commit touching only the Go package
// leaves the TypeScript package's tree value equal, and a commit touching its source changes it; a module that names no
// path keeps its directory's tree id.
func TestAGoOnlyCommitLeavesTheNamedTreeEqualOnRealGit(t *testing.T) {
repo := t.TempDir()
realGit(t, repo, "init", "-q", "-b", "main")
writeFile(t, repo, "module.json", `{"module":"mesh-sdk","version":"1","build":{"artifacts":[`+
`{"name":"lib","kind":"package","language":"typescript","paths":["src","test","package.json","package-lock.json","tsconfig.json"]}]}}`)
writeFile(t, repo, "src/index.ts", "export const a = 1\n")
writeFile(t, repo, "test/a.test.ts", "// a\n")
writeFile(t, repo, "package.json", "{}\n")
writeFile(t, repo, "package-lock.json", "{}\n")
writeFile(t, repo, "tsconfig.json", "{}\n")
writeFile(t, repo, "go/delivery/x.go", "package delivery\n")
writeFile(t, repo, "other/module.json", `{"module":"other","version":"1"}`)
writeFile(t, repo, "other/a.txt", "a\n")
commit := func(msg string) string {
realGit(t, repo, "add", "-A")
realGit(t, repo, "commit", "-q", "-m", msg)
return realGit(t, repo, "rev-parse", "HEAD")
}
trees := func(c string) map[string]ModuleTree {
got, err := ModuleTrees(context.Background(), forgekey.ExecRunner, nil, repo, c)
if err != nil {
t.Fatal(err)
}
return got
}
first := trees(commit("first"))
if !strings.HasPrefix(first["."].Tree, "named:") || len(first["."].Paths) != 5 {
t.Fatalf("the root module was not narrowed: %+v", first["."])
}
if want := realGit(t, repo, "rev-parse", "HEAD:other"); first["other"].Tree != want || first["other"].Paths != nil {
t.Fatalf("a module naming no path: %+v, want its directory's tree %s", first["other"], want)
}
writeFile(t, repo, "go/delivery/x.go", "package delivery\n\nconst B = 2\n")
goOnly := trees(commit("go only"))
if goOnly["."].Tree != first["."].Tree {
t.Fatalf("a Go-only commit changed the TypeScript package's tree: %s, was %s", goOnly["."].Tree, first["."].Tree)
}
writeFile(t, repo, "src/index.ts", "export const a = 2\n")
src := trees(commit("src"))
if src["."].Tree == first["."].Tree {
t.Fatal("a commit to src/ left the TypeScript package's tree equal")
}
if src["."].Paths["src"] != realGit(t, repo, "rev-parse", "HEAD:src") {
t.Fatalf("the object of src is %s, git says %s", src["."].Paths["src"], realGit(t, repo, "rev-parse", "HEAD:src"))
}
}
+113
View File
@@ -0,0 +1,113 @@
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// A module whose every artifact names its paths takes its tree from them (novox/hq ADR 0499 clause 2): ModuleTrees asks
// git for each named path's object at the commit, keeps them in Paths and answers their delivery.NamedTree as Tree; a
// module naming none keeps its directory's tree, as before.
func TestModuleTreesNarrowsAModuleWhoseArtifactsNameTheirPaths(t *testing.T) {
root := t.TempDir()
sdk := `{"module":"mesh-sdk","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","package.json"]}]}}`
writeTreeFile(t, root, "module.json", sdk)
writeTreeFile(t, root, "modules/b/module.json", `{"module":"b","version":"1"}`)
merge := strings.Repeat("c", 40)
var lsTree []string
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "module.json\nmodules/b/module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "tree-of-" + args[3] + "\n"}
case "ls-tree":
lsTree = append(lsTree, strings.Join(args[4:], " "))
return forgekey.Ran{Stdout: "100644 blob " + strings.Repeat("b", 40) + "\tpackage.json\x00" +
"040000 tree " + strings.Repeat("a", 40) + "\tsrc\x00"}
}
return forgekey.Ran{Status: 1, Stderr: "unexpected " + args[2]}
}
got, err := ModuleTrees(context.Background(), run, nil, root, merge)
if err != nil {
t.Fatal(err)
}
paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)}
want, _ := delivery.NamedTree(hexOf(sdk), paths)
if g := got["."]; g.Tree != want || len(g.Paths) != 2 || g.Paths["src"] != paths["src"] || g.Paths["package.json"] != paths["package.json"] {
t.Fatalf("the root module = %+v, want tree %s and paths %v", g, want, paths)
}
if len(lsTree) != 1 || lsTree[0] != merge+" -- package.json src" {
t.Fatalf("git ls-tree ran as %v", lsTree)
}
if g := got["modules/b"]; g.Tree != "tree-of-"+merge+":modules/b" || g.Paths != nil {
t.Fatalf("a module naming no path was narrowed: %+v", g)
}
}
// A named path absent at the commit fails the answer, naming the module and the path: a tree computed without it would
// pin a build source that does not exist.
func TestModuleTreesFailsNamingANamedPathAbsentAtTheCommit(t *testing.T) {
root := t.TempDir()
writeTreeFile(t, root, "modules/a/module.json", `{"module":"a","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","gone"]}]}}`)
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "modules/a/module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "t\n"}
case "ls-tree":
if strings.Join(args[4:], " ") != strings.Repeat("c", 40)+" -- modules/a/gone modules/a/src" {
return forgekey.Ran{Status: 1, Stderr: "wrong arguments " + strings.Join(args[3:], " ")}
}
return forgekey.Ran{Stdout: "040000 tree " + strings.Repeat("a", 40) + "\tmodules/a/src\x00"}
}
return forgekey.Ran{Status: 1}
}
_, err := ModuleTrees(context.Background(), run, nil, root, strings.Repeat("c", 40))
if err == nil || !strings.Contains(err.Error(), `"gone"`) || !strings.Contains(err.Error(), "modules/a") {
t.Fatalf("an absent named path gave %v", err)
}
}
// A manifest whose config value falls back to a predefined key is narrowed here as the indexer narrows it: both read it
// with catalogue.ParseManifestForInputs (issue 1751), so a build module holding no published list computes the same
// tree (ADR 0499 clause 2).
func TestModuleTreesNarrowsAManifestFallingBackToAPredefinedKey(t *testing.T) {
was := catalogue.ReplacePublished(nil) // the build modules' case: no published list
defer catalogue.ReplacePublished(was)
root := t.TempDir()
raw := `{"module":"xterm","version":"1","config":{"size":{"type":"number","description":"The text size.","predefined":"font-size","default":12,"runtime":true}},` +
`"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src"]}]}}`
if _, err := catalogue.ParseManifest([]byte(raw)); err == nil {
t.Fatal("ParseManifest reads the manifest with no list; the test proves nothing")
}
if _, err := catalogue.ParseManifestForInputs([]byte(raw)); err != nil {
t.Fatalf("the indexer's read refuses the manifest: %v", err)
}
writeTreeFile(t, root, "module.json", raw)
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "whole\n"}
case "ls-tree":
return forgekey.Ran{Stdout: "040000 tree " + strings.Repeat("a", 40) + "\tsrc\x00"}
}
return forgekey.Ran{Status: 1}
}
got, err := ModuleTrees(context.Background(), run, nil, root, strings.Repeat("c", 40))
if err != nil {
t.Fatal(err)
}
want, _ := delivery.NamedTree(hexOf(raw), map[string]string{"src": strings.Repeat("a", 40)})
if g := got["."]; g.Tree != want || g.Paths["src"] == "" {
t.Fatalf("the module = %+v, want the named tree %s", g, want)
}
}
+66
View File
@@ -394,6 +394,72 @@ func (b *Build) problems(module string) []string {
"%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From))
}
problems = append(problems, upstreamFileProblems(module, a)...)
problems = append(problems, pathsProblems(module, a)...)
}
return problems
}
// NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the
// paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact
// names paths; otherwise none, and the module keeps its directory's tree.
//
// **Read by the core alone** (ADR 0495 clause 1, the lead's decision L8 of R4e): the indexer and its merge preview
// (buildstep.ModuleTrees) call it. A build module's fetch step is handed the paths by the builder, from the indexer's
// kept inputs (delivery.KeptInputs2.Paths), and reads no manifest for them.
func (m Manifest) NamedPaths() (paths []string, every bool) {
if m.Build == nil || len(m.Build.Artifacts) == 0 {
return nil, false
}
seen := map[string]bool{}
for _, a := range m.Build.Artifacts {
if len(a.Paths) == 0 {
return nil, false
}
for _, p := range a.Paths {
seen[path.Clean(p)] = true
}
}
for p := range seen {
paths = append(paths, p)
}
sort.Strings(paths)
return paths, true
}
// pathsProblems is what is wrong with an artifact's `paths` (novox/hq ADR 0499 clause 1): named on a kind whose build
// source is derived or named otherwise, or a path that leaves the module's directory, is its whole directory, or is a
// pattern.
func pathsProblems(module string, a Artifact) []string {
if a.Paths == nil {
return nil
}
switch {
case a.Kind == ArtifactBundle && a.Language == "go":
return []string{fmt.Sprintf("%s: %q is a Go program and names paths: its build source is derived from the "+
"package it compiles (novox/hq ADR 0267 clause 1, ADR 0499 clause 1)", module, a.Name)}
case a.Kind != ArtifactBundle && a.Kind != ArtifactPackage:
return []string{fmt.Sprintf("%s: %q is a %q and names paths; only a package or a bundle in a language other "+
"than Go names the paths it is built from (novox/hq ADR 0499 clause 1)", module, a.Name, a.Kind)}
case len(a.Paths) == 0:
return []string{fmt.Sprintf("%s: %q names no path in paths; leave the word out to build from the module's "+
"directory", module, a.Name)}
}
var problems []string
for _, p := range a.Paths {
clean := path.Clean(p)
switch {
case strings.TrimSpace(p) == "":
problems = append(problems, fmt.Sprintf("%s: %q names an empty path", module, a.Name))
case strings.HasPrefix(p, "/") || clean == ".." || strings.HasPrefix(clean, "../"):
problems = append(problems, fmt.Sprintf("%s: %q names the path %q, which leaves the module's directory",
module, a.Name, p))
case clean == ".":
problems = append(problems, fmt.Sprintf("%s: %q names the module's whole directory (%q) as a path; leave "+
"the word out for that", module, a.Name, p))
case strings.ContainsAny(p, "*?[]{}\\"):
problems = append(problems, fmt.Sprintf("%s: %q names %q, which is a pattern; paths names files and "+
"directories", module, a.Name, p))
}
}
return problems
}
+2 -1
View File
@@ -42,7 +42,8 @@ func deliveryVerbs() []Verb {
"paths": "the files it changes, comma-separated, from the repository's root",
"base": "the branch it merges into (default main)"}, []string{"repository", "paths"})},
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
"of the commit's mesh statuses (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, " +
"of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " +
"mesh/delivery, …) with its state, " +
"description and when it was set; the merge check's full verdict as the controller said it — each " +
"layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " +
"protection would let it merge, every required status being success.",
+11
View File
@@ -1191,6 +1191,17 @@ type Artifact struct {
// (novox/hq ADR 0193). Absent for nearly every bundle, which is then one file per entrypoint.
External []string `json:"external,omitempty"`
// Paths are the files and directories, relative to the module's directory, a package or a bundle in a language
// other than Go is built from (novox/hq ADR 0499 clause 1): with the module's manifest they are its build source,
// and a module whose every artifact names them takes its fingerprint's tree from them alone (clause 2). Absent means
// the module's directory, as before.
//
// **Named, because the build source of a package sharing a repository cannot be derived.** A Go program's is
// (ADR 0267 clause 1), an archive's `from` names it and an image's is the context its manifest names, so each of those
// refuses the word. A path stays inside the module's directory and is no pattern: a build source that could reach
// out of it, or grow with a directory listing, would be one no fingerprint pins.
Paths []string `json:"paths,omitempty"`
// Env is what a tools bundle is given on a machine (novox/hq ADR 0192): words and their values,
// paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment
// is, never a secret's content. The node's runtime hands it to this bundle and to no other.
+29
View File
@@ -0,0 +1,29 @@
package catalogue
import (
"os"
"testing"
)
// mesh-sdk's own manifest is judged by the controller that runs when it is checked, and that controller reads it
// strictly: a manifest word ships one release ahead of its first user. R4e's group is judged by today's controller
// (novox/hq ADR 0503 §4), which does not know `paths` (ADR 0499), so mesh-sdk's lib names no paths until a release
// after R4e's delivery adds them back (ADR 0499 §6, issue 1758).
func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
m, err := ParseManifest(raw)
if err != nil {
t.Fatalf("mesh-sdk's module.json does not parse: %v", err)
}
if m.Build == nil || len(m.Build.Artifacts) == 0 {
t.Fatal("mesh-sdk's module.json declares no artifact")
}
for _, a := range m.Build.Artifacts {
if a.Paths != nil {
t.Errorf("artifact %q names paths %v: the running controller refuses the word until R4e is delivered", a.Name, a.Paths)
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package catalogue
import (
"strings"
"testing"
)
// An artifact names the paths it is built from with `paths` (novox/hq ADR 0499 clause 1): a package, or a bundle in a
// language other than Go, may; a Go program's artifact, an archive, an image and an upstream artifact may not, and the
// refusal names the artifact and its kind. Each path stays inside the module's directory: no `..`, no absolute path,
// no pattern.
func TestAnArtifactNamesThePathsItIsBuiltFrom(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk","build":{"artifacts":[
{"name":"lib","kind":"package","language":"typescript",
"paths":["src","test","package.json","package-lock.json","tsconfig.json"]}]},"resources":[]}`))
if err != nil {
t.Fatalf("a package naming its paths was refused: %v", err)
}
if got := strings.Join(m.Build.Artifacts[0].Paths, ","); got != "src,test,package.json,package-lock.json,tsconfig.json" {
t.Fatalf("the paths were not kept as written: %q", got)
}
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["index.js"],"loads":["index.js"],"paths":["src","package.json"]}]}}`)); err != nil {
t.Fatalf("a TypeScript bundle naming its paths was refused: %v", err)
}
for _, c := range []struct{ artifact, says string }{
{`{"name":"x","kind":"bundle","language":"go","system":"linux-amd64","paths":["cmd"]}`, `"x" is a Go program`},
{`{"name":"x","kind":"archive","from":"files","paths":["files"]}`, `"x" is a "archive"`},
{`{"name":"x","kind":"image","from":"Dockerfile","paths":["src"]}`, `"x" is a "image"`},
{`{"name":"x","kind":"upstream","from":"nats:2.10","paths":["src"]}`, `"x" is a "upstream"`},
{`{"name":"x","kind":"package","language":"typescript","paths":["../other"]}`, "leaves the module's directory"},
{`{"name":"x","kind":"package","language":"typescript","paths":["src/../../x"]}`, "leaves the module's directory"},
{`{"name":"x","kind":"package","language":"typescript","paths":["/etc"]}`, "leaves the module's directory"},
{`{"name":"x","kind":"package","language":"typescript","paths":["src/*.ts"]}`, "is a pattern"},
{`{"name":"x","kind":"package","language":"typescript","paths":["src/[ab]"]}`, "is a pattern"},
{`{"name":"x","kind":"package","language":"typescript","paths":[""]}`, "names an empty path"},
{`{"name":"x","kind":"package","language":"typescript","paths":["."]}`, "names the module's whole directory"},
{`{"name":"x","kind":"package","language":"typescript","paths":[]}`, "names no path"},
} {
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`))
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says)
}
}
}
// The paths a module's tree is taken from are the union its artifacts name, cleaned and sorted, and only when every
// artifact names some (novox/hq ADR 0499 clause 2); a module with an artifact that names none, or with none at all,
// keeps its directory's tree.
func TestAModulesNamedPathsAreTheUnionWhenEveryArtifactNamesThem(t *testing.T) {
m := Manifest{Module: "a", Build: &Build{Artifacts: []Artifact{
{Name: "lib", Kind: ArtifactPackage, Paths: []string{"src/", "package.json"}},
{Name: "tools", Kind: ArtifactBundle, Paths: []string{"./src", "tools"}},
}}}
paths, every := m.NamedPaths()
if !every || strings.Join(paths, ",") != "package.json,src,tools" {
t.Fatalf("NamedPaths = %v, %v; want [package.json src tools], true", paths, every)
}
m.Build.Artifacts = append(m.Build.Artifacts, Artifact{Name: "img", Kind: ArtifactImage, From: "Dockerfile"})
if paths, every := m.NamedPaths(); every || paths != nil {
t.Fatalf("a module with an artifact naming no path gave %v, %v", paths, every)
}
if paths, every := (Manifest{Module: "b"}).NamedPaths(); every || paths != nil {
t.Fatalf("a module that builds nothing gave %v, %v", paths, every)
}
}
+41
View File
@@ -0,0 +1,41 @@
package catalogue
import "encoding/json"
// ParseManifestForInputs reads a manifest for the inputs of its build and its fingerprint: the indexer's and the
// builder's read (novox/hq issue 1751). It is never a judge of config values. A value falling back to a predefined key
// (ADR 0348 §7) is judged against the list the mesh published when this process holds one (PublishedPredefined);
// where it holds none, as in the indexer and the builder, each key a value names is taken as the value declares it (its
// type, trusted when it is), since the list is read where a manifest is judged: the merge gate from the tree's root,
// the controller from what it published. No input of a fingerprint or a build comes from the list. Everything else
// ParseManifest refuses is refused here too.
func ParseManifestForInputs(raw []byte) (Manifest, error) {
list := PublishedPredefined()
if list == nil {
list = fallbacksOf(raw)
}
return parseManifest(raw, list)
}
// fallbacksOf is the list of the predefined keys a manifest's config values fall back to, each as the value declares
// it. A manifest that is not JSON gives an empty list, and the parser says what is wrong with it.
func fallbacksOf(raw []byte) PredefinedList {
var m struct {
Config map[string]struct {
Type string `json:"type"`
Description string `json:"description"`
Predefined string `json:"predefined"`
Trusted bool `json:"trusted"`
} `json:"config"`
}
list := PredefinedList{}
if json.Unmarshal(raw, &m) != nil {
return list
}
for _, d := range m.Config {
if d.Predefined != "" {
list[d.Predefined] = PredefinedKey{Type: d.Type, Description: d.Description, Trusted: d.Trusted}
}
}
return list
}
+40
View File
@@ -0,0 +1,40 @@
package catalogue
import (
"os"
"strings"
"testing"
)
// The indexer and the builder hold no published list of predefined keys; a manifest whose config values fall back to
// one (claude-code, dunst, xterm at mesh-catalog 835bf22b) is still read for its build's and fingerprint's inputs
// (issue 1751).
func TestParseManifestForInputsReadsAValueFallingBackToAPredefinedKey(t *testing.T) { // issue 1751
was := ReplacePublished(nil) // the indexer's and the builder's case: no published list
defer ReplacePublished(was)
for _, module := range []string{"claude-code", "dunst", "xterm"} {
raw, err := os.ReadFile("testdata/catalogue-835bf22b-" + module + ".module.json")
if err != nil {
t.Fatal(err)
}
if _, err := ParseManifest(raw); err == nil {
t.Fatalf("%s: ParseManifest reads it with no list; the test proves nothing", module)
}
if m, err := ParseManifestForInputs(raw); err != nil || m.Module != module {
t.Errorf("%s: %v", module, err)
}
}
}
func TestParseManifestForInputsStillRefusesWhatTheParserRefuses(t *testing.T) { // issue 1751
was := ReplacePublished(nil)
defer ReplacePublished(was)
raw, _ := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json")
bad := strings.Replace(string(raw), `"predefined": "font-size"`, `"predefined": "font-size", "trusted": "yes"`, 1)
if bad == string(raw) {
t.Fatal("the fixture no longer names font-size; the test proves nothing")
}
if _, err := ParseManifestForInputs([]byte(bad)); err == nil {
t.Error("a manifest the parser refuses was read")
}
}
-7
View File
@@ -423,13 +423,6 @@ func CatalogueProblems(shelf Shelf) []string {
"operator's account, which every agent runs as (novox/hq ADR 0259)", module, why))
}
}
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
// owner is on the shelf, as a consumer may be installed before its emitter.
var manifests []Manifest
for _, module := range shelfOrder(shelf) {
manifests = append(manifests, shelf[module])
}
problems = append(problems, StateReadsNothingDeclares(manifests)...)
sort.Strings(problems)
return problems
}
-41
View File
@@ -126,44 +126,3 @@ func StateProblems(m Manifest) []string {
}
return problems
}
// ControllerMirrors are the controller's record mirrors a module may read, each `mesh-controller.<name>`
// (novox/hq ADR 0289, design 54 §5, design 57 F3). **The controller keeps them, not its manifest's `state`**:
// they are asserted with the controller's own buckets, with options of their own, and written by the
// controller's account alone (the writers table); declared as a module's state, they would be asserted a
// second way and granted to the module's own principal as well. Named here so a read of one is a read of
// kept state. The bus's list is broker.ControllerMirrors; a test keeps the two agreeing.
var ControllerMirrors = []string{"assignments", "declared-config", "predefined", "builds", "rollouts", "checks-running"}
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
// such state. An absent owner says nothing — a module may be installed long before the one whose
// state it reads, as a consumer may before its emitter (design 32 §1).
func StateReadsNothingDeclares(manifests []Manifest) []string {
declared := map[string]map[string]bool{}
for _, m := range manifests {
own := map[string]bool{}
for _, s := range m.State {
own[s.Name] = true
}
declared[m.Module] = own
}
if own, present := declared[ControllerSeatName]; present {
for _, name := range ControllerMirrors {
own[name] = true
}
}
var problems []string
for _, m := range manifests {
for _, r := range m.Reads {
module, local, err := ReadState(r)
if err != nil {
continue
}
if own, present := declared[module]; present && !own[local] {
problems = append(problems, fmt.Sprintf(
"%s reads %q, and %s keeps no state called %q", m.Module, r, module, local))
}
}
}
return problems
}
+8 -38
View File
@@ -52,59 +52,29 @@ func TestAStateNameIsLocalAndOneToken(t *testing.T) {
}
}
// A read whose owner is present must name a state that owner keeps; an absent owner says nothing,
// because a module may be installed before the one whose state it reads.
func TestAReadNamesStateItsOwnerKeeps(t *testing.T) {
owner := Manifest{Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}}
good := Manifest{Module: "claude-code", Reads: []string{"licence-manager.bindings", "absent.anything"}}
bad := Manifest{Module: "other", Reads: []string{"licence-manager.tokens"}}
if p := StateReadsNothingDeclares([]Manifest{owner, good}); len(p) != 0 {
t.Fatalf("a read of declared state was refused: %v", p)
}
p := StateReadsNothingDeclares([]Manifest{owner, bad})
if len(p) != 1 || !strings.Contains(p[0], `licence-manager keeps no state called "tokens"`) {
t.Fatalf("a read of state nobody keeps was not named: %v", p)
}
}
// **The controller's record mirrors are kept state** (design 54 §5, design 57 F3): a module may read
// `mesh-controller.assignments` and `mesh-controller.declared-config` although the controller's manifest
// declares no `state`; a read of anything else of the controller's is still named.
func TestAReadOfTheControllersMirrorsIsAReadOfKeptState(t *testing.T) {
controller := Manifest{Module: ControllerSeatName}
reader := Manifest{Module: "mesh-config", Reads: []string{"mesh-controller.assignments", "mesh-controller.declared-config"}}
if p := StateReadsNothingDeclares([]Manifest{controller, reader}); len(p) != 0 {
t.Fatalf("a read of the controller's mirrors was refused: %v", p)
}
other := Manifest{Module: "other", Reads: []string{"mesh-controller.calls"}}
if p := StateReadsNothingDeclares([]Manifest{controller, other}); len(p) != 1 {
t.Fatalf("a read of the controller's own calls was not named: %v", p)
}
}
// **Across the whole catalogue**: every state name is local, and every read whose owner is present
// names state that owner keeps.
// **Across the whole catalogue**: every state name is local and every read names a module's state.
func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) {
manifests := theCatalogue(t)
var problems []string
for _, m := range manifests {
problems = append(problems, StateProblems(m)...)
}
problems = append(problems, StateReadsNothingDeclares(manifests)...)
if len(problems) > 0 {
t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n "))
}
}
// `module check` says it too: the cross-catalogue pass names a read nothing on the shelf keeps.
func TestTheCataloguePassNamesAReadItsOwnerDoesNotKeep(t *testing.T) {
// The catalogue pass no longer judges one module's reads against another's manifest (novox/hq ADR 0495 clauses 1 and 5,
// issue 1790): what an owner keeps is what it announces on the bus, and each module's own announcement test holds that.
func TestTheCataloguePassJudgesNoReadAgainstTheOwnersManifest(t *testing.T) {
shelf := Shelf{
"licence-manager": {Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}},
"claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}},
}
problems := CatalogueProblems(shelf)
if len(problems) != 1 || !strings.Contains(problems[0], `keeps no state called "tokens"`) {
t.Fatalf("the catalogue pass said %v", problems)
for _, p := range CatalogueProblems(shelf) {
if strings.Contains(p, "keeps no state") {
t.Fatalf("the catalogue pass judged a read against the owner's manifest: %s", p)
}
}
}
@@ -0,0 +1,249 @@
{
"module": "claude-code",
"version": "1",
"moved": true,
"slug": "agent",
"capabilities": [
"package-manager"
],
"requires": [
"mcp-endpoint",
{
"name": "gitea-read",
"optional": true
}
],
"binds": {
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
},
"secrets": {
"gitea-read": "${dir:state}/gitea-read.secret"
},
"uses": [
"operator-channel"
],
"state": [
"servers",
"holdings",
"config",
"proposals",
"questions",
"memory",
"waits"
],
"reads": [
"claude-licence-manager.bindings"
],
"tools": [
"claude_code_status",
"claude_code_render",
"claude_code_guard",
"claude_code_pull",
"claude_code_grant",
"claude_code_add_api_key",
"claude_code_registrations",
"claude_code_memory",
"claude_code_mcp_list",
"claude_code_mcp_register",
"claude_code_mcp_unregister",
"claude_code_skill_list",
"claude_code_skill_register",
"claude_code_skill_unregister",
"claude_code_agent_list",
"claude_code_agent_register",
"claude_code_agent_unregister",
"claude_code_command_list",
"claude_code_command_register",
"claude_code_command_unregister",
"claude_code_hook_list",
"claude_code_hook_register",
"claude_code_hook_unregister",
"claude_code_output_style_list",
"claude_code_output_style_register",
"claude_code_output_style_unregister",
"claude_code_instructions_list",
"claude_code_instructions_register",
"claude_code_instructions_unregister",
"claude_code_instructions_propose",
"claude_code_proposals",
"claude_code_ask",
"claude_code_answer",
"claude_code_wait",
"claude_code_settings_get",
"claude_code_settings_set",
"claude_code_settings_clear",
"claude_code_permission_add",
"claude_code_permission_remove",
"claude_code_config_list",
"claude_code_config_show",
"claude_code_config_status",
"claude_code_config_import",
"claude_code_home_show",
"claude_code_home_remove",
"claude_code_home_removed",
"claude_code_home_restore",
"claude_code_judge",
"claude_code_judged"
],
"data": {
"own": [
{
"id": "agent-home",
"path": "${dir:agent-home}",
"class": "valuable",
"why": "the operator's agent's own files: its memory, history and projects"
}
]
},
"config": {
"role": {
"type": "string",
"description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.",
"trusted": true,
"default": ""
},
"mcp_servers": {
"type": "object",
"description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.",
"trusted": true,
"default": {}
},
"managed_settings": {
"type": "object",
"description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.",
"trusted": true,
"predefined": "agent-managed-settings",
"default": {}
},
"instructions": {
"type": "object",
"description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.",
"trusted": true,
"default": {}
},
"output_styles": {
"type": "object",
"description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
"trusted": true,
"default": {}
},
"skills": {
"type": "object",
"description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.",
"trusted": true,
"default": {}
},
"commands": {
"type": "object",
"description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
"trusted": true,
"default": {}
},
"agents": {
"type": "object",
"description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
"trusted": true,
"default": {}
}
},
"resources": [
{
"id": "package",
"type": "package",
"package": "claude-code"
},
{
"id": "managed",
"type": "directory",
"path": "/etc/claude-code",
"mode": "0755",
"unknown": "program"
},
{
"id": "start",
"type": "file",
"path": "/usr/local/bin/claude-agent",
"mode": "0755",
"template": "files/claude-agent"
},
{
"id": "agent-account-name",
"type": "file",
"path": "/etc/claude-code/agent-account",
"mode": "0644",
"template": "files/agent-account"
},
{
"id": "agent-account",
"type": "user",
"name": "${machine:agent-account}",
"home": "${machine:agent-home}",
"root": "${machine:agent-root}"
},
{
"id": "agent-home",
"type": "directory",
"path": "${machine:agent-home}/.claude",
"mode": "0700",
"owner": "${machine:agent-account}",
"unknown": "module"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "${machine:account}",
"place": ".",
"unknown": "program"
},
{
"id": "facts",
"type": "file",
"path": "${dir:state}/facts.json",
"mode": "0600",
"owner": "${machine:account}",
"template": "files/facts.json"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "${machine:account}",
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
"merge": "json",
"template": "files/settings.json"
}
],
"items": [
{
"collection": "shell:zsh:normal",
"template": "files/zsh-normal"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/claude-code",
"binary": "claude-code",
"loads": [
"claude-code"
],
"env": {
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
@@ -0,0 +1,123 @@
{
"module": "dunst",
"version": "1",
"moved": true,
"capabilities": [
"package-manager"
],
"claims": [
{
"name": "node-notifier",
"scope": "node",
"serves": [
"send",
"history"
]
}
],
"tools": [
"dunst_pause",
"dunst_resume",
"dunst_close_all",
"dunst_rules",
"dunst_count",
"dunst_reload"
],
"config": {
"font-size": {
"type": "number",
"description": "The size of a notification's text, in points of Inter: 10 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.",
"minimum": 6,
"maximum": 72,
"predefined": "font-size",
"default": 10
},
"width": {
"type": "number",
"description": "The width of a notification, in pixels: 250 fits a title of about forty characters at 10 points, and a larger font needs a wider notification.",
"minimum": 50,
"maximum": 4000,
"default": 250
}
},
"resources": [
{
"id": "package",
"type": "package",
"package": "dunst"
},
{
"id": "client",
"type": "package",
"package": "libnotify"
},
{
"id": "configuration-dir",
"type": "directory",
"path": "${machine:account-home}/.config/dunst",
"owner": "${machine:account}",
"mode": "0755",
"unknown": "move-out"
},
{
"id": "dropins",
"type": "directory",
"path": "${machine:account-home}/.config/dunst/dunstrc.d",
"owner": "${machine:account}",
"mode": "0755",
"unknown": "move-out",
"knows": ["50-slack.conf"]
},
{
"id": "configuration",
"trusted": false,
"type": "file",
"path": "${machine:account-home}/.config/dunst/dunstrc",
"owner": "${machine:account}",
"mode": "0644",
"template": "files/dunstrc"
},
{
"id": "notifier",
"type": "service",
"unit": "dunst.service",
"scope": "user",
"user": "${machine:account}",
"reload-on": [
"configuration"
]
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/dunst-tools",
"binary": "dunst-tools",
"loads": [
"dunst-tools"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
},
"emits": [
"action-chosen",
"started",
"paused"
],
"items": [
{
"collection": "contribution:node-display-session:config",
"name": "display-session",
"template": "files/i3/50-dunst.conf"
}
]
}
@@ -0,0 +1,236 @@
{
"module": "xterm",
"version": "1",
"moved": true,
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"claims": [
{
"name": "node-terminal-emulator",
"scope": "node",
"serves": [
"open"
]
}
],
"tools": [
"xterm_font",
"xterm_colours"
],
"config": {
"face": {
"type": "string",
"description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).",
"pattern": "[A-Za-z0-9 ._-]{1,64}",
"default": "JetBrainsMono Nerd Font"
},
"size": {
"type": "number",
"description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.",
"minimum": 6,
"maximum": 72,
"predefined": "font-size",
"default": 12
},
"save-lines": {
"type": "number",
"description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.",
"minimum": 0,
"maximum": 1000000,
"default": 10000
},
"term-name": {
"type": "string",
"description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.",
"pattern": "[A-Za-z0-9._-]{1,64}",
"default": "xterm-256color"
},
"render-font": {
"type": "boolean",
"description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.",
"default": true
},
"bold-mode": {
"type": "boolean",
"description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.",
"default": false
},
"always-bold-mode": {
"type": "boolean",
"description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.",
"default": false
},
"allow-bold-fonts": {
"type": "boolean",
"description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.",
"default": true
},
"background": {
"type": "string",
"description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#000000"
},
"foreground": {
"type": "string",
"description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#a8a8a8"
},
"color0": {
"type": "string",
"description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#000000"
},
"color1": {
"type": "string",
"description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#9e1828"
},
"color2": {
"type": "string",
"description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#008800"
},
"color3": {
"type": "string",
"description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#d2bb4b"
},
"color4": {
"type": "string",
"description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#414171"
},
"color5": {
"type": "string",
"description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#963c59"
},
"color6": {
"type": "string",
"description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#418179"
},
"color7": {
"type": "string",
"description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#bebebe"
},
"color8": {
"type": "string",
"description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#666666"
},
"color9": {
"type": "string",
"description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#bc5766"
},
"color10": {
"type": "string",
"description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#61a171"
},
"color11": {
"type": "string",
"description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#e7db52"
},
"color12": {
"type": "string",
"description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#5085af"
},
"color13": {
"type": "string",
"description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#a97a99"
},
"color14": {
"type": "string",
"description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#6ba4a4"
},
"color15": {
"type": "string",
"description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#ffffff"
},
"copy-binding": {
"type": "string",
"description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.",
"pattern": "[^:\\\\\\n]+",
"default": "Ctrl Shift ~Alt <Key> C"
},
"paste-binding": {
"type": "string",
"description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.",
"pattern": "[^:\\\\\\n]+",
"default": "Ctrl Shift ~Alt <Key> V"
}
},
"items": [
{
"collection": "environment",
"name": "terminal",
"attributes": {
"kind": "variable",
"name": "TERMINAL"
},
"template": "files/env-terminal"
},
{
"collection": "shell:xresources:normal",
"name": "xresources-normal",
"template": "files/xresources-normal"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "xterm"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/xterm-tools",
"binary": "xterm-tools",
"loads": [
"xterm-tools"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
+3 -2
View File
@@ -10,7 +10,8 @@ import (
// the provision, and does not find out at its first build. The manifests are the catalogue's, as they are.
// builderNode is a machine that can run a build module: the package manager and user namespaces it asks of
// the machine, and the forge account (mesh-scope, provided by the forge) stood in for.
// the machine, and the forge account (mesh-scope, provided by the forge) and the artifact store the build
// modules publish to (ADR 0513, provided by distribution) stood in for.
func builderNode() Node {
return Node{Name: "builder", Site: "house",
Capabilities: map[string]bool{"package-manager": true, "user-namespaces": true}}
@@ -19,7 +20,7 @@ func builderNode() Node {
func toolchainShelf(t *testing.T) map[string]Manifest {
t.Helper()
m := map[string]Manifest{
"forge": {Module: "forge", Version: "1", Provides: []Offer{{Name: "forge-account"}, {Name: "npm-package-registry"}}},
"forge": {Module: "forge", Version: "1", Provides: []Offer{{Name: "forge-account"}, {Name: "npm-package-registry"}, {Name: "artifact-store"}}},
}
for _, name := range []string{"go", "nodejs", "mesh-build-go", "mesh-build-node", "dev-tools"} {
m[name] = catalogueManifest(t, name)
+22 -56
View File
@@ -147,24 +147,16 @@ var ControllerVerbs = []Verb{
}, nil, "data")},
// The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and
// composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them.
{Name: "delivery-plan", Description: "The delivery plan of a diffset, as the planner computes it and keeping " +
"nothing (novox/hq ADR 0239): what it moves and builds after them in tiers, what each machine receives and " +
"what waits there, the steps that are not an ordinary send, and whether the gate would run.",
Input: schema(map[string]string{"repository": "owner/repository", "head": "the commit at hand",
"base": "the branch it merges into (default main)",
"paths": "the files it changes, comma-separated, from the repository's root",
"module-dirs": "the directories holding a module.json at the head, comma-separated, as the forge says them",
"removed": "the files among paths it deletes, comma-separated"}, []string{"repository", "paths"})},
{Name: "delivery-plan", Description: "Removed in R4e (ADR 0501 §5 / ADR 0503 §3): refused by name, naming its replacement",
Input: schema(nil, nil)},
{Name: "delivery-order", Description: "A delivery group's order (novox/hq ADR 0419): its members in the order " +
"they are delivered, every pair and why — built by, verb before caller, version skew, engine before " +
"controller — read from the module graph and the manifests' change alone; no `after:` line is read. The " +
"cycle, with every module and edge of it, is the one refusal.",
Input: schema(map[string]string{"members": "the members, as JSON: [{id, repository, base, head, number, paths, " +
"module_dirs, module_dirs_said, removed}]"}, []string{"members"})},
{Name: "delivery-check", Description: "Ask the build seat for a delivery group's composed check (novox/hq ADR " +
"0239): every member's head laid over the mesh in turn and judged as one future state. Answers the ask's id; " +
"the verdict is said as `checked` with the group's id. With one member and no group, that head is checked " +
"again as its pull request is.",
{Name: "delivery-check", Description: "Answers that the checker (mesh-delivery-checker) judges a group (novox/hq " +
"ADR 0503 §3); asks nothing. Its inputs stay, since hq's repository check still names it (ADR 0513 §4).",
Input: schema(map[string]string{"group": "the group's id (none for one head checked again)",
"members": "the members, as JSON, as delivery-order takes them",
"ready": "\"true\" when the pull request is marked ready: its check is queued before every other check (novox/hq ADR 0406 §3)"},
@@ -378,45 +370,33 @@ var ControllerVerbs = []Verb{
{Name: "clear", Description: "Cancel every waiting ask in the build queue — and with dead, every dead one too — each " +
"recorded failed, cancelled by hand. Never touches one in flight.",
Input: schema(map[string]string{"dead": "\"true\" to cancel the dead asks as well"}, nil, "dead")},
{Name: "rebuild", Description: "Ask a module's current source again under a new id — the branch it follows — or, given a " +
"build's id, that build's repository, path and ref. A module a plan holds unbuilt or failed joins that plan. Answers the new id.",
Input: schema(map[string]string{"what": "a module's name, or a build's id"}, []string{"what"})},
{Name: "replay", Description: "Ask a recorded build's repository and path again at the commit it built, under a new id. " +
"A dry run unless register: nothing recorded or registered. Registering is refused when a newer build of the module " +
"is registered — it would roll the older commit out (novox/hq issue 207) — unless older says so.",
Input: schema(map[string]string{
"id": "the build's id",
"register": "\"true\" to register what it builds",
"older": "\"true\", with register: even though a newer build of the module is registered",
}, []string{"id"}, "register", "older")},
{Name: "rebuild", Description: "Removed in R4e (ADR 0501 §5 / ADR 0503 §3): refused by name, naming its replacement",
Input: schema(nil, nil)},
{Name: "replay", Description: "Ask a recorded build's repository and path again at the commit it built, under a new id: " +
"a dry run only, nothing recorded or registered (novox/hq ADR 0429 §18: a fingerprint's key is built again by the " +
"builder, never registered by hand).",
Input: schema(map[string]string{"id": "the build's id"}, []string{"id"})},
// novox/hq issue 686: a builder ran a branch's store tests for the first time in its pull request's merge check.
{Name: "try", Description: "Try a pushed branch or commit before its pull request (novox/hq issue 686): the build " +
"seat merges it into the repository's trunk and runs the repository's own merge-check.sh on the merge, in the " +
"toolchain containers and against the throwaway store and bus a pull request's merge check gets. Answers at " +
{Name: "try", Description: "Try a pushed branch or commit before its pull request (novox/hq issue 686): the " +
"checker (mesh-delivery-checker) merges the head onto the repository's trunk and runs its levels on the merge, " +
"as it judges a pull request (ADR 0503 §3); no merge-check.sh is run and build-agent is not asked. Answers at " +
"once with an id; asked again with that id it waits up to 20 s and answers pass, fail or error, how long it " +
"took and the lines naming what failed — or that it is still waiting or running, to ask again. Sets no " +
"status on the forge, registers and builds nothing, runs no merge gate, counts as a check in the build queue " +
"(after deliveries and after every pull request's check, however long it waits) and is bounded as one. One " +
"try of a commit at a time — a second ask answers the first's id — and at most four waiting or running in " +
"all: a fifth is refused. Only for a repository of the mesh; the ref must be on the forge.",
"status on the forge, registers and builds nothing for delivery. One try of a commit at a time — a second " +
"ask answers the first's id. Only for a repository of the mesh; the ref must be on the forge.",
Input: schema(map[string]string{
"repository": "the repository on the forge, owner/name; with ref",
"ref": "the branch or commit to try, as pushed to the forge",
"base": "the branch it is merged into (the repository's default branch when none is named). The " +
"merge-check.sh that runs is that branch's, never the ref's own: the same trust as a pull request's base",
"base": "the branch it is merged into (the repository's default branch when none is named)",
"tests": "only the tests this `go test -run` pattern names, where the repository's merge-check.sh reads " +
"MESH_CHECK_TESTS; a script that does not runs whole",
"store-only": "\"true\": only the repository's store tests, where its merge-check.sh reads " +
"MESH_CHECK_STORE_ONLY",
"id": "instead of a repository: the id a try answered, to read what came of it",
"store-only": "\"true\": only the integration level",
"id": "instead of a repository: the id a try answered, to read what came of it",
}, nil, "store-only"),
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
// novox/hq ADR 0350 §5: asked by the delivery's owner when the forge says a pull request closed unmerged.
{Name: "forget-checks", Description: "Let go of every build a pull request's merge checks made for its merge in " +
"the artifact store's scratch namespace (novox/hq ADR 0350 §5): asked when it closed unmerged; a merged one's go " +
"when the walk answering its merge ends, and any check build goes after a week.",
Input: schema(map[string]string{"owner": "the repository's owner", "repo": "the repository",
"number": "the pull request's number"}, []string{"owner", "repo", "number"})},
{Name: "forget-checks", Description: "Removed in R4e (ADR 0501 §5 / ADR 0503 §3): refused by name, naming its replacement",
Input: schema(nil, nil)},
{Name: "kill", Description: "End a build where it runs: the machine that took it stops its commands and containers and " +
"announces it failed, killed by hand — settled, never handed to another machine.",
Input: schema(map[string]string{"id": "the build's id"}, []string{"id"})},
@@ -595,22 +575,8 @@ var ControllerVerbs = []Verb{
"machine": "one machine (optional)",
"retired": "\"true\": only what is retired",
}, nil, "retired")},
// Three shapes, as the command has them: one repository, every module standing on a base (on), or
// every module behind its source (behind). One per call; a second is refused as passed over.
{Name: "build", Description: "Have the build machine build a repository — or, instead, every module that " +
"stands on a base (on), or every module behind its source (behind). Answers at once with each build's id: " +
"`builds` with an id follows it line by line, and a module is registered when its outcome comes.",
Input: schema(map[string]string{
"repository": "the repository's URL, or its path on the forge holding the git seat (owner/name); " +
"needed unless on or behind is given",
"path": "with repository: the module's directory inside it (optional)",
"ref": "with repository: the branch, tag or commit to build (optional)",
"on": "instead of a repository: a module's name. Every module the mesh holds that was built against " +
"its artifacts is built again from its own source, bases among them asked first — the rebuild a " +
"changed base needs, which behind does not see because their sources did not move",
"behind": "\"true\", instead of a repository: build every module the mesh holds whose source has moved " +
"past the commit it was built from, bases asked first",
}, nil, "behind")},
{Name: "build", Description: "Removed in R4e (ADR 0501 §5 / ADR 0503 §3): refused by name, naming its replacement",
Input: schema(nil, nil)},
// What the records say the registries may keep, asked on demand (novox/hq ADR 0251, to-be 51).
{Name: "artifacts", Description: "Every artifact the mesh recorded making that lives in its artifact store, " +
"each with its repository, digest, kind (image or archive) and state: kept — and why: a definition names " +
+20 -3
View File
@@ -74,7 +74,10 @@ func (c *composer) overlayRange() string {
}
// network builds the private network over the machines that resolved the module for it. A machine is on it because it
// was given the module, or because it is joining with a key and an address (novox/hq ADR 0169).
// was given the module, or because it is joining with a key and an address (novox/hq ADR 0169), or because its own set
// could not be resolved and it holds a place (a key and an address): that place is its last good one on the network, and
// it does not change because its modules failed, so every other machine keeps its path to it -- the hub above all -- and
// only its own declaration is refused (issue 1833). A machine refused with no place is left off.
func (c *composer) network(on map[string]bool, refused map[string]string) (*overlay.Generator, error) {
tunnels := c.in.Tunnels
carried := c.in.CarriedPeers
@@ -85,9 +88,15 @@ func (c *composer) network(on map[string]bool, refused map[string]string) (*over
var nodes []overlay.Node
for _, node := range c.in.sorted() {
p := node.Place
if !on[node.Name] && !(isJoining[node.Name] && p.Key != "" && p.Address != "") {
placed := p.Key != "" && p.Address != ""
_, wasRefused := refused[node.Name]
if !on[node.Name] && !((isJoining[node.Name] || wasRefused) && placed) {
continue
}
if !on[node.Name] && wasRefused && !isJoining[node.Name] {
c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it",
node.Name, p.Address)
}
n := overlay.Node{
Name: node.Name, Key: p.Key, Endpoint: p.Endpoint,
Site: p.Site, Hub: p.Hub, Address: p.Address,
@@ -233,7 +242,9 @@ func (c *composer) whereEveryoneIs() (map[string]string, error) {
}
// onTheNetwork is every placed machine that resolves the private network: has an address AND runs what puts it there
// (novox/hq issue 079). A machine that does not resolve is not counted, and said.
// (novox/hq issue 079). A machine that does not resolve keeps its place when it holds one (a key and an address), by the
// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not
// counted, and said.
func (c *composer) onTheNetwork() []Node {
var out []Node
for _, n := range c.in.sorted() {
@@ -247,6 +258,12 @@ func (c *composer) onTheNetwork() []Node {
catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities},
catalogue.World{Unchecked: true, Holdings: c.in.Holdings})
if err != nil {
if n.Place.Key != "" {
c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it",
n.Name, n.Place.Address)
out = append(out, n)
continue
}
c.say("%s is not counted as on the network: it does not resolve: %v", n.Name, err)
continue
}
+8 -3
View File
@@ -159,9 +159,11 @@ func TestANodeNotOnThePrivateNetworkGetsNoAddressAndNoNetwork(t *testing.T) {
}
// 1360/AC3: a node whose resolution is refused is said with today's words: the network missing its hub names the
// machine that could not be resolved, which is almost always why.
// machine that could not be resolved, which is almost always why. The hub here holds no complete place (no key): a refused
// machine with one keeps it on the network (issue 1833), so only one without is missing from it.
func TestARefusedNodeIsSaidWhenTheNetworkCannotBeComposed(t *testing.T) {
in := aMesh(t)
nodeOf(t, &in, "anchor").Place.Key = ""
one := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
two := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
in.Catalogue["rival-one"], in.Catalogue["rival-two"] = one, two
@@ -210,9 +212,10 @@ func TestEachFieldOfTheInputReachesTheGenerator(t *testing.T) {
if len(base) != 2 {
t.Fatalf("the base mesh has %v", base)
}
t.Run("Catalogue: without the module nobody is on the network", func(t *testing.T) {
t.Run("Catalogue: without the module offering it nobody is on the network", func(t *testing.T) {
in := aMesh(t)
in.Catalogue = map[string]catalogue.Manifest{}
// The module stays known, so every machine still resolves: a machine that does not resolve keeps its place (issue 1833).
in.Catalogue = map[string]catalogue.Manifest{overlay.Name: {Module: overlay.Name, Version: "1"}}
if got := addressesOf(in); len(got) != 0 {
t.Errorf("got %v", got)
}
@@ -290,6 +293,8 @@ func TestEachFieldOfTheInputReachesTheGenerator(t *testing.T) {
}
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "holder-one", "holder-two")
// Without a key: a machine that does not resolve and holds a complete place keeps it on the network (issue 1833).
laptop.Place.Key = ""
if got := addressesOf(in); got["laptop"] != "" {
t.Fatalf("a machine whose set claims one seat twice is on the network: %v", got)
}
+3 -1
View File
@@ -387,9 +387,11 @@ func TestAnotherNodesKeptBuildDoesNotChangeTheRestOfTheMeshOrWhoIsOnTheNetwork(t
func overlayName() string { return overlay.Name }
// Round 1, finding 6: the nodes the composition did not count as on the network are returned, said, not lost.
// Round 1, finding 6: the nodes the composition did not count as on the network are returned, said, not lost. laptop holds no
// complete place (no key): one that does keeps it on the network (issue 1833) and is counted.
func TestANodeNotCountedAsOnTheNetworkIsSaidInTheResult(t *testing.T) {
in := aMesh(t)
nodeOf(t, &in, "laptop").Place.Key = ""
in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
laptop := nodeOf(t, &in, "laptop")
+167
View File
@@ -0,0 +1,167 @@
package compose
import (
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/overlay"
)
// fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at
// ace.example:51820, at 10.77.0.3, and `box`, behind no endpoint, at 10.77.0.4. A module `office` requires
// `document-server`, which nothing in the catalogue provides: the shape of issue 1833 (nextcloud on the hub wanting a
// document server nobody offered).
func fourOnTheNetwork(t *testing.T) Input {
t.Helper()
in := aMesh(t)
in.Catalogue["office"] = catalogue.Manifest{Module: "office", Version: "1", Requires: []string{"document-server"}}
caps := map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}
in.Nodes = append(in.Nodes,
Node{Name: "ace", Place: Place{Key: keyOf(t, "ace"), Endpoint: "ace.example:51820", Site: "there", Address: "10.77.0.3"},
Assigned: []string{overlay.Name}, Capabilities: caps},
Node{Name: "box", Place: Place{Key: keyOf(t, "box"), Site: "there", Address: "10.77.0.4"},
Assigned: []string{overlay.Name}, Capabilities: caps})
return in
}
// unresolvableBy makes one machine's own set fail to resolve: it is also given `office`, whose requirement nothing provides.
func unresolvableBy(t *testing.T, in *Input, name string) {
t.Helper()
n := nodeOf(t, in, name)
n.Assigned = append(n.Assigned, "office")
}
// networkOf is the generator of an Input's private network, failing the test when it is not composed.
func networkOf(t *testing.T, in Input) *overlay.Generator {
t.Helper()
gens, err := Generators(in)
if err != nil {
t.Fatalf("the private network was not composed: %v", err)
}
return gens[overlay.Name].(*overlay.Generator)
}
func placesOf(g *overlay.Generator) map[string]overlay.Node {
out := map[string]overlay.Node{}
for _, n := range g.Nodes() {
out[n.Name] = n
}
return out
}
// 1833/AC1: four machines, the hub's own set does not resolve; the other three compose their declarations, and the hub alone is
// refused, with an error that is its own set failing and names what could not be resolved.
func TestOneMachineThatDoesNotResolveIsRefusedAlone(t *testing.T) {
in := fourOnTheNetwork(t)
unresolvableBy(t, &in, "anchor")
for _, node := range []string{"laptop", "ace", "box"} {
d, problems, err := Compose(in, node)
if err != nil || len(problems) != 0 {
t.Errorf("%s does not compose because another machine does not resolve: %v %v", node, err, problems)
continue
}
if !has(resourceIDs(d), "file:mesh-wireguard.overlay-config") {
t.Errorf("%s is given no private network: %v", node, resourceIDs(d))
}
}
_, _, err := Compose(in, "anchor")
if err == nil || !IsUnresolvable(err) {
t.Fatalf("the machine that does not resolve is not refused as its own set failing: %v", err)
}
if !strings.Contains(err.Error(), `nothing provides "document-server", wanted by office`) {
t.Errorf("the refusal does not name what could not be resolved: %v", err)
}
}
// 1833/AC2: the private network is composed with every machine on it: the hub that does not resolve keeps its place (key,
// address, endpoint, site, hub role), so every other machine keeps its path through it.
func TestAMachineThatDoesNotResolveKeepsItsPlaceOnTheNetwork(t *testing.T) {
in := fourOnTheNetwork(t)
unresolvableBy(t, &in, "anchor")
got := placesOf(networkOf(t, in))
if len(got) != 4 {
t.Fatalf("the network is over %v, want all four machines", got)
}
hub := got["anchor"]
if !hub.Hub || hub.Key != keyOf(t, "anchor") || hub.Address != "10.77.0.1" || hub.Endpoint != "anchor.example:51820" || hub.Site != "here" {
t.Errorf("the hub did not keep its place: %+v", hub)
}
nodes, graph, err := Graph(in)
if err != nil || len(nodes) != 4 {
t.Fatalf("the graph is not shown with every machine: %v %v", nodes, err)
}
for _, node := range []string{"ace", "box"} {
through := false
for _, p := range graph[node] {
through = through || (p.Name == "anchor" && p.Endpoint == "anchor.example:51820")
}
if !through {
t.Errorf("%s has no path through the hub: %+v", node, graph[node])
}
}
}
// 1833/AC3: a machine that does not resolve and was never placed (a new machine: no key, no address) is left off the network,
// which is composed from the rest -- here with the hub that does not resolve on its last place -- and that machine alone is refused.
func TestANewMachineThatDoesNotResolveIsLeftOffAndRefusedAlone(t *testing.T) {
in := fourOnTheNetwork(t)
unresolvableBy(t, &in, "anchor")
in.Nodes = append(in.Nodes, Node{Name: "fresh", Place: Place{Site: "there"},
Assigned: []string{overlay.Name, "office"}, Capabilities: map[string]bool{"wireguard": true, "systemd": true}})
got := placesOf(networkOf(t, in))
if _, on := got["fresh"]; on || len(got) != 4 {
t.Fatalf("the network is over %v, want the four placed machines without fresh", got)
}
if _, _, err := Compose(in, "fresh"); err == nil || !IsUnresolvable(err) {
t.Errorf("the new machine is not refused as its own set failing: %v", err)
}
if _, problems, err := Compose(in, "box"); err != nil || len(problems) != 0 {
t.Errorf("another machine does not compose: %v %v", err, problems)
}
}
// 1833/AC4: a network that truly cannot be composed from what remains still fails: the hub does not resolve and has no
// complete place (no key), so nothing is the hub; the refusal is today's, naming the machine that could not be resolved.
func TestANetworkThatCannotBeComposedFromWhatRemainsStillFails(t *testing.T) {
in := fourOnTheNetwork(t)
unresolvableBy(t, &in, "anchor")
nodeOf(t, &in, "anchor").Place.Key = ""
_, err := Generators(in)
if err == nil {
t.Fatal("a network without a hub was composed")
}
for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q:\n%v", want, err)
}
}
if _, _, err := Compose(in, "laptop"); err == nil {
t.Error("a declaration was composed over a network that cannot be")
}
}
// 1833/AC2, a failure the composition's unchecked pass refuses too (two modules claiming one seat on the hub, the 1360/AC3
// shape): the hub keeps its place in what every other machine is sent besides the tunnel -- its internal name and its address
// among the sources "from the mesh" -- and another machine still composes.
func TestAMachineThatDoesNotResolveKeepsItsNameAndItsAddressInTheMesh(t *testing.T) {
in := fourOnTheNetwork(t)
in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
hub := nodeOf(t, &in, "anchor")
hub.Assigned = append(hub.Assigned, "rival-one", "rival-two")
if _, err := Resolve(in, "anchor"); !IsUnresolvable(err) {
t.Fatalf("the hub is meant not to resolve: %v", err)
}
c := newComposer(in)
if got := c.namesInTheMesh()["anchor."+c.suffix()]; got != "10.77.0.1" {
t.Errorf("the hub's name is not in the mesh's names: %v", c.namesInTheMesh())
}
if got := c.onThePrivateNetwork(); !slices.Contains(got, "10.77.0.1") || len(got) != 4 {
t.Errorf("the sources from the mesh are %v, want all four machines with the hub", got)
}
if _, problems, err := Compose(in, "ace"); err != nil || len(problems) != 0 {
t.Errorf("another machine does not compose: %v %v", err, problems)
}
}
+109 -8
View File
@@ -63,8 +63,18 @@ func ParseBuilderBuildKey(key string) (module, fingerprint string, err error) {
return module, fingerprint, nil
}
// BuiltArtifact is one artifact of a done build: its name in the manifest, its kind, its content digest and, for an
// image, the reference it was pushed to.
// BuiltArtifact is one artifact of a done build: its name in the manifest, its kind, its content digest (`sha256:` and
// 64 hex digits) and the reference that names where it is (novox/hq ADR 0498 clauses 1, 5 and 6; the lead's decision
// L1 of R4e), by kind:
//
// bundle, archive artifact-store://<module>/<artifact>/blobs/<digest> the blob pushed to the artifact store
// image, upstream <registry>/<module>/<artifact>@<digest> the image registry's reference, which
// artifact-store://<module>/<artifact>@<digest> catalogue.Recorded keeps without its address
// package <name>@<version> the version published from the trunk
//
// <artifact> is Name and <digest> is Digest. A reference is empty for a candidate's image or package, which is never
// pushed or published (ADR 0498 clause 6), and in a key done before R4e; the controller delivers only from a key whose
// every artifact names one (Referenced, clause 8). A kind left empty (a key written before kinds) is not checked.
type BuiltArtifact struct {
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
@@ -72,6 +82,83 @@ type BuiltArtifact struct {
Reference string `json:"reference,omitempty"`
}
const artifactStore = "artifact-store://"
// referenceProblem says what is wrong with a's reference for its kind, or "".
func (a BuiltArtifact) referenceProblem() string {
r := a.Reference
if r == "" || a.Kind == "" {
return ""
}
switch a.Kind {
case "bundle", "archive":
path, ok := strings.CutPrefix(r, artifactStore)
module, rest, _ := strings.Cut(path, "/")
if !ok || module == "" || rest != a.Name+"/blobs/"+a.Digest {
return "is not artifact-store://<module>/" + a.Name + "/blobs/" + a.Digest
}
case "image", "upstream":
path, digest, ok := strings.Cut(r, "@")
if !ok || digest != a.Digest {
return "does not name its digest " + a.Digest
}
var host, module, name string
if rest, kept := strings.CutPrefix(path, artifactStore); kept {
module, name, _ = strings.Cut(rest, "/")
host = "kept"
} else {
parts := strings.Split(path, "/")
if len(parts) == 3 {
host, module, name = parts[0], parts[1], parts[2]
}
}
if host == "" || !(host == "kept" || host == "localhost" || strings.ContainsAny(host, ".:")) || module == "" || name != a.Name {
return "is not <registry>/<module>/" + a.Name + "@" + a.Digest
}
case "package":
at := strings.LastIndex(r, "@")
if at <= 0 || at == len(r)-1 || r[:at] == "@" {
return "is not <name>@<version>"
}
}
return ""
}
// Referenced says whether b is done and every artifact names where it is, which the controller delivers from (novox/hq
// ADR 0498 clause 8), and names the artifacts that name no reference. A done key with no artifact (a module that builds
// none, issue 1613) is referenced.
func (b BuilderBuild) Referenced() (ok bool, missing []string) {
for _, a := range b.Artifacts {
if a.Reference == "" {
missing = append(missing, a.Name)
}
}
return b.State == BuildDone && len(missing) == 0, missing
}
// The prefixes of a failed key's error that a build module answers (novox/hq ADR 0498 clauses 2, 4 and 5; ADR 0478
// clause 2), each followed by the artifact and the words of what refused it.
const (
// NotStored: the push of a bundle, an archive or an upstream copy to the artifact store failed; built again.
NotStored = "not stored: "
// NotPinned: an upstream artifact names no digest.
NotPinned = "not pinned: "
// VersionTaken: the package registry holds the package's version with another packed file.
VersionTaken = "version taken: "
// RefusedKind: no build module builds this kind of artifact in its language.
RefusedKind = "refused kind: "
// WaitsFor: a fetch found the module's lock naming a package version the registry does not hold yet; the builder
// writes the key queued with Waits (novox/hq ADR 0500 clause 6), and it is built once the package is published.
WaitsFor = "waits for: "
)
// FailedUntilChanged says whether a failed key's error is one that is not built again until the module's fingerprint
// changes (not pinned, version taken, refused kind: the same source fails the same way); every other failed key, a
// push that was not stored included, is built again as a new revision (ADR 0429 clause 18).
func FailedUntilChanged(err string) bool {
return strings.HasPrefix(err, NotPinned) || strings.HasPrefix(err, VersionTaken) || strings.HasPrefix(err, RefusedKind)
}
// ToolchainUsed is what a build used for one language (ADR 0405 clauses 2 and 7): the installed toolchain's version and
// the build module's recipe version.
type ToolchainUsed struct {
@@ -82,7 +169,8 @@ type ToolchainUsed struct {
// BuilderBuild is the value of a builder build key: its State (queued, building, done or failed), By the builder
// instance that holds it, Step what it is doing or did last, Manifest the digest of the module's manifest (the key of
// the indexer's manifests bucket), Artifacts when done, Toolchains by language when it built or is building (a done
// build names them, so two builders that differ are seen: ADR 0405 clause 2), and Error when failed.
// build names them, so two builders that differ are seen: ADR 0405 clause 2), Error when failed, and Waits when queued for a
// package version.
type BuilderBuild struct {
V int `json:"v"`
State string `json:"state"`
@@ -92,6 +180,10 @@ type BuilderBuild struct {
Artifacts []BuiltArtifact `json:"artifacts,omitempty"`
Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"`
Error string `json:"error,omitempty"`
// Waits is, on a queued key only, the package version `<name>@<version>` the module's lock names and the registry
// does not yet hold: the key is not claimed until that package's trunk key is done and published (novox/hq ADR 0500
// clause 6). A reader that does not know the field reads the key as queued.
Waits string `json:"waits,omitempty"`
}
func (b BuilderBuild) check() error {
@@ -120,6 +212,17 @@ func (b BuilderBuild) check() error {
if a.Digest == "" {
return fmt.Errorf("delivery: the artifact %q of the build has no digest", a.Name)
}
if p := a.referenceProblem(); p != "" {
return fmt.Errorf("delivery: the reference %q of the %s %q %s", a.Reference, a.Kind, a.Name, p)
}
}
if b.Waits != "" {
if b.State != BuildQueued {
return fmt.Errorf("delivery: a %s build waits for %q; only a queued key waits for a package", b.State, b.Waits)
}
if p := (BuiltArtifact{Kind: "package", Reference: b.Waits}).referenceProblem(); p != "" {
return fmt.Errorf("delivery: the package a key waits for, %q, %s", b.Waits, p)
}
}
switch b.State {
case BuildQueued, BuildBuilding:
@@ -127,9 +230,7 @@ func (b BuilderBuild) check() error {
return fmt.Errorf("delivery: a %s build carries no artifact and no error", b.State)
}
case BuildDone:
if len(b.Artifacts) == 0 {
return fmt.Errorf("delivery: a done build names no artifact")
}
// A module that builds no artifact gets a done key too, and is delivered from it (issue 1613).
if b.Manifest == "" {
return fmt.Errorf("delivery: a done build names no manifest digest")
}
@@ -144,8 +245,8 @@ func (b BuilderBuild) check() error {
return nil
}
// EncodeBuilderBuild is b as JSON at Version; it refuses a build its state does not allow (done without artifacts or a
// manifest digest, failed without an error, an artifact without a name or digest, a toolchain without a version or a
// EncodeBuilderBuild is b as JSON at Version; it refuses a build its state does not allow (done without a manifest
// digest, failed without an error, an artifact without a name or digest, a toolchain without a version or a
// recipe version).
func EncodeBuilderBuild(b BuilderBuild) ([]byte, error) {
if err := b.check(); err != nil {
+143
View File
@@ -0,0 +1,143 @@
package delivery
import (
"strings"
"testing"
)
func artifactOf(name, kind, digest, reference string) BuiltArtifact {
return BuiltArtifact{Name: name, Kind: kind, Digest: digest, Reference: reference}
}
// A done key names where each artifact is (novox/hq ADR 0498 clauses 1, 5 and 6; the lead's decision L1 of R4e): a
// bundle or an archive by its artifact-store blob, an image or an upstream copy by the registry's reference to its
// digest (or the kept artifact-store form), a package by name and version. A reference of the wrong form for its kind,
// or naming another digest or artifact, is refused, naming the artifact.
func TestADoneKeyNamesEachArtifactsReferenceInTheFormOfItsKind(t *testing.T) {
d := "sha256:" + digestOf('6')
good := []BuiltArtifact{
artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+d),
artifactOf("files", "archive", d, "artifact-store://mail/files/blobs/"+d),
artifactOf("server", "image", d, "10.0.0.1:5000/mail/server@"+d),
artifactOf("server", "image", d, "artifact-store://mail/server@"+d),
artifactOf("nats", "upstream", d, "registry.example/nats/nats@"+d),
artifactOf("lib", "package", d, "@novox/mesh-sdk@0.1.15"),
artifactOf("lib", "package", d, "left-pad@1.3.0"),
artifactOf("old", "", d, "anything at all"),
}
for _, a := range good {
b := doneBuild()
b.Artifacts = []BuiltArtifact{a}
if _, err := EncodeBuilderBuild(b); err != nil {
t.Errorf("%+v was refused: %v", a, err)
}
}
other := "sha256:" + digestOf('7')
bad := []BuiltArtifact{
artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+other),
artifactOf("code", "bundle", d, "artifact-store://mail/other/blobs/"+d),
artifactOf("code", "bundle", d, "10.0.0.1:5000/mail/code@"+d),
artifactOf("server", "image", d, "10.0.0.1:5000/mail@"+d),
artifactOf("server", "image", d, "10.0.0.1:5000/mail/server@"+other),
artifactOf("server", "image", d, "mail/server@"+d),
artifactOf("server", "image", d, "artifact-store://mail/server/blobs/"+d),
artifactOf("lib", "package", d, "@novox/mesh-sdk"),
artifactOf("lib", "package", d, "0.1.15"),
}
for _, a := range bad {
b := doneBuild()
b.Artifacts = []BuiltArtifact{a}
if _, err := EncodeBuilderBuild(b); err == nil || !strings.Contains(err.Error(), `"`+a.Name+`"`) {
t.Errorf("%+v: %v, wanted a refusal naming the artifact", a, err)
}
}
}
// The controller delivers only from a done key whose every artifact names a reference (ADR 0498 clause 8): Referenced
// says so and names the artifacts without one.
func TestReferencedNamesTheArtifactsWithoutAReference(t *testing.T) {
d := "sha256:" + digestOf('6')
b := doneBuild()
b.Artifacts = []BuiltArtifact{
artifactOf("code", "bundle", d, "artifact-store://mail/code/blobs/"+d),
artifactOf("server", "image", d, ""),
}
if ok, missing := b.Referenced(); ok || len(missing) != 1 || missing[0] != "server" {
t.Fatalf("Referenced = %v, %v; want false, [server]", ok, missing)
}
b.Artifacts[1].Reference = "10.0.0.1:5000/mail/server@" + d
if ok, missing := b.Referenced(); !ok || len(missing) != 0 {
t.Fatalf("Referenced = %v, %v; want true", ok, missing)
}
if ok, _ := (BuilderBuild{State: BuildQueued}).Referenced(); ok {
t.Fatal("a queued key is referenced")
}
}
// The errors a build module answers open with a fixed prefix (ADR 0498 clauses 2, 4 and 5; ADR 0478 clause 2), and three
// of them are not built again until the module's fingerprint changes; a failed push is.
func TestOnlyAPushFailureIsBuiltAgainBeforeTheFingerprintChanges(t *testing.T) {
for _, c := range []struct {
err string
again bool
}{
{NotStored + "code: the store said 500", true},
{NotPinned + "nats: upstream names no digest", false},
{VersionTaken + "@novox/mesh-sdk@0.1.15 holds another file", false},
{RefusedKind + "a package in go", false},
{"go test: 2 failed", true},
} {
if got := FailedUntilChanged(c.err); got == c.again {
t.Errorf("FailedUntilChanged(%q) = %v", c.err, got)
}
}
if NotStored != "not stored: " || NotPinned != "not pinned: " || VersionTaken != "version taken: " || RefusedKind != "refused kind: " {
t.Fatal("a prefix changed its words")
}
}
// A key whose module's lock names a version of the group's package not yet published waits, queued, saying which
// (ADR 0500 clause 6): an older reader reads it as queued, and only a queued key waits.
func TestAKeyWaitsQueuedForAPackageVersion(t *testing.T) {
b := BuilderBuild{State: BuildQueued, Manifest: digestOf('4'), Waits: "@novox/mesh-sdk@0.1.15"}
data, err := EncodeBuilderBuild(b)
if err != nil {
t.Fatal(err)
}
back, err := DecodeBuilderBuild(data)
if err != nil || back.Waits != b.Waits || back.State != BuildQueued {
t.Fatalf("decoded %+v, %v", back, err)
}
building := BuilderBuild{State: BuildBuilding, By: "x", Waits: "@novox/mesh-sdk@0.1.15"}
if _, err := EncodeBuilderBuild(building); err == nil {
t.Fatal("a building key that waits was accepted")
}
if _, err := EncodeBuilderBuild(BuilderBuild{State: BuildQueued, Waits: "mesh-sdk"}); err == nil {
t.Fatal("a wait naming no version was accepted")
}
}
// A module that builds no artifact gets a done key too (issue 1613), and it is delivered: Referenced is ok for a done
// key with no artifact, since no artifact lacks a reference; a queued one with none still is not.
func TestADoneKeyWithNoArtifactIsReferenced(t *testing.T) {
b := BuilderBuild{State: BuildDone, Manifest: digestOf('4')}
data, err := EncodeBuilderBuild(b)
if err != nil {
t.Fatalf("a done key with no artifact was refused: %v", err)
}
back, err := DecodeBuilderBuild(data)
if err != nil {
t.Fatal(err)
}
if ok, missing := back.Referenced(); !ok || len(missing) != 0 {
t.Fatalf("Referenced = %v, %v; want true", ok, missing)
}
}
// A Node fetch that finds the lock naming a package version the registry does not hold fails with WaitsFor; the
// builder then writes the key queued with Waits, and the error is not one that waits for the fingerprint to change.
func TestWaitsForIsAPrefixThatIsBuiltAgain(t *testing.T) {
if WaitsFor != "waits for: " || FailedUntilChanged(WaitsFor+"@novox/mesh-sdk@0.1.15") {
t.Fatalf("WaitsFor = %q", WaitsFor)
}
}
+4 -5
View File
@@ -15,7 +15,7 @@ func doneBuild() BuilderBuild {
Manifest: digestOf('4'),
Artifacts: []BuiltArtifact{
{Name: "code", Kind: "bundle", Digest: "sha256:" + digestOf('5')},
{Name: "server", Kind: "image", Digest: "sha256:" + digestOf('6'), Reference: "registry.example/mail@sha256:" + digestOf('6')},
{Name: "server", Kind: "image", Digest: "sha256:" + digestOf('6'), Reference: "registry.example/mail/server@sha256:" + digestOf('6')},
},
Toolchains: map[string]ToolchainUsed{
"go": {Version: "1.26.0", Recipe: "3"},
@@ -83,14 +83,13 @@ func TestBuilderBuildRoundTrip(t *testing.T) {
}
}
// 1358/AC3: done without artifacts or without a manifest digest, failed without an error, an artifact without a digest
// 1358/AC3: done without a manifest digest, failed without an error, an artifact without a digest
// and an unknown state are refused.
func TestBuilderBuildChecked(t *testing.T) {
cases := map[string]struct {
edit func(*BuilderBuild)
words []string
}{
"done without artifacts": {func(b *BuilderBuild) { b.Artifacts = nil }, []string{"artifact"}},
"done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}},
"a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}},
"an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}},
@@ -125,8 +124,8 @@ func TestBuilderBuildChecked(t *testing.T) {
t.Error("a queued build with artifacts accepted")
}
// decode refuses what encode refuses
if _, err := DecodeBuilderBuild([]byte(`{"v":1,"state":"done","manifest":"` + digestOf('4') + `"}`)); err == nil {
t.Error("decoded a done build without artifacts")
if _, err := DecodeBuilderBuild([]byte(`{"v":1,"state":"done"}`)); err == nil {
t.Error("decoded a done build without a manifest digest")
}
}
+40
View File
@@ -0,0 +1,40 @@
package delivery
import (
"strings"
"testing"
)
// A candidate's module carries the git objects of its named paths at the preview (novox/hq ADR 0499 clause 2), so the
// builder's fetch request for a candidate names them; an older candidate decodes without them, and a path that is not
// clean and relative, or names no object, is refused.
func TestACandidatesModuleCarriesItsNamedPaths(t *testing.T) {
c := sampleCandidate()
paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)}
c.Pulls[0].Fps = map[string]CandidateModule{"mesh-mail": {FP2: SourcePrefix2 + strings.Repeat("1", 64),
Manifest: strings.Repeat("2", 64), Paths: paths}}
c.FPSet = FingerprintSet(c.Pulls)
data, err := EncodeCandidate(c)
if err != nil {
t.Fatal(err)
}
back, err := DecodeCandidate(data)
if err != nil || back.Pulls[0].Fps["mesh-mail"].Paths["src"] != paths["src"] {
t.Fatalf("decoded %+v, %v", back.Pulls[0].Fps, err)
}
old := sampleCandidate()
old.Pulls[0].Fps = map[string]CandidateModule{"mesh-mail": {FP2: SourcePrefix2 + strings.Repeat("1", 64)}}
old.FPSet = FingerprintSet(old.Pulls)
if data, err := EncodeCandidate(old); err != nil {
t.Fatal(err)
} else if b, err := DecodeCandidate(data); err != nil || b.Pulls[0].Fps["mesh-mail"].Paths != nil {
t.Fatalf("an older candidate: %+v, %v", b, err)
}
for _, bad := range []map[string]string{{"../x": "a"}, {"/src": "a"}, {"": "a"}, {"src": ""}} {
c.Pulls[0].Fps = map[string]CandidateModule{"mesh-mail": {FP2: SourcePrefix2 + strings.Repeat("1", 64), Paths: bad}}
c.FPSet = FingerprintSet(c.Pulls)
if _, err := EncodeCandidate(c); err == nil {
t.Errorf("%v was accepted", bad)
}
}
}
+3 -1
View File
@@ -29,7 +29,9 @@ const (
)
// The contexts of the statuses the mesh owes the forge: the gate's, the delivery's view of a head and of a group
// (design 47), and the checker's, which is not required until R4f (ADR 0484 clause 2, ADR 0453 clause 4).
// (design 47), and the checker's. From R4e the checker's mesh/check is the one status a repository's main requires,
// in place of mesh/merge-gate and mesh/repo-check (ADR 0503 clauses 3 and 5); a main that requires only
// mesh/repo-check (hq) keeps it, and the controller writes it for a repository the checker writes no status for.
const (
StatusContextMergeGate = "mesh/merge-gate"
StatusContextDelivery = "mesh/delivery"
+32
View File
@@ -0,0 +1,32 @@
package delivery
import (
"strings"
"testing"
)
// From R4e a modules value needs no old form (novox/hq ADR 0453 clause 6; issue 1851): a value with only the new
// fingerprint, or only the reason it has none, is written and read; an old value with fp or none still reads; a value
// with no fingerprint of either form and no reason is still refused.
func TestAModulesValueNeedsNoOldForm(t *testing.T) {
fp2 := SourcePrefix2 + strings.Repeat("a", 64)
for _, m := range []Module{
{FP2: fp2, Repository: "a/b", Commit: "c"},
{None2: "no lock", Repository: "a/b", Commit: "c"},
} {
data, err := EncodeModule(m)
if err != nil {
t.Fatalf("%+v was refused: %v", m, err)
}
back, err := DecodeModule(data)
if err != nil || back.FP2 != m.FP2 || back.None2 != m.None2 || back.FP != "" || back.None != "" {
t.Fatalf("decoded %+v, %v", back, err)
}
}
if _, err := DecodeModule([]byte(`{"v":1,"fp":"src1:x","repository":"a/b","commit":"c"}`)); err != nil {
t.Fatalf("an old value no longer reads: %v", err)
}
if _, err := EncodeModule(Module{Repository: "a/b", Commit: "c"}); err == nil {
t.Fatal("a value with no fingerprint and no reason was accepted")
}
}
+33
View File
@@ -0,0 +1,33 @@
package delivery
import (
"strings"
"testing"
)
// The indexer's modules value carries each artifact's named paths (novox/hq ADR 0499; the lead's decision L8 of R4e):
// the checker tells from it whether a change touches lib's paths without reading a manifest. An older value has none.
func TestAModulesValueCarriesEachArtifactsNamedPaths(t *testing.T) {
m := Module{FP2: SourcePrefix2 + strings.Repeat("a", 64), Repository: "novox/mesh-sdk", Commit: "c",
ArtifactPaths: map[string][]string{"lib": {"package-lock.json", "package.json", "src", "test", "tsconfig.json"}}}
data, err := EncodeModule(m)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"paths":{"lib":[`) {
t.Fatalf("the value carries no paths: %s", data)
}
back, err := DecodeModule(data)
if err != nil || len(back.ArtifactPaths["lib"]) != 5 || back.ArtifactPaths["lib"][2] != "src" {
t.Fatalf("decoded %+v, %v", back.ArtifactPaths, err)
}
if old, err := DecodeModule([]byte(`{"v":1,"fp":"src1:x","repository":"a/b","commit":"c"}`)); err != nil || old.ArtifactPaths != nil {
t.Fatalf("an older value: %+v, %v", old, err)
}
for _, bad := range []map[string][]string{{"": {"src"}}, {"lib": {}}, {"lib": {"../x"}}, {"lib": {""}}} {
m.ArtifactPaths = bad
if _, err := EncodeModule(m); err == nil {
t.Errorf("%v was accepted", bad)
}
}
}
+47
View File
@@ -0,0 +1,47 @@
package delivery
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"sort"
"strings"
)
// NamedTreePrefix opens the tree input of a module whose every artifact names its paths, so such a value is never read
// as a git tree id and a reader of a kept Tree can tell the two apart.
const NamedTreePrefix = "named:"
// NamedTree is the tree input of the new-form fingerprint of a module whose every artifact names the paths it is built
// from (novox/hq ADR 0499 clause 2): `named:` and the hex sha256 of the lines "manifest <digest>" and, sorted,
// "path <path> <object>", joined by a newline. manifest is the module's ManifestDigest (the sha256 of its module.json's
// exact bytes, as Module.Manifest and the merge preview carry it); paths are the named paths, each clean and relative to
// the module's directory as the manifest names them (catalogue.Manifest.NamedPaths), with the git object id each has at
// the commit (a blob for a file, a tree for a directory).
//
// **One function computes it** (clause 2): the indexer, its merge preview (buildstep.ModuleTrees) and the build modules
// call this one, and no second computation exists. It refuses no path, a named path with no object (absent at the
// commit), a path that is not clean, and a manifest that is not a digest, naming them: the indexer then gives the
// module none, with that reason (clause 3).
func NamedTree(manifest string, paths map[string]string) (string, error) {
if _, err := ManifestsKey(manifest); err != nil {
return "", err
}
if len(paths) == 0 {
return "", fmt.Errorf("delivery: the module names no path, so it has no named tree")
}
lines := make([]string, 0, len(paths))
for p, object := range paths {
if p == "" || p != path.Clean(p) || strings.HasPrefix(p, "/") || p == ".." || strings.HasPrefix(p, "../") {
return "", fmt.Errorf("delivery: the named path %q is not a clean path inside the module's directory", p)
}
if object == "" {
return "", fmt.Errorf("delivery: the named path %q has no git object at the commit", p)
}
lines = append(lines, "path "+p+" "+object)
}
sort.Strings(lines)
sum := sha256.Sum256([]byte("manifest " + manifest + "\n" + strings.Join(lines, "\n")))
return NamedTreePrefix + hex.EncodeToString(sum[:]), nil
}
+79
View File
@@ -0,0 +1,79 @@
package delivery
import (
"strings"
"testing"
)
// The named paths' git objects travel beside a module's other new-form inputs (novox/hq ADR 0499 clauses 2 and 3): in
// the forge bridge's messages (Forge.Paths), in the merge preview (PreviewModule.Paths) and in what the indexer keeps
// (KeptInputs2.Paths), so a computation from kept inputs gives the merge's value. Each is read as absent by an older
// reader, and an older value, without them, still decodes.
func TestTheNamedPathsObjectsRoundTripBesideTheOtherInputs(t *testing.T) {
manifest := sha256Of(`{"module":"mesh-sdk"}`)
paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)}
tree, err := NamedTree(manifest, paths)
if err != nil {
t.Fatal(err)
}
m := Module{FP2: SourcePrefix2 + strings.Repeat("1", 64), Manifest: manifest, Repository: "novox/mesh-sdk",
Commit: strings.Repeat("c", 40), Inputs2: &KeptInputs2{Tree: tree, Paths: paths}}
m.FP = SourcePrefix + strings.Repeat("0", 64)
data, err := EncodeModule(m)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"paths":{`) {
t.Fatalf("the kept inputs carry no paths: %s", data)
}
back, err := DecodeModule(data)
if err != nil || back.Inputs2.Paths["src"] != paths["src"] || back.Inputs2.Tree != tree {
t.Fatalf("decoded %+v, %v", back.Inputs2, err)
}
// What the indexer keeps must compute to the tree it kept: a mismatch is a value no recompute could give.
bad := m
bad.Inputs2 = &KeptInputs2{Tree: tree, Paths: map[string]string{"src": strings.Repeat("d", 40)}}
if _, err := EncodeModule(bad); err == nil || !strings.Contains(err.Error(), "named tree") {
t.Fatalf("kept paths that do not compute to the kept tree were accepted: %v", err)
}
// An older value has no paths, and its directory tree stays its tree.
old := `{"v":1,"fp":"` + m.FP + `","manifest":"` + manifest + `","inputs2":{"tree":"` + strings.Repeat("e", 40) +
`"},"repository":"novox/x","commit":"` + strings.Repeat("c", 40) + `"}`
if o, err := DecodeModule([]byte(old)); err != nil || o.Inputs2.Paths != nil {
t.Fatalf("an older value: %+v, %v", o, err)
}
f := Forge{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 1, Head: strings.Repeat("h", 40),
Merge: strings.Repeat("m", 40), Root: strings.Repeat("r", 40), Trees: map[string]string{".": strings.Repeat("r", 40)},
Paths: map[string]map[string]string{".": paths}}
data, err = EncodeForge(f)
if err != nil {
t.Fatal(err)
}
if fb, err := DecodeForge(data); err != nil || fb.Paths["."]["package.json"] != paths["package.json"] {
t.Fatalf("decoded forge %+v, %v", fb.Paths, err)
}
f.Paths = map[string]map[string]string{"other": paths}
if _, err := EncodeForge(f); err == nil {
t.Fatal("named paths of a directory the message has no tree of were accepted")
}
f.Paths = map[string]map[string]string{".": {"src": ""}}
if _, err := EncodeForge(f); err == nil {
t.Fatal("a named path with no object was accepted")
}
p := Preview{Repository: "novox/mesh-sdk", Head: strings.Repeat("a", 40), Trunk: strings.Repeat("b", 40),
Merge: strings.Repeat("c", 40), Modules: map[string]PreviewModule{".": {Tree: tree, Manifest: manifest, Paths: paths}}}
data, err = EncodePreview(p)
if err != nil {
t.Fatal(err)
}
if pb, err := DecodePreview(data); err != nil || pb.Modules["."].Paths["src"] != paths["src"] {
t.Fatalf("decoded preview %+v, %v", pb.Modules, err)
}
p.Modules["."] = PreviewModule{Tree: strings.Repeat("e", 40), Manifest: manifest, Paths: paths}
if _, err := EncodePreview(p); err == nil || !strings.Contains(err.Error(), "named tree") {
t.Fatalf("a preview whose tree is not its paths' named tree was accepted: %v", err)
}
}
+68
View File
@@ -0,0 +1,68 @@
package delivery
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
)
func sha256Of(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
// A module whose every artifact names its paths takes its tree from its manifest and those paths alone (novox/hq ADR
// 0499 clause 2): a change outside them leaves the value equal, a change to a named path or to the manifest changes it,
// and the order the paths are given in changes nothing.
func TestTheNamedTreeIsOfTheManifestAndTheNamedPathsAlone(t *testing.T) {
manifest := sha256Of(`{"module":"mesh-sdk"}`)
paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)}
v, err := NamedTree(manifest, paths)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(v, NamedTreePrefix) || !isHex64(strings.TrimPrefix(v, NamedTreePrefix)) {
t.Fatalf("the value %q is not %s and 64 hex digits", v, NamedTreePrefix)
}
// A change outside the named paths is no input at all: the same inputs give the same value, in any map order.
again, _ := NamedTree(manifest, map[string]string{"package.json": strings.Repeat("b", 40), "src": strings.Repeat("a", 40)})
if again != v {
t.Fatalf("the same inputs gave %q and %q", v, again)
}
changed, _ := NamedTree(manifest, map[string]string{"src": strings.Repeat("c", 40), "package.json": strings.Repeat("b", 40)})
if changed == v {
t.Fatal("a changed named path left the value equal")
}
otherManifest, _ := NamedTree(sha256Of(`{"module":"mesh-sdk","version":"2"}`), paths)
if otherManifest == v {
t.Fatal("a changed manifest left the value equal")
}
renamed, _ := NamedTree(manifest, map[string]string{"lib": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)})
if renamed == v {
t.Fatal("a path named differently with the same object left the value equal")
}
// The lines are fixed, so every computation of it agrees: pinned here.
want := "named:" + sha256Of("manifest "+manifest+"\npath package.json "+strings.Repeat("b", 40)+"\npath src "+strings.Repeat("a", 40))
if v != want {
t.Fatalf("NamedTree = %q, want %q", v, want)
}
}
func TestTheNamedTreeRefusesWhatItCannotPin(t *testing.T) {
manifest := sha256Of("{}")
for _, c := range []struct {
manifest string
paths map[string]string
says string
}{
{manifest, nil, "names no path"},
{manifest, map[string]string{"src": ""}, `the named path "src" has no git object`},
{"abc", map[string]string{"src": "x"}, "not a manifest digest"},
{manifest, map[string]string{"src/../x": "x"}, "is not a clean path"},
{manifest, map[string]string{"": "x"}, "is not a clean path"},
{manifest, map[string]string{"/src": "x"}, "is not a clean path"},
} {
if _, err := NamedTree(c.manifest, c.paths); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("NamedTree(%q, %v) = %v, wanted a refusal saying %q", c.manifest, c.paths, err, c.says)
}
}
}
+7 -1
View File
@@ -98,12 +98,15 @@ func ParsePreviewKey(key string) (repository, head, trunk string, err error) {
return repository, parts[1], parts[2], nil
}
// PreviewModule is what the preview holds of one module directory: the tree sha of the directory, the digest of its
// PreviewModule is what the preview holds of one module directory: the tree sha of the directory (or its named tree), the digest of its
// manifest (the key in the manifests bucket) and the hex sha256 of its lock files by path relative to it.
type PreviewModule struct {
Tree string `json:"tree"`
Manifest string `json:"manifest"`
Locks map[string]string `json:"locks,omitempty"`
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the merge; Tree
// is then their NamedTree (novox/hq ADR 0499 clause 2). Absent otherwise.
Paths map[string]string `json:"paths,omitempty"`
}
// Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge
@@ -152,6 +155,9 @@ func (p Preview) check() error {
return fmt.Errorf("delivery: the lock %q of the preview's module %q has an empty path or hash", path, dir)
}
}
if err := checkNamed(m.Tree, m.Manifest, m.Paths, fmt.Sprintf("the preview's module %q", dir)); err != nil {
return err
}
}
return nil
}
+86 -11
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"path"
"reflect"
"sort"
"strings"
@@ -276,6 +277,9 @@ type CandidateModule struct {
FP2 string `json:"fp2,omitempty"`
None2 string `json:"none2,omitempty"`
Manifest string `json:"manifest,omitempty"`
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the preview
// (novox/hq ADR 0499 clause 2), so the builder's fetch request for the candidate names them. Absent otherwise.
Paths map[string]string `json:"paths,omitempty"`
}
func (m CandidateModule) check() error {
@@ -285,6 +289,9 @@ func (m CandidateModule) check() error {
case m.FP2 == "" && m.None2 == "":
return fmt.Errorf("delivery: a module has neither fp2 nor none2")
}
if err := checkPathObjects(m.Paths); err != nil {
return err
}
return checkNewForm(m.FP2, m.Manifest)
}
@@ -356,7 +363,7 @@ func (c Candidate) check() error {
if err := m.check(); err != nil {
return fmt.Errorf("delivery: the candidate's module %q: %w", module, err)
}
if before, ok := seen[module]; ok && before != m {
if before, ok := seen[module]; ok && !reflect.DeepEqual(before, m) {
return fmt.Errorf("delivery: two pull requests of the candidate give the module %q different fingerprints", module)
}
seen[module] = m
@@ -370,12 +377,13 @@ func (c Candidate) check() error {
// Module is the value of a modules key: the module's source fingerprint, or none and why (a build that resolves
// packages when built gets none, said here), and where it was computed: Repository and Commit are the trunk merge,
// Src the source revision then (0 before the gate writes source.main). Exactly one of FP and None is set.
// Src the source revision then (0 before the gate writes source.main). At most one of FP and None is set.
//
// From R4c the indexer writes the new form beside it (novox/hq ADR 0453 clause 6): FP2, the fingerprint of the new
// form (`src2:` and 64 hex digits), or None2, why it has none; one of them whenever either is, never both; and
// Manifest, the digest of the module's manifest, the key of the manifests bucket. A value written before has none of
// the three, and a reader of today's shape ignores them.
// the three, and a reader of today's shape ignores them. From R4e a value may carry the new form alone (ADR 0453 clause
// 6; issue 1851): one of FP, None, FP2 and None2 is set, and the old form stays readable.
type Module struct {
V int `json:"v"`
FP string `json:"fp,omitempty"`
@@ -398,6 +406,11 @@ type Module struct {
// builds the fingerprint only with these. Empty when FP2 is empty or the module has no toolchain; a value written
// before has none (issue 1615).
Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"`
// ArtifactPaths are, of each artifact that names the paths it is built from, those paths as its manifest names
// them, by artifact (novox/hq ADR 0499 clause 1): the indexer fills them from each catalogue.Artifact.Paths it reads,
// so the checker tells whether a change touches an artifact's build source without reading a manifest (the lead's
// decision L8 of R4e). Absent for an artifact that names none, and in a value written before.
ArtifactPaths map[string][]string `json:"paths,omitempty"`
}
// RootPath is Module.Path of a module whose manifest is its repository's root.
@@ -411,6 +424,39 @@ type KeptInputs2 struct {
Builder string `json:"builder,omitempty"`
Contexts map[string]string `json:"contexts,omitempty"`
Locks map[string]string `json:"locks,omitempty"`
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the merge, by
// path relative to the module's directory; Tree is then their NamedTree with the module's manifest digest, so a
// computation from kept inputs gives the merge's value (novox/hq ADR 0499 clause 2). Absent for every other module,
// whose Tree is its directory's tree, and in a value written before.
Paths map[string]string `json:"paths,omitempty"`
}
// checkPathObjects refuses a named path that is not clean and relative to the module's directory, or names no object.
func checkPathObjects(paths map[string]string) error {
for p, object := range paths {
if p == "" || p != path.Clean(p) || strings.HasPrefix(p, "/") || p == ".." || strings.HasPrefix(p, "../") {
return fmt.Errorf("delivery: the named path %q is not a clean path inside the module's directory", p)
}
if object == "" {
return fmt.Errorf("delivery: the named path %q names no git object", p)
}
}
return nil
}
// checkNamed refuses kept named paths that do not compute to the kept tree with the module's manifest digest.
func checkNamed(tree, manifest string, paths map[string]string, of string) error {
if len(paths) == 0 {
return nil
}
named, err := NamedTree(manifest, paths)
if err != nil {
return fmt.Errorf("delivery: %s: %w", of, err)
}
if named != tree {
return fmt.Errorf("delivery: %s keeps the tree %q, and its named paths give the named tree %q", of, tree, named)
}
return nil
}
// With is the full inputs of module m: the kept ones and the given toolchains.
@@ -423,8 +469,8 @@ func (m Module) check() error {
switch {
case m.FP != "" && m.None != "":
return fmt.Errorf("delivery: a module has both a fingerprint and a reason for none")
case m.FP == "" && m.None == "":
return fmt.Errorf("delivery: a module has neither a fingerprint nor a reason for none")
case m.FP == "" && m.None == "" && m.FP2 == "" && m.None2 == "":
return fmt.Errorf("delivery: a module has neither a fingerprint nor a reason for none, of either form")
case m.FP != "" && !strings.HasPrefix(m.FP, SourcePrefix):
return fmt.Errorf("delivery: the module's fingerprint %q does not start with %s", m.FP, SourcePrefix)
case m.FP2 != "" && m.None2 != "":
@@ -438,6 +484,21 @@ func (m Module) check() error {
if m.Inputs2 != nil && m.Manifest == "" {
return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest")
}
for artifact, paths := range m.ArtifactPaths {
if artifact == "" || len(paths) == 0 {
return fmt.Errorf("delivery: the module's named paths of the artifact %q are empty or name no artifact", artifact)
}
for _, p := range paths {
if p == "" || p != path.Clean(p) || strings.HasPrefix(p, "/") || p == ".." || strings.HasPrefix(p, "../") {
return fmt.Errorf("delivery: the artifact %q names the path %q, not a clean path inside the module's directory", artifact, p)
}
}
}
if m.Inputs2 != nil {
if err := checkNamed(m.Inputs2.Tree, m.Manifest, m.Inputs2.Paths, "the module's kept inputs"); err != nil {
return err
}
}
return checkNewForm(m.FP2, m.Manifest)
}
@@ -465,9 +526,13 @@ type Forge struct {
// relative to it (novox/hq ADR 0465 clause 1). Older writers send neither.
Manifests map[string]string `json:"manifests,omitempty"`
Locks map[string]map[string]string `json:"locks,omitempty"`
Act string `json:"act,omitempty"`
Words string `json:"words,omitempty"`
Open []OpenPull `json:"open,omitempty"`
// Paths are, of each module directory whose manifest names paths on every artifact, each named path's git object
// id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The
// indexer names the paths; a message without them gives such a module none. Older writers send none.
Paths map[string]map[string]string `json:"paths,omitempty"`
Act string `json:"act,omitempty"`
Words string `json:"words,omitempty"`
Open []OpenPull `json:"open,omitempty"`
}
func (f Forge) check() error {
@@ -537,8 +602,8 @@ func (f Forge) check() error {
if len(f.Dirs) > 0 || f.DirsKnown {
return fmt.Errorf("delivery: a refused message carries no module directories")
}
if len(f.Manifests) > 0 || len(f.Locks) > 0 {
return fmt.Errorf("delivery: a refused message carries no manifests or lock hashes")
if len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 {
return fmt.Errorf("delivery: a refused message carries no manifests, lock hashes or named paths")
}
if f.Act == "" {
return fmt.Errorf("delivery: a refused message needs the act it answers")
@@ -571,6 +636,16 @@ func (f Forge) checkInputs(known func(dir string) bool, of string) error {
}
}
}
for dir, paths := range f.Paths {
if !known(dir) {
return fmt.Errorf("delivery: the named paths of the directory %q are not of %s", dir, of)
}
for path, object := range paths {
if path == "" || object == "" {
return fmt.Errorf("delivery: the named path %q of the directory %q has an empty path or object", path, dir)
}
}
}
return nil
}
@@ -578,7 +653,7 @@ func (f Forge) checkInputs(known func(dir string) bool, of string) error {
// an open pull request that is not `<owner>/<name>` and a number.
func (f Forge) checkOpen() error {
if f.Repository != "" || f.Branch != "" || f.Number != 0 || f.Head != "" || f.Action != "" || len(f.Dirs) > 0 || f.DirsKnown ||
f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || f.Root != "" || f.Act != "" || f.Words != "" {
f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 || f.Root != "" || f.Act != "" || f.Words != "" {
return fmt.Errorf("delivery: an open message carries only the open pull requests it names")
}
for _, p := range f.Open {
+80
View File
@@ -0,0 +1,80 @@
package stdio
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// What this bundle said it keeps (novox/hq ADR 0495 clause 2; issue 1790).
var (
keepsMu sync.Mutex
kept map[string]bool // nil until Keeps: no state is refused then
announced []byte // the announcement, published once the bundle serves
)
// Keeps says on the bus, as this bundle's module, every state it keeps and which of them others may read (novox/hq
// ADR 0495 clause 2): what a reader is granted is what the owner announced (clause 3). Call it before Serve; the
// announcement is published through the runtime once the bundle serves, or at once when it already does. From then
// on a state of the module's own that it did not announce is refused at the call, naming it, so code and announcement
// cannot drift (clause 5). It refuses a state no bucket could be named by and a state given twice.
func Keeps(states ...announce.State) error {
module := os.Getenv("MESH_MODULE")
if module == "" {
module = os.Getenv(ServedAs)
}
data, err := announce.Encode(announce.States{Module: module, Node: os.Getenv("MESH_NODE"), States: states})
if err != nil {
return err
}
names := map[string]bool{}
for _, s := range states {
names[s.Name] = true
}
keepsMu.Lock()
kept, announced = names, data
keepsMu.Unlock()
currentMu.Lock()
serving := current != nil
currentMu.Unlock()
if serving {
return announce1()
}
return nil
}
// announce1 publishes the announcement Keeps made, if any.
func announce1() error {
keepsMu.Lock()
data := announced
keepsMu.Unlock()
if data == nil {
return nil
}
return Emit(announce.Event, json.RawMessage(data))
}
// refusedUnannounced is the error of a call on a state of the module's own that Keeps did not name, or nil. A read of
// another module's state (`<module>.<name>`) is the grant's business.
func refusedUnannounced(name string) error {
if strings.Contains(name, ".") {
return nil
}
keepsMu.Lock()
defer keepsMu.Unlock()
if kept != nil && !kept[name] {
return fmt.Errorf("the state %q is not among the states this module announced it keeps (stdio.Keeps)", name)
}
return nil
}
// forgetKeeps is for a test: nothing announced.
func forgetKeeps() {
keepsMu.Lock()
kept, announced = nil, nil
keepsMu.Unlock()
}
+63
View File
@@ -0,0 +1,63 @@
package stdio
import (
"bufio"
"encoding/json"
"io"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// A Go bundle says what it keeps from its code (novox/hq ADR 0495 clauses 1 and 2): Keeps, called before Serve,
// announces through the runtime once the bundle serves, as the module the runtime names; and a state of its own that it
// did not announce is refused at the call, naming it, so code and announcement cannot drift.
func TestABundleAnnouncesWhatItKeepsAndBindsNothingElse(t *testing.T) {
t.Setenv(ServedAs, "chime")
t.Setenv("MESH_MODULE", "chime")
t.Setenv("MESH_NODE", "anchor")
defer forgetKeeps()
// Not yet serving: an earlier test's channel is gone with its test.
currentMu.Lock()
current = nil
currentMu.Unlock()
if err := Keeps(announce.State{Name: "rings", Readable: true}, announce.State{Name: "own"}); err != nil {
t.Fatal(err)
}
if _, err := State("other").Get("k"); err == nil || !strings.Contains(err.Error(), `"other"`) {
t.Fatalf("a state not announced was bound: %v", err)
}
inR, inW := io.Pipe()
outR, outW := io.Pipe()
go func() { _ = ServeOn("", nil, inR, outW) }()
defer inW.Close()
lines := bufio.NewScanner(outR)
got := make(chan map[string]any, 1)
go func() {
if lines.Scan() {
var m map[string]any
_ = json.Unmarshal(lines.Bytes(), &m)
got <- m
}
}()
var asked map[string]any
select {
case asked = <-got:
case <-time.After(3 * time.Second):
t.Fatal("the bundle announced nothing")
}
p, _ := asked["params"].(map[string]any)
if asked["method"] != "mesh/publish" || p["key"] != announce.Event {
t.Fatalf("the announcement was not published through the runtime: %v", asked)
}
raw, _ := json.Marshal(p["body"])
s, err := announce.Decode(raw)
if err != nil || s.Module != "chime" || s.Node != "anchor" || !s.Readable("rings") || !s.Keeps("own") || s.Readable("own") {
t.Fatalf("announced %s (%v)", raw, err)
}
if err := Keeps(announce.State{Name: "bad_name"}); err == nil {
t.Fatal("an announcement no bucket could be named by was accepted")
}
}
+165
View File
@@ -0,0 +1,165 @@
// Package membership is what the mesh issues an assignment to serve and to reach (novox/hq ADR 0160), as a
// process reads it from the bus: the Membership shape, the one subject it is published on, the stream that
// holds it, and the bus address helpers a reader needs. The controller composes it and the route proxy reads
// it; both use this one definition, so there is no second copy to drift (novox/hq issue 1633).
package membership
import (
"encoding/json"
"strings"
)
// AssignmentsStream holds every assignment's membership, the newest per subject.
const AssignmentsStream = "ASSIGNMENTS"
// Subject is the one address a runtime derives for itself: where its own membership is published, from
// the two names its credential carries. Everything else is in the membership.
func Subject(node, module string) string {
return "mesh.assignment." + node + "." + module
}
// DirectGetSubject is the subject-addressed direct get of a membership from its stream: the one request an
// account may make of the stream.
func DirectGetSubject(node, module string) string {
return "$JS.API.DIRECT.GET." + AssignmentsStream + "." + Subject(node, module)
}
// Membership is one assignment's subjects: what this instance of a module on this machine serves,
// and what it may reach.
type Membership struct {
Node string `json:"node"`
Module string `json:"module"`
// Serves is every address a tool of this instance answers on. `{tool}` stands for the tool's
// own name. An address with a queue is shared with the module's other instances.
Serves []Served `json:"serves"`
// Seats is every verb of a seat this instance holds, at the subject the seat's callers use.
Seats []SeatServed `json:"seats,omitempty"`
// Emits is where an event of this module lands; `{event}` stands for the event's name.
Emits string `json:"emits"`
// Reaches is each tool this module may call, `<module>.<tool>`, to the subjects that reach it.
Reaches map[string][]string `json:"reaches,omitempty"`
// Tools is where this instance answers what it serves.
Tools string `json:"tools"`
// Receives is what this assignment is given for each requirement it receives, by requirement (novox/hq
// ADR 0167): kept as JSON because the catalogue owns the shape of a contribution.
Receives map[string]json.RawMessage `json:"receives,omitempty"`
// Mesh is every machine's address on the private network (novox/hq ADR 0167).
Mesh []string `json:"mesh,omitempty"`
// State is every bucket this module's code may reach, and whether it may write it (novox/hq ADR 0201).
State []StateIssued `json:"state,omitempty"`
// Logs is every log this module's code may reach (novox/hq ADR 0297).
Logs []LogIssued `json:"logs,omitempty"`
// SeatTraffic is what this module's code may submit, say, hear, take, ask, answer and read on seats
// that name their caller or their kind (novox/hq ADR 0259 §3).
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
// Streams are this module's own streams, Hears the module streams it hears (novox/hq ADR 0363).
Streams []StreamIssued `json:"streams,omitempty"`
Hears []HeardIssued `json:"hears,omitempty"`
// Waits are the event families a session may wait on through the mesh MCP server (novox/hq ADR 0367 §2).
Waits []WaitIssued `json:"waits,omitempty"`
}
// Served is one address a tool is answered on.
type Served struct {
Subject string `json:"subject"`
Queue string `json:"queue,omitempty"`
}
// SeatServed is one verb of a held seat, where its callers ask.
type SeatServed struct {
Seat string `json:"seat"`
Verb string `json:"verb"`
Subject string `json:"subject"`
}
// StateIssued is one bucket an assignment may reach, by the name its module uses for it (novox/hq ADR 0201).
// Key, when set, is the one key it may reach (novox/hq ADR 0260): this machine's.
type StateIssued struct {
Name string `json:"name"`
Bucket string `json:"bucket"`
Writes bool `json:"writes,omitempty"`
Key string `json:"key,omitempty"`
}
// LogIssued is one log an assignment may reach, by the name its module uses for it (novox/hq ADR 0297).
type LogIssued struct {
Name string `json:"name"`
Stream string `json:"stream"`
Subject string `json:"subject"`
Writes bool `json:"writes"`
}
// Worker is one durable consumer a holder pulls a seat's work from.
type Worker struct {
Stream string
Consumer string
Filter string
}
// SeatTraffic is one module's seat traffic beyond tools. Publish and Subscribe are subject patterns, in the
// server's wildcards.
type SeatTraffic struct {
Publish []string `json:"publish,omitempty"`
Subscribe []string `json:"subscribe,omitempty"`
Answers []string `json:"answers,omitempty"`
Workers []Worker `json:"workers,omitempty"`
Records []string `json:"records,omitempty"`
Kinds []KindHeld `json:"kinds,omitempty"`
}
// KindHeld is one kind of a kinded bench and who holds it.
type KindHeld struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Module string `json:"module"`
Node string `json:"node"`
Capabilities []string `json:"capabilities,omitempty"`
}
// StreamIssued is one of a module's own streams as its membership names it (ADR 0363 §2).
type StreamIssued struct {
Stream string `json:"stream"`
Events []string `json:"events"`
Subject string `json:"subject"`
}
// HeardIssued is one module stream an assignment hears (ADR 0363 §8).
type HeardIssued struct {
Stream string `json:"stream"`
Consumer string `json:"consumer"`
}
// WaitIssued is one family as a membership issues it: the name a session asks for, and its subject.
type WaitIssued struct {
Event string `json:"event"`
Subject string `json:"subject"`
}
// CredentialIn reads the credential out of a bus address, `[scheme://]user:password@host:port`, splitting on
// the last at-sign so a password containing one stays whole, and returns the address without it.
func CredentialIn(address string) (user, password, bare string) {
at := strings.LastIndex(address, "@")
if at < 0 {
return "", "", address
}
scheme := ""
rest := address[:at]
if i := strings.Index(rest, "://"); i >= 0 {
scheme, rest = rest[:i+3], rest[i+3:]
}
user, password, _ = strings.Cut(rest, ":")
return user, password, scheme + address[at+1:]
}
// BareAddress is a bus address with any credential stripped, for something that only needs to know
// whether a server is answering there.
func BareAddress(address string) string {
_, _, bare := CredentialIn(address)
if bare == "" {
return address
}
if strings.Contains(bare, "://") {
return bare
}
return "nats://" + bare
}
+76
View File
@@ -0,0 +1,76 @@
package membership
import (
"encoding/json"
"reflect"
"testing"
)
func TestACredentialIsReadOutOfABusAddress(t *testing.T) {
for _, c := range []struct{ in, user, password, bare string }{
{"nats://controller:secret@127.0.0.1:4222", "controller", "secret", "nats://127.0.0.1:4222"},
{"controller:secret@127.0.0.1:4222", "controller", "secret", "127.0.0.1:4222"},
{"nats://127.0.0.1:4222", "", "", "nats://127.0.0.1:4222"},
{"127.0.0.1:4222", "", "", "127.0.0.1:4222"},
// A password containing an at-sign: split on the last one.
{"nats://controller:a@b@127.0.0.1:4222", "controller", "a@b", "nats://127.0.0.1:4222"},
} {
user, password, bare := CredentialIn(c.in)
if user != c.user || password != c.password || bare != c.bare {
t.Errorf("%q read as %q/%q at %q; wanted %q/%q at %q", c.in, user, password, bare, c.user, c.password, c.bare)
}
}
}
func TestABareAddressCarriesNoCredentialAndAScheme(t *testing.T) {
for in, want := range map[string]string{
"nats://controller:secret@10.0.0.1:4222": "nats://10.0.0.1:4222",
"controller:secret@10.0.0.1:4222": "nats://10.0.0.1:4222",
"nats://10.0.0.1:4222": "nats://10.0.0.1:4222",
"10.0.0.1:4222": "nats://10.0.0.1:4222",
} {
if got := BareAddress(in); got != want {
t.Errorf("BareAddress(%q) = %q, want %q", in, got, want)
}
}
}
func TestAMembershipIsPublishedAndReadAtTheSubjectDerivedFromTheTwoNames(t *testing.T) {
if got := Subject("ace", "route-proxy"); got != "mesh.assignment.ace.route-proxy" {
t.Errorf("subject: %q", got)
}
if got := DirectGetSubject("ace", "route-proxy"); got != "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.ace.route-proxy" {
t.Errorf("direct get: %q", got)
}
if AssignmentsStream != "ASSIGNMENTS" {
t.Errorf("stream: %q", AssignmentsStream)
}
}
// The shape on the wire is the controller's: a membership as issued reads back whole, with the routes kept raw.
func TestAMembershipRoundTripsAsIssued(t *testing.T) {
in := Membership{Node: "ace", Module: "route-proxy", Emits: "e", Tools: "t",
Serves: []Served{{Subject: "s", Queue: "q"}},
Receives: map[string]json.RawMessage{"route": json.RawMessage(`[{"name":"a"}]`)},
Mesh: []string{"10.10.0.7"},
State: []StateIssued{{Name: "n", Bucket: "b", Writes: true, Key: "ace"}},
SeatTraffic: &SeatTraffic{Publish: []string{"p"}, Kinds: []KindHeld{{Seat: "channel", Kind: "tg"}}}}
raw, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
var out Membership
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(in, out) {
t.Fatalf("round trip changed it:\n%+v\n%+v", in, out)
}
var wire map[string]json.RawMessage
_ = json.Unmarshal(raw, &wire)
for _, k := range []string{"receives", "mesh", "seat-traffic"} {
if _, ok := wire[k]; !ok {
t.Errorf("field %q is not on the wire under that name", k)
}
}
}
+7 -2
View File
@@ -10,13 +10,17 @@
# 1. the unit level: `go test ./...` untagged, no bus or other process, timed and held to a bound
# (unit_bound below); over it the check fails and names the slowest tests (issue 895);
# 2. the integration level: the whole suite with `-tags integration`, each test against a bus of its
# own (internal/testbus, which issue 1171 brings with the first test that needs a bus) so nothing is
# own (testbus, which issue 1171 brings with the first test that needs a bus) so nothing is
# serialised. With no tagged test yet, the level runs the unit tests again and says their count.
#
# **A test that starts a bus or another process carries `//go:build integration`** as the first line of
# its file; a file that held both kinds is two files (`<name>_integration_test.go` beside `<name>_test.go`).
# Each level says its test count and its time.
#
# **A test that needs bubblewrap carries `//go:build integration && isolation`** (issue 1309's proof that a run step
# is shut off): this script vets it but does not run it, since the toolchain it runs in has no bubblewrap; the checker
# runs the `isolation` tag on the build nodes, which have it (R4e).
#
# No dependency is committed (no vendor directory: .gitignore; the operator, 2026-10-19; issue 1304). Every dependency
# is resolved by the version and checksum go.sum names (novox/hq ADR 0405 §5), from its public source: this binding
# requires nothing of the mesh's own forge. `go mod verify` fails by module name when a downloaded dependency does not
@@ -98,6 +102,7 @@ fi
go vet ./...
# The files tagged `integration` are vetted too, or nothing would read them.
go vet -tags integration ./...
go vet -tags integration,isolation ./...
# NOT RACE-CHECKED here: the SDK's suite is small and carries no store or other shared state between
# packages, so the race detector is not required of this check (novox/hq issue 1175); a module that needs
@@ -117,7 +122,7 @@ if [ "$status" -ne 0 ]; then
fi
judge_unit_run "$work/unit.out" "$unit_bound"
# The integration level: the whole suite with the tag, each test against its own bus (internal/testbus).
# The integration level: the whole suite with the tag, each test against its own bus (testbus).
integration_start=$(date +%s)
status=0
go test -tags integration -count=1 -v ./... >"$work/integration.out" 2>&1 || status=$?
+12 -4
View File
@@ -24,6 +24,12 @@ type Conn struct {
mu sync.Mutex
next uint64
pending map[string]map[uint64]context.CancelCauseFunc
// What Keeps announced (keeps.go): the states' names (nil before Keeps), the announcement, and whether a
// reconnect announces it again.
kept map[string]bool
announced []byte
reannounces bool
}
// errPermissionRefused marks a context cancelled because the bus refused this account's publish of the
@@ -40,7 +46,7 @@ var refusedPublish = regexp.MustCompile(`(?i)Publish to "(\S+)"`)
// other certificate), authenticated as its user and password, on the inbox prefix _INBOX.<user> and no
// other, reconnecting without limit as the tool runner does (novox/hq issue 1192: a process outlives a
// bus restart, never giving up the way the client library's own default of 60 attempts would). Ported
// from mesh-controller internal/broker/jetstream.go:106-132 (DialPinned, PinnedToFingerprint) since this
// from mesh-controller internal/broker/jetstream.go (DialPinned, PinnedToFingerprint), which now uses this one since this
// package may not import the controller.
//
// An ErrorHandler among opts is chained behind this package's own, which watches every async error for a
@@ -78,7 +84,7 @@ func (c *Conn) options(opts []nats.Option) []nats.Option {
nats.MaxReconnects(-1),
}
if c.account.Fingerprint != "" {
all = append(all, nats.Secure(pinnedToFingerprint(c.account.Fingerprint)))
all = append(all, nats.Secure(PinnedToFingerprint(c.account.Fingerprint)))
}
all = append(all, opts...)
return append(all, nats.ErrorHandler(func(nc *nats.Conn, sub *nats.Subscription, err error) {
@@ -140,9 +146,11 @@ func (c *Conn) publishRefused(err error) bool {
return len(cancels) > 0
}
// pinnedToFingerprint accepts exactly the certificate with this SHA-256 and no other — the leaf alone, its
// PinnedToFingerprint accepts exactly the certificate with this SHA-256 and no other — the leaf alone, its
// name and chain deliberately not consulted, as mesh-controller's own pin does (jetstream.go:116-132).
func pinnedToFingerprint(want string) *tls.Config {
// PinnedToFingerprint is public so that a process dialling the bus on its own terms, and the controller,
// share this one pin (novox/hq issue 1633).
func PinnedToFingerprint(want string) *tls.Config {
return &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // replaced by the pin below, which is stricter
MinVersion: tls.VersionTLS12,
+1 -1
View File
@@ -22,7 +22,7 @@ import (
)
// selfSignedServer is a nats-server with JetStream and TLS, self-signed, for this test alone (mirroring
// go/internal/testbus.Start, with a certificate added since testbus has none): a mesh's bus presents its own
// go/testbus.Start, with a certificate added since testbus has none): a mesh's bus presents its own
// certificate in no public trust store, which is exactly what a process pins to (ADR 0436 clause 4).
func selfSignedServer(t *testing.T) (url, fingerprint string) {
t.Helper()
+1
View File
@@ -38,6 +38,7 @@ var sendGrants = []string{
"$KV.mesh-mail_sends.>",
"mesh.mod.mesh-mail.event.mail.send.failed",
"mesh.mod.mesh-mail.event.mail.send.sent",
"mesh.mod.mesh-mail.event.states",
"mesh.seat.operator-channel.call.tell.*.novox~mesh-mail~send",
"mesh.seat.operator-channel.call.tell.novox~mesh-mail~send",
"mesh.seat.operator-channel.tool.tell",
+53
View File
@@ -0,0 +1,53 @@
package process
import (
"encoding/json"
"fmt"
"time"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// FollowMembership reads this account's own membership and follows it (novox/hq ADR 0160): apply is called
// with every membership the mesh issues this account's node and module, the one already issued first and
// each later one as it is published, so a change reaches a running process without a restart. A body that is
// not a membership is handed to onBad (nil to ignore it) and skipped.
//
// The subject is followed before it is read, so an issue landing between the two is applied, not missed. The
// read is the subject-addressed direct get, the one request this account may make of the stream; no
// membership issued yet, or the read failing, is not an error: the process follows the subject and is told
// when one arrives. The returned error is a refusal to follow at all.
func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(error)) error {
if c.account.Node == "" || c.account.Module == "" {
return fmt.Errorf("the account names no node or module, so it has no membership")
}
bad := func(err error) {
if onBad != nil {
onBad(err)
}
}
take := func(body []byte) {
var issued membership.Membership
if err := json.Unmarshal(body, &issued); err != nil {
bad(fmt.Errorf("a membership arrived that is not one: %w", err))
return
}
apply(issued)
}
subject := membership.Subject(c.account.Node, c.account.Module)
if _, err := c.nc.Subscribe(subject, func(m *nats.Msg) { take(m.Data) }); err != nil {
return fmt.Errorf("cannot follow %s: %w", subject, err)
}
got, err := c.nc.Request(membership.DirectGetSubject(c.account.Node, c.account.Module), nil, 5*time.Second)
switch {
case err != nil:
bad(fmt.Errorf("cannot read the membership issued on %s yet (%v); following it", subject, err))
case got.Header.Get("Status") != "" || len(got.Data) == 0:
// None issued yet: the follow above delivers it.
default:
take(got.Data)
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
//go:build integration
package process
import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// A process connected pinned to a test bus hears the membership published for its node and module, and
// ignores what is not one (novox/hq issue 1633, AC2).
func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) {
url, fingerprint := selfSignedServer(t)
account := mailSenderAccount(t, url, fingerprint)
conn, err := Connect(account)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
got := make(chan membership.Membership, 1)
bad := make(chan error, 4)
if err := conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e }); err != nil {
t.Fatal(err)
}
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte("not json")); err != nil {
t.Fatal(err)
}
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.1"]}`)); err != nil {
t.Fatal(err)
}
select {
case m := <-got:
if m.Module != "mesh-mail" || len(m.Mesh) != 1 {
t.Fatalf("membership: %+v", m)
}
case <-time.After(5 * time.Second):
t.Fatal("no membership heard")
}
if len(bad) == 0 {
t.Error("the body that is not a membership was not reported")
}
}
+71
View File
@@ -0,0 +1,71 @@
package process
import (
"encoding/json"
"fmt"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// Keeps says on the bus, as this process's module, every state it keeps and which of them others may read (novox/hq
// ADR 0495 clause 2; issue 1790), on announce.Subject; what a reader is granted is what the owner announced (clause 3).
// It publishes at once and again after every reconnect, so the controller hears it whenever it missed it. From then on
// a state of the module that it did not announce is refused at the call, naming it, so code and announcement cannot
// drift (clause 5). It refuses a state no bucket could be named by and a state given twice.
func (c *Conn) Keeps(states ...announce.State) error {
if err := c.remember(states); err != nil {
return err
}
c.mu.Lock()
first := !c.reannounces
c.reannounces = true
c.mu.Unlock()
if first {
before := c.nc.Opts.ReconnectedCB
c.nc.SetReconnectHandler(func(nc *nats.Conn) {
if before != nil {
before(nc)
}
_ = c.publishKeeps()
})
}
return c.publishKeeps()
}
// remember makes the announcement of states and binds only them from now on.
func (c *Conn) remember(states []announce.State) error {
data, err := announce.Encode(announce.States{Module: c.account.Module, Node: c.account.Node, States: states})
if err != nil {
return err
}
names := map[string]bool{}
for _, s := range states {
names[s.Name] = true
}
c.mu.Lock()
c.kept, c.announced = names, data
c.mu.Unlock()
return nil
}
func (c *Conn) publishKeeps() error {
c.mu.Lock()
data := c.announced
c.mu.Unlock()
if err := c.Emit(announce.Event, json.RawMessage(data)); err != nil {
return fmt.Errorf("announcing what %s keeps: %w", c.account.Module, err)
}
return nil
}
// refusedUnannounced is the error of a bind of a state Keeps did not name, or nil (also before Keeps).
func (c *Conn) refusedUnannounced(name string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.kept != nil && !c.kept[name] {
return fmt.Errorf("the state %q is not among the states %s announced it keeps (Conn.Keeps)", name, c.account.Module)
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
//go:build integration
package process
import (
"strings"
"testing"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// 1790/S10: on a real bus, a process's Keeps lands on its module's own announcement subject, in the events stream, as
// the module's announcement; a state it did not announce is then refused at the call.
func TestAProcessAnnouncesWhatItKeepsOnTheBus(t *testing.T) {
url, account := grantedSendProcess(t)
_, admin := adminJS(t, url)
stream, err := admin.CreateStream(ctxFor(t), jetstream.StreamConfig{Name: "MAIL_STATES", Subjects: []string{announce.Subject("mesh-mail")}})
if err != nil {
t.Fatal(err)
}
conn, err := Connect(account)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := conn.Keeps(announce.State{Name: "accounts", Readable: true}, announce.State{Name: "drafts"}); err != nil {
t.Fatal(err)
}
msg, err := stream.GetMsg(ctxFor(t), 1)
if err != nil {
t.Fatalf("no announcement in the stream: %v", err)
}
if msg.Subject != "mesh.mod.mesh-mail.event.states" || msg.Header.Get("x-source") != "mesh-mail" {
t.Fatalf("announced on %s with %v", msg.Subject, msg.Header)
}
got, err := announce.Decode(msg.Data)
if err != nil || !got.Readable("accounts") || !got.Keeps("drafts") || got.Readable("drafts") || got.Node != "novox" {
t.Fatalf("announced %s (%v)", msg.Data, err)
}
if _, err := conn.State("outbox").Get("k"); err == nil || !strings.Contains(err.Error(), `"outbox"`) {
t.Fatalf("an unannounced state was bound: %v", err)
}
}
+28
View File
@@ -0,0 +1,28 @@
package process
import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// After Keeps a process binds only the states it announced (novox/hq ADR 0495 clause 5): any other of its module's
// states is refused at the call, naming it, before the bus is asked; before Keeps nothing is refused.
func TestAProcessBindsOnlyTheStatesItAnnounced(t *testing.T) {
c := &Conn{account: Account{Module: "mesh-mail", Node: "novox"}}
if err := c.remember([]announce.State{{Name: "accounts", Readable: true}}); err != nil {
t.Fatal(err)
}
_, err := c.State("drafts").Get("k")
if err == nil || !strings.Contains(err.Error(), `"drafts"`) || !strings.Contains(err.Error(), "Keeps") {
t.Fatalf("an unannounced state was bound: %v", err)
}
if err := c.remember([]announce.State{{Name: "a_b"}}); err == nil {
t.Fatal("an announcement no bucket could be named by was accepted")
}
got, err := announce.Decode(c.announced)
if err != nil || got.Module != "mesh-mail" || got.Node != "novox" || !got.Readable("accounts") {
t.Fatalf("the announcement is %s (%v)", c.announced, err)
}
}
+23
View File
@@ -0,0 +1,23 @@
package process
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
// The pin accepts exactly the leaf certificate with the named SHA-256, and no other (novox/hq issue 1633).
func TestThePinAcceptsOnlyTheNamedCertificate(t *testing.T) {
leaf := []byte("a certificate")
sum := sha256.Sum256(leaf)
cfg := PinnedToFingerprint("sha256:" + hex.EncodeToString(sum[:]))
if err := cfg.VerifyPeerCertificate([][]byte{leaf}, nil); err != nil {
t.Errorf("the pinned certificate was refused: %v", err)
}
if err := cfg.VerifyPeerCertificate([][]byte{[]byte("another")}, nil); err == nil {
t.Error("another certificate was accepted")
}
if err := cfg.VerifyPeerCertificate(nil, nil); err == nil {
t.Error("no certificate was accepted")
}
}
+153 -1
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/nats-io/nats.go"
@@ -27,6 +29,8 @@ type State struct {
// kv binds this state's underlying key-value store: a live connection's own (Conn.State), or, in a
// unit test, a fake that never reaches a bus.
kv func(ctx context.Context) (jetstream.KeyValue, error)
// closed says the connection is gone, so a watch ends; nil in a unit test.
closed func() bool
}
// bucket is this state's name on the bus.
@@ -38,7 +42,10 @@ func (s State) bucket() string { return s.Module + "_" + s.Name }
// not granted is refused there, promptly, as nats.ErrPermissionViolation.
func (c *Conn) State(name string) State {
bucket := c.account.Module + "_" + name
return State{Module: c.account.Module, Name: name, kv: func(ctx context.Context) (jetstream.KeyValue, error) {
return State{Module: c.account.Module, Name: name, closed: func() bool { return c.nc.IsClosed() }, kv: func(ctx context.Context) (jetstream.KeyValue, error) {
if err := c.refusedUnannounced(name); err != nil {
return nil, err
}
js, err := jetstream.New(c.nc)
if err != nil {
return nil, err
@@ -128,3 +135,148 @@ func (s State) Update(key string, value any, revision uint64) (uint64, error) {
}
return rev, nil
}
// Put writes a key whole, whatever it held, and returns the revision written.
func (s State) Put(key string, value any) (uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), stateTimeout)
defer cancel()
kv, err := s.store(ctx)
if err != nil {
return 0, err
}
data, err := json.Marshal(value)
if err != nil {
return 0, err
}
rev, err := kv.Put(ctx, key, data)
if err != nil {
return 0, fmt.Errorf("%s's state %s, key %s: %w", s.Module, s.Name, key, err)
}
return rev, nil
}
// Keys is every key with a current value, sorted; none is an empty list.
func (s State) Keys() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), stateTimeout)
defer cancel()
kv, err := s.store(ctx)
if err != nil {
return nil, err
}
keys, err := kv.Keys(ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return []string{}, nil
}
if err != nil {
return nil, fmt.Errorf("%s's state %s, keys: %w", s.Module, s.Name, err)
}
sort.Strings(keys)
return keys, nil
}
// watchPattern is a key narrowing as the bus names it: `*` stays one name, `**` is the rest, empty is every key.
func watchPattern(key string) string {
if key == "" {
return jetstream.AllKeys
}
tokens := strings.Split(key, ".")
for i, t := range tokens {
if t == "**" {
tokens[i] = ">"
}
}
return strings.Join(tokens, ".")
}
// Watch hands handle every current value — none that is deleted, each with Current set — and then every
// change in order, and returns once the current values have all been handled. key narrows it: a key, with
// `*` for one name and `**` for the rest; empty is every key. The changes go on for the life of the
// connection; an error from handle, or the connection closing, ends them. Before the current values are
// handled, either is returned as the error.
func (s State) Watch(key string, handle func(stdio.StateChange) error) error {
// the watch outlives this call, so its context is not the call's timeout: the connection ends it.
ctx, cancel := context.WithCancel(context.Background())
bind, stopBind := context.WithTimeout(ctx, stateTimeout)
kv, err := s.store(bind)
stopBind()
if err != nil {
cancel()
return err
}
w, err := kv.Watch(ctx, watchPattern(key))
if err != nil {
cancel()
return fmt.Errorf("%s's state %s, watching %q: %w", s.Module, s.Name, key, err)
}
end := func() { w.Stop(); cancel() }
next := func() (jetstream.KeyValueEntry, bool, error) {
tick := time.NewTicker(250 * time.Millisecond)
defer tick.Stop()
for {
select {
case e, open := <-w.Updates():
if !open {
return nil, false, fmt.Errorf("%s's state %s: the watch ended", s.Module, s.Name)
}
return e, true, nil
case <-tick.C:
if s.closed != nil && s.closed() {
return nil, false, fmt.Errorf("%s's state %s: the connection closed", s.Module, s.Name)
}
}
}
}
deliver := func(e jetstream.KeyValueEntry, current bool) error {
c := stdio.StateChange{State: s.Name, Key: e.Key(), Op: "put", Revision: e.Revision(), Current: current}
if e.Operation() == jetstream.KeyValuePut {
c.Value = json.RawMessage(e.Value())
} else {
c.Op = "delete"
}
return handle(c)
}
for {
e, _, err := next()
if err != nil {
end()
return err
}
if e == nil {
break
}
if e.Operation() != jetstream.KeyValuePut {
continue
}
if err := deliver(e, true); err != nil {
end()
return err
}
}
go func() {
defer end()
for {
e, _, err := next()
if err != nil || e == nil {
return
}
if deliver(e, false) != nil {
return
}
}
}()
return nil
}
// Delete removes a key. A key that was not there is not an error.
func (s State) Delete(key string) error {
ctx, cancel := context.WithTimeout(context.Background(), stateTimeout)
defer cancel()
kv, err := s.store(ctx)
if err != nil {
return err
}
if err := kv.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return fmt.Errorf("%s's state %s, key %s: %w", s.Module, s.Name, key, err)
}
return nil
}
+180
View File
@@ -6,6 +6,7 @@ import (
"errors"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -94,3 +95,182 @@ func TestStateRefusesAnUngrantedBucketNamingIt(t *testing.T) {
t.Errorf("the refusal %q does not name the bucket mesh-mail_unrelated", err.Error())
}
}
// 1840/S1: given a process connection granted its bucket, when it puts keys whole and lists them, then Put
// returns a new revision each time and Keys lists them sorted (none for a bucket with none).
func TestStatePutAndKeysOnAGrantedBucket(t *testing.T) {
url, account := grantedSendProcess(t)
_, adminJetStream := adminJS(t, url)
if _, err := adminJetStream.CreateKeyValue(ctxFor(t), jetstream.KeyValueConfig{Bucket: "mesh-mail_sends"}); err != nil {
t.Fatalf("the admin could not make the bucket: %v", err)
}
conn, err := Connect(account)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
s := conn.State("sends")
if keys, err := s.Keys(); err != nil || len(keys) != 0 {
t.Fatalf("Keys of an empty bucket: %v, %v", keys, err)
}
rev, err := s.Put("b", map[string]any{"v": 1})
if err != nil || rev == 0 {
t.Fatalf("Put on a granted bucket: %d, %v", rev, err)
}
rev2, err := s.Put("b", map[string]any{"v": 2})
if err != nil || rev2 <= rev {
t.Fatalf("Put over a key: %d, %v", rev2, err)
}
if _, err := s.Put("a", 1); err != nil {
t.Fatal(err)
}
keys, err := s.Keys()
if err != nil || strings.Join(keys, ",") != "a,b" {
t.Fatalf("Keys: %v, %v, want a,b", keys, err)
}
}
// 1840/S2: given a process connection granted its bucket, which already holds two keys and a deleted one,
// when it watches, then it gets the two current values marked Current, and then a later put and a delete in
// order; a narrowed watch gets only the keys it names.
func TestStateWatchOnAGrantedBucket(t *testing.T) {
url, account := grantedSendProcess(t)
_, adminJetStream := adminJS(t, url)
kv, err := adminJetStream.CreateKeyValue(ctxFor(t), jetstream.KeyValueConfig{Bucket: "mesh-mail_sends"})
if err != nil {
t.Fatalf("the admin could not make the bucket: %v", err)
}
ctx := ctxFor(t)
for k, v := range map[string]string{"a.x": `1`, "a.y": `2`, "b.z": `3`, "gone": `4`} {
if _, err := kv.Put(ctx, k, []byte(v)); err != nil {
t.Fatal(err)
}
}
if err := kv.Delete(ctx, "gone"); err != nil {
t.Fatal(err)
}
conn, err := Connect(account)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
s := conn.State("sends")
all := make(chan stdio.StateChange, 16)
if err := s.Watch("", func(c stdio.StateChange) error { all <- c; return nil }); err != nil {
t.Fatalf("Watch on a granted bucket: %v", err)
}
current := map[string]bool{}
for len(all) > 0 {
c := <-all
if !c.Current || c.Op != "put" {
t.Fatalf("a current value arrived as %+v", c)
}
current[c.Key] = true
}
if len(current) != 3 || current["gone"] {
t.Fatalf("current values %v, want a.x, a.y, b.z and not gone", current)
}
narrow := make(chan stdio.StateChange, 16)
if err := s.Watch("a.*", func(c stdio.StateChange) error { narrow <- c; return nil }); err != nil {
t.Fatal(err)
}
if len(narrow) != 2 {
t.Fatalf("a narrowed watch handed %d current values, want 2", len(narrow))
}
// the current values come in the order of their last revision, not of their keys: taken as a set.
narrowCurrent := map[string]bool{}
for len(narrow) > 0 {
c := <-narrow
if !c.Current || c.Op != "put" {
t.Fatalf("a narrowed watch's current value arrived as %+v", c)
}
narrowCurrent[c.Key] = true
}
if !narrowCurrent["a.x"] || !narrowCurrent["a.y"] {
t.Fatalf("a narrowed watch's current values %v, want a.x and a.y", narrowCurrent)
}
if _, err := s.Put("a.x", 5); err != nil {
t.Fatal(err)
}
if err := kv.Delete(ctx, "a.y"); err != nil {
t.Fatal(err)
}
for _, want := range []struct{ key, op string }{{"a.x", "put"}, {"a.y", "delete"}} {
select {
case c := <-all:
if c.Key != want.key || c.Op != want.op || c.Current {
t.Fatalf("got %+v, want %s %s, not current", c, want.op, want.key)
}
case <-time.After(10 * time.Second):
t.Fatalf("no %s of %s", want.op, want.key)
}
}
if _, err := s.Put("b.q", 6); err != nil {
t.Fatal(err)
}
for _, want := range []string{"a.x", "a.y"} {
select {
case c := <-narrow:
if c.Key != want || c.Current {
t.Fatalf("narrowed watch got %+v, want the change of %s", c, want)
}
case <-time.After(10 * time.Second):
t.Fatalf("narrowed watch missed %s", want)
}
}
select {
case c := <-narrow:
t.Fatalf("narrowed watch got %+v for a key it did not name", c)
case <-time.After(500 * time.Millisecond):
}
}
// 1840/AC8: given an account not granted a bucket, when it watches it, then the refusal names the bucket.
func TestStateWatchRefusesAnUngrantedBucketNamingIt(t *testing.T) {
url, account := grantedSendProcess(t)
_, adminJetStream := adminJS(t, url)
if _, err := adminJetStream.CreateKeyValue(ctxFor(t), jetstream.KeyValueConfig{Bucket: "mesh-mail_unrelated"}); err != nil {
t.Fatalf("the admin could not make the bucket: %v", err)
}
conn, err := Connect(account)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
err = conn.State("unrelated").Watch("", func(stdio.StateChange) error { return nil })
if err == nil || !errors.Is(err, nats.ErrPermissionViolation) || !strings.Contains(err.Error(), "mesh-mail_unrelated") {
t.Fatalf("Watch on an ungranted bucket: %v, want a refusal naming mesh-mail_unrelated", err)
}
}
// 1840/S1: given a process connection granted its bucket, when it deletes a key, then the key is gone and
// deleting it again is no error.
func TestStateDeleteOnAGrantedBucket(t *testing.T) {
url, account := grantedSendProcess(t)
_, adminJetStream := adminJS(t, url)
if _, err := adminJetStream.CreateKeyValue(ctxFor(t), jetstream.KeyValueConfig{Bucket: "mesh-mail_sends"}); err != nil {
t.Fatalf("the admin could not make the bucket: %v", err)
}
conn, err := Connect(account)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
s := conn.State("sends")
if _, err := s.Put("k", 1); err != nil {
t.Fatal(err)
}
if err := s.Delete("k"); err != nil {
t.Fatalf("Delete on a granted bucket: %v", err)
}
if e, err := s.Get("k"); err != nil || e != nil {
t.Fatalf("Get after Delete: %+v, %v", e, err)
}
if err := s.Delete("k"); err != nil {
t.Fatalf("Delete of an absent key: %v", err)
}
}
+199
View File
@@ -3,8 +3,11 @@ package process
import (
"context"
"errors"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
@@ -32,6 +35,9 @@ type fakeKV struct {
values map[string][]byte
revs map[string]uint64
next uint64
// initial is what a Watch hands first, before its nil marker; watcher is the last one made.
initial []jetstream.KeyValueEntry
watcher *fakeWatcher
}
func newFakeKV() *fakeKV { return &fakeKV{values: map[string][]byte{}, revs: map[string]uint64{}} }
@@ -68,6 +74,58 @@ func (f *fakeKV) Update(_ context.Context, key string, value []byte, revision ui
return f.next, nil
}
func (f *fakeKV) Put(_ context.Context, key string, value []byte) (uint64, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.next++
f.values[key], f.revs[key] = value, f.next
return f.next, nil
}
func (f *fakeKV) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.values, key)
delete(f.revs, key)
return nil
}
func (f *fakeKV) Keys(_ context.Context, _ ...jetstream.WatchOpt) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.values) == 0 {
return nil, jetstream.ErrNoKeysFound
}
keys := make([]string, 0, len(f.values))
for k := range f.values {
keys = append(keys, k)
}
sort.Sort(sort.Reverse(sort.StringSlice(keys))) // as a bucket lists them: in no order the caller may rely on
return keys, nil
}
// fakeWatcher is a jetstream.KeyWatcher fed by a test.
type fakeWatcher struct {
jetstream.KeyWatcher
updates chan jetstream.KeyValueEntry
pattern string
}
func (w *fakeWatcher) Updates() <-chan jetstream.KeyValueEntry { return w.updates }
func (w *fakeWatcher) Stop() error { return nil }
func (f *fakeKV) Watch(_ context.Context, pattern string, _ ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) {
f.mu.Lock()
defer f.mu.Unlock()
w := &fakeWatcher{updates: make(chan jetstream.KeyValueEntry, 16), pattern: pattern}
f.watcher = w
for _, e := range f.initial {
w.updates <- e
}
w.updates <- nil
return w, nil
}
func stateOn(kv jetstream.KeyValue) State {
return State{Module: "mesh-mail", Name: "sends", kv: func(context.Context) (jetstream.KeyValue, error) { return kv, nil }}
}
@@ -103,3 +161,144 @@ func TestStateSatisfiesAskerStore(t *testing.T) {
t.Fatal("Update did not advance the revision")
}
}
// 1840/S1: given a state, when a key is put twice, then each Put returns a new revision and the key reads
// back the last value, whether or not it had one.
func TestStatePutWritesAKeyWhole(t *testing.T) {
s := stateOn(newFakeKV())
rev, err := s.Put("k", map[string]any{"a": 1})
if err != nil || rev == 0 {
t.Fatalf("Put of a new key: %d, %v", rev, err)
}
rev2, err := s.Put("k", map[string]any{"a": 2})
if err != nil || rev2 <= rev {
t.Fatalf("Put over a key: %d, %v, want a revision after %d", rev2, err, rev)
}
e, err := s.Get("k")
if err != nil || e == nil || e.Revision != rev2 || string(e.Value) != `{"a":2}` {
t.Fatalf("Get after Put: %+v, %v", e, err)
}
}
// 1840/S1: given a state, when its keys are listed, then they come sorted, and none is an empty list, not an error.
func TestStateKeysAreSortedAndEmptyWhenNone(t *testing.T) {
s := stateOn(newFakeKV())
if keys, err := s.Keys(); err != nil || len(keys) != 0 {
t.Fatalf("Keys of an empty state: %v, %v", keys, err)
}
for _, k := range []string{"b", "c", "a"} {
if _, err := s.Put(k, 1); err != nil {
t.Fatal(err)
}
}
keys, err := s.Keys()
if err != nil || strings.Join(keys, ",") != "a,b,c" {
t.Fatalf("Keys: %v, %v, want a,b,c", keys, err)
}
}
type watchEntry struct {
fakeEntry
op jetstream.KeyValueOp
}
func (e watchEntry) Operation() jetstream.KeyValueOp { return e.op }
// 1840/S2: given a state with current values (one of them deleted), when it is watched, then the handler gets
// each current value marked Current and not the deleted one, Watch returns, and a later put and delete arrive
// in order unmarked.
func TestStateWatchHandsCurrentValuesThenChanges(t *testing.T) {
kv := newFakeKV()
kv.initial = []jetstream.KeyValueEntry{
watchEntry{fakeEntry{key: "a", value: []byte(`1`), rev: 1}, jetstream.KeyValuePut},
watchEntry{fakeEntry{key: "gone", rev: 2}, jetstream.KeyValueDelete},
watchEntry{fakeEntry{key: "b", value: []byte(`2`), rev: 3}, jetstream.KeyValuePut},
}
got := make(chan stdio.StateChange, 8)
s := stateOn(kv)
if err := s.Watch("", func(c stdio.StateChange) error { got <- c; return nil }); err != nil {
t.Fatal(err)
}
for _, want := range []stdio.StateChange{
{State: "sends", Key: "a", Op: "put", Value: []byte(`1`), Revision: 1, Current: true},
{State: "sends", Key: "b", Op: "put", Value: []byte(`2`), Revision: 3, Current: true},
} {
select {
case c := <-got:
if c.Key != want.Key || c.Op != want.Op || string(c.Value) != string(want.Value) || c.Revision != want.Revision || c.Current != want.Current || c.State != want.State {
t.Fatalf("got %+v, want %+v", c, want)
}
default:
t.Fatalf("Watch returned before handling %+v", want)
}
}
if len(got) != 0 {
t.Fatalf("a deleted key was handed: %+v", <-got)
}
kv.watcher.updates <- watchEntry{fakeEntry{key: "a", value: []byte(`3`), rev: 4}, jetstream.KeyValuePut}
kv.watcher.updates <- watchEntry{fakeEntry{key: "b", rev: 5}, jetstream.KeyValueDelete}
for _, want := range []stdio.StateChange{
{Key: "a", Op: "put", Revision: 4}, {Key: "b", Op: "delete", Revision: 5},
} {
select {
case c := <-got:
if c.Key != want.Key || c.Op != want.Op || c.Revision != want.Revision || c.Current {
t.Fatalf("got %+v, want %+v", c, want)
}
case <-time.After(5 * time.Second):
t.Fatalf("no change %+v", want)
}
}
}
// 1840/S2: a key narrowing reaches the bus as a subject pattern: `*` one name, `**` the rest, empty every key.
func TestStateWatchNarrowing(t *testing.T) {
for key, want := range map[string]string{"": ">", "a.b": "a.b", "a.*": "a.*", "a.**": "a.>", "**": ">"} {
if got := watchPattern(key); got != want {
t.Errorf("watchPattern(%q) = %q, want %q", key, got, want)
}
}
}
// 1840/S2: given a handler that refuses a current value, then Watch returns its error.
func TestStateWatchReturnsAHandlersErrorOnCurrentValues(t *testing.T) {
kv := newFakeKV()
kv.initial = []jetstream.KeyValueEntry{watchEntry{fakeEntry{key: "a", value: []byte(`1`), rev: 1}, jetstream.KeyValuePut}}
boom := errors.New("boom")
if err := stateOn(kv).Watch("", func(stdio.StateChange) error { return boom }); !errors.Is(err, boom) {
t.Fatalf("Watch: %v, want the handler's error", err)
}
}
// 1840/S2: given a connection that closes before the current values arrive, then Watch returns an error.
func TestStateWatchEndsWithAnErrorWhenTheConnectionCloses(t *testing.T) {
s := State{Module: "mesh-mail", Name: "sends", closed: func() bool { return true },
kv: func(context.Context) (jetstream.KeyValue, error) { return silentKV{}, nil }}
if err := s.Watch("", func(stdio.StateChange) error { return nil }); err == nil {
t.Fatal("Watch on a closed connection returned no error")
}
}
// silentKV watches but never delivers.
type silentKV struct{ jetstream.KeyValue }
func (silentKV) Watch(context.Context, string, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) {
return &fakeWatcher{updates: make(chan jetstream.KeyValueEntry)}, nil
}
// 1840/S1: given a key, when it is deleted, then it reads as absent; deleting a key that was not there is no error.
func TestStateDeleteRemovesAKeyAndAnAbsentOneIsNoError(t *testing.T) {
s := stateOn(newFakeKV())
if _, err := s.Put("k", 1); err != nil {
t.Fatal(err)
}
if err := s.Delete("k"); err != nil {
t.Fatalf("Delete of a key: %v", err)
}
if e, err := s.Get("k"); err != nil || e != nil {
t.Fatalf("Get after Delete: %+v, %v, want absent", e, err)
}
if err := s.Delete("never"); err != nil {
t.Fatalf("Delete of an absent key: %v", err)
}
}
+3
View File
@@ -50,6 +50,9 @@ func State(name string) KeptState { return KeptState{name: name} }
const stateTimeout = 30 * time.Second
func (s KeptState) ask(verb string, params map[string]any) (json.RawMessage, error) {
if err := refusedUnannounced(s.name); err != nil {
return nil, err
}
c, err := live()
if err != nil {
return nil, err
+3
View File
@@ -180,6 +180,9 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
currentMu.Lock()
current = c
currentMu.Unlock()
// What the bundle said it keeps is announced once it serves: the runtime answers the publish on this channel, so
// it is asked from beside the loop that reads the answer.
go func() { _ = announce1() }()
scanner := bufio.NewScanner(in)
scanner.Buffer(make([]byte, 1<<20), 1<<24)
for scanner.Scan() {
@@ -1,5 +1,7 @@
// Package testbus is the bus an integration test of the SDK's Go binding runs against: a server of
// its own, of the release go.mod pins, started for the one test and gone after it.
// its own, of the release go.mod pins, started for the one test and gone after it. It is exported so a
// module's own integration tests start against the same (announcetest, novox/hq ADR 0495 clause 5;
// issue 1182 names the controller's copy).
//
// **Why one per test.** A test that reaches a shared bus asserts, reads and removes objects by fixed
// name — a stream, a consumer — and two tests at once would step on each other's: one deleting what
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 Antithesis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-214
View File
@@ -1,214 +0,0 @@
//go:build enable_antithesis_sdk
// Package assert enables defining [test properties] about your program or [workload]. It is part of the [Antithesis Go SDK], which enables Go applications to integrate with the [Antithesis platform].
//
// Code that uses this package should be instrumented with the [antithesis-go-generator] utility. This step is required for the Always, Sometime, and Reachable methods. It is not required for the Unreachable and AlwaysOrUnreachable methods, but it will improve the experience of using them.
//
// These functions are no-ops with minimal performance overhead when called outside of the Antithesis environment. However, if the environment variable ANTITHESIS_SDK_LOCAL_OUTPUT is set, these functions will log to the file pointed to by that variable using a structured JSON format defined [here]. This allows you to make use of the Antithesis assertions package in your regular testing, or even in production. In particular, very few assertions frameworks offer a convenient way to define [Sometimes assertions], but they can be quite useful even outside Antithesis.
//
// Each function in this package takes a parameter called message, which is a human readable identifier used to aggregate assertions. Antithesis generates one test property per unique message and this test property will be named "<message>" in the [triage report].
//
// This test property either passes or fails, which depends upon the evaluation of every assertion that shares its message. Different assertions in different parts of the code should have different message, but the same assertion should always have the same message even if it is moved to a different file.
//
// Each function also takes a parameter called details, which is a key-value map of optional additional information provided by the user to add context for assertion failures. The information that is logged will appear in the [triage report], under the details section of the corresponding property. Normally the values passed to details are evaluated at runtime.
//
// [Antithesis Go SDK]: https://antithesis.com/docs/using_antithesis/sdk/go/
// [Antithesis platform]: https://antithesis.com
// [test properties]: https://antithesis.com/docs/properties_assertions/properties/
// [workload]: https://antithesis.com/docs/test_templates/first_test/
// [antithesis-go-generator]: https://antithesis.com/docs/using_antithesis/sdk/go/instrumentor/
// [triage report]: https://antithesis.com/docs/reports/
// [here]: https://antithesis.com/docs/using_antithesis/sdk/fallback/
// [Sometimes assertions]: https://antithesis.com/docs/best_practices/sometimes_assertions/
//
// [details]: https://antithesis.com/docs/reports/
package assert
import (
"encoding/json"
"fmt"
)
type assertInfo struct {
Location *locationInfo `json:"location"`
Details map[string]any `json:"details"`
AssertType string `json:"assert_type"`
DisplayType string `json:"display_type"`
Message string `json:"message"`
Id string `json:"id"`
Hit bool `json:"hit"`
MustHit bool `json:"must_hit"`
Condition bool `json:"condition"`
}
// Create a custom json marshaler for assertInfo so that we can force Errors to be marshaled with their error details.
// Without this, custom errors are marshaled as an empty object because the default json marshaler doesn't include the error
// (because it's a method - not an exported struct field).
func (f assertInfo) MarshalJSON() ([]byte, error) {
type alias assertInfo // prevent infinite recursion
a := alias(f)
if a.Details != nil {
a.Details = normalizeMap(a.Details)
}
return json.Marshal(a)
}
type jsonError struct {
innerError error
}
func (e jsonError) MarshalJSON() ([]byte, error) {
// Marshal this as the debug output string instead of e.Error(). These should be equivalent, but Sprintf correctly
// handles nil values for us (which otherwise are annoying to defend against due to this - https://go.dev/doc/faq#nil_error)
return json.Marshal(fmt.Sprintf("%+v", e.innerError))
}
// Recursively replace any `error` with jsonError while doing a deep copy.
// Most of the logic is in the normalize method below. This method exists to localize the type assertions
// and provide a function that takes in/out a map instead of any.
func normalizeMap(v map[string]any) map[string]any {
return normalize(v).(map[string]any)
}
func normalize(input any) any {
// This switch will miss some cases (pointers, structs, non-any types), but should catch a very large proportion of real error interfaces
// in real details objects. We can augment this if we find other cases common enough to support.
switch inputTyped := input.(type) {
case error:
// Check if the underlying error implements json.Marshaler, so that if the error
// already knows who to marshal itself, we don't override that.
if _, ok := inputTyped.(json.Marshaler); ok {
return inputTyped
} else {
return jsonError{inputTyped}
}
case map[string]any:
out := make(map[string]any, len(inputTyped))
for k, v := range inputTyped {
out[k] = normalize(v)
}
return out
case []any:
out := make([]any, len(inputTyped))
for i := range inputTyped {
out[i] = normalize(inputTyped[i])
}
return out
default:
return input
}
}
type wrappedAssertInfo struct {
A *assertInfo `json:"antithesis_assert"`
}
// --------------------------------------------------------------------------------
// Assertions
// --------------------------------------------------------------------------------
const (
wasHit = true
mustBeHit = true
optionallyHit = false
expectingTrue = true
)
const (
universalTest = "always"
existentialTest = "sometimes"
reachabilityTest = "reachability"
)
const (
alwaysDisplay = "Always"
alwaysOrUnreachableDisplay = "AlwaysOrUnreachable"
sometimesDisplay = "Sometimes"
reachableDisplay = "Reachable"
unreachableDisplay = "Unreachable"
)
// Always asserts that condition is true every time this function is called, and that it is called at least once. The corresponding test property will be viewable in the Antithesis SDK: Always group of your triage report.
func Always(condition bool, message string, details map[string]any) {
locationInfo := newLocationInfo(offsetAPICaller)
id := makeKey(message, locationInfo)
assertImpl(condition, message, details, locationInfo, wasHit, mustBeHit, universalTest, alwaysDisplay, id)
}
// AlwaysOrUnreachable asserts that condition is true every time this function is called. The corresponding test property will pass if the assertion is never encountered (unlike Always assertion types). This test property will be viewable in the “Antithesis SDK: Always” group of your triage report.
func AlwaysOrUnreachable(condition bool, message string, details map[string]any) {
locationInfo := newLocationInfo(offsetAPICaller)
id := makeKey(message, locationInfo)
assertImpl(condition, message, details, locationInfo, wasHit, optionallyHit, universalTest, alwaysOrUnreachableDisplay, id)
}
// Sometimes asserts that condition is true at least one time that this function was called. (If the assertion is never encountered, the test property will therefore fail.) This test property will be viewable in the “Antithesis SDK: Sometimes” group.
func Sometimes(condition bool, message string, details map[string]any) {
locationInfo := newLocationInfo(offsetAPICaller)
id := makeKey(message, locationInfo)
assertImpl(condition, message, details, locationInfo, wasHit, mustBeHit, existentialTest, sometimesDisplay, id)
}
// Unreachable asserts that a line of code is never reached. The corresponding test property will fail if this function is ever called. (If it is never called the test property will therefore pass.) This test property will be viewable in the “Antithesis SDK: Reachablity assertions” group.
func Unreachable(message string, details map[string]any) {
locationInfo := newLocationInfo(offsetAPICaller)
id := makeKey(message, locationInfo)
assertImpl(false, message, details, locationInfo, wasHit, optionallyHit, reachabilityTest, unreachableDisplay, id)
}
// Reachable asserts that a line of code is reached at least once. The corresponding test property will pass if this function is ever called. (If it is never called the test property will therefore fail.) This test property will be viewable in the “Antithesis SDK: Reachablity assertions” group.
func Reachable(message string, details map[string]any) {
locationInfo := newLocationInfo(offsetAPICaller)
id := makeKey(message, locationInfo)
assertImpl(true, message, details, locationInfo, wasHit, mustBeHit, reachabilityTest, reachableDisplay, id)
}
// AssertRaw is a low-level method designed to be used by third-party frameworks. Regular users of the assert package should not call it.
func AssertRaw(cond bool, message string, details map[string]any,
classname, funcname, filename string, line int,
hit bool, mustHit bool,
assertType string, displayType string,
id string,
) {
assertImpl(cond, message, details,
&locationInfo{classname, funcname, filename, line, columnUnknown},
hit, mustHit,
assertType, displayType,
id)
}
func assertImpl(cond bool, message string, details map[string]any,
loc *locationInfo,
hit bool, mustHit bool,
assertType string, displayType string,
id string,
) {
trackerEntry := assertTracker.getTrackerEntry(id, loc.Filename, loc.Classname)
// Always grab the Filename and Classname captured when the trackerEntry was established
// This provides the consistency needed between instrumentation-time and runtime
if loc.Filename != trackerEntry.Filename {
loc.Filename = trackerEntry.Filename
}
if loc.Classname != trackerEntry.Classname {
loc.Classname = trackerEntry.Classname
}
aI := &assertInfo{
Hit: hit,
MustHit: mustHit,
AssertType: assertType,
DisplayType: displayType,
Message: message,
Condition: cond,
Id: id,
Location: loc,
Details: details,
}
trackerEntry.emit(aI)
}
func makeKey(message string, _ *locationInfo) string {
return message
}
@@ -1,16 +0,0 @@
//go:build !enable_antithesis_sdk
package assert
func Always(condition bool, message string, details map[string]any) {}
func AlwaysOrUnreachable(condition bool, message string, details map[string]any) {}
func Sometimes(condition bool, message string, details map[string]any) {}
func Unreachable(message string, details map[string]any) {}
func Reachable(message string, details map[string]any) {}
func AssertRaw(cond bool, message string, details map[string]any,
classname, funcname, filename string, line int,
hit bool, mustHit bool,
assertType string, displayType string,
id string,
) {
}
@@ -1,30 +0,0 @@
package assert
// Allowable numeric types of comparison parameters
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint8 | ~uint16 | ~uint32 | ~float32 | ~float64 | ~uint64 | ~uint | ~uintptr
}
// Internally, numeric guidanceFn Operands only use these
type operandConstraint interface {
int32 | int64 | uint64 | float64
}
type numConstraint interface {
uint64 | float64
}
// Used for boolean assertions
type NamedBool struct {
First string `json:"first"`
Second bool `json:"second"`
}
// Convenience function to construct a NamedBool used for boolean assertions
func NewNamedBool(first string, second bool) *NamedBool {
p := NamedBool{
First: first,
Second: second,
}
return &p
}
@@ -1,66 +0,0 @@
//go:build enable_antithesis_sdk
package assert
import (
"sync"
"github.com/antithesishq/antithesis-sdk-go/internal"
)
// TODO: Tracker is intended to prevent sending the same guidance
// more than once. In this case, we always send, so the tracker
// is not presently used.
type booleanGuidance struct {
n int
}
type booleanGuidanceTracker map[string]*booleanGuidance
var (
boolean_guidance_tracker booleanGuidanceTracker = make(booleanGuidanceTracker)
boolean_guidance_tracker_mutex sync.Mutex
boolean_guidance_info_mutex sync.Mutex
)
func (tracker booleanGuidanceTracker) getTrackerEntry(messageKey string) *booleanGuidance {
var trackerEntry *booleanGuidance
var ok bool
if tracker == nil {
return nil
}
boolean_guidance_tracker_mutex.Lock()
defer boolean_guidance_tracker_mutex.Unlock()
if trackerEntry, ok = boolean_guidance_tracker[messageKey]; !ok {
trackerEntry = newBooleanGuidance()
tracker[messageKey] = trackerEntry
}
return trackerEntry
}
// Create a boolean guidance tracker
func newBooleanGuidance() *booleanGuidance {
trackerInfo := booleanGuidance{}
return &trackerInfo
}
func (tI *booleanGuidance) send_value(bgI *booleanGuidanceInfo) {
if tI == nil {
return
}
boolean_guidance_info_mutex.Lock()
defer boolean_guidance_info_mutex.Unlock()
// The tracker entry should be consulted to determine
// if this Guidance info has already been sent, or not.
emitBooleanGuidance(bgI)
}
func emitBooleanGuidance(bgI *booleanGuidanceInfo) error {
return internal.Json_data(map[string]any{"antithesis_guidance": bgI})
}
-58
View File
@@ -1,58 +0,0 @@
//go:build enable_antithesis_sdk
package assert
import (
"path"
"runtime"
"strings"
)
// stackFrameOffset indicates how many frames to go up in the
// call stack to find the filename/location/line info. As
// this work is always done in NewLocationInfo(), the offset is
// specified from the perspective of NewLocationInfo
type stackFrameOffset int
// Order is important here since iota is being used
const (
offsetNewLocationInfo stackFrameOffset = iota
offsetHere
offsetAPICaller
offsetAPICallersCaller
)
// locationInfo represents the attributes known at instrumentation time
// for each Antithesis assertion discovered
type locationInfo struct {
Classname string `json:"class"`
Funcname string `json:"function"`
Filename string `json:"file"`
Line int `json:"begin_line"`
Column int `json:"begin_column"`
}
// columnUnknown is used when the column associated with
// a locationInfo is not available
const columnUnknown = 0
// NewLocationInfo creates a locationInfo directly from
// the current execution context
func newLocationInfo(nframes stackFrameOffset) *locationInfo {
// Get location info and add to details
funcname := "*function*"
classname := "*class*"
pc, filename, line, ok := runtime.Caller(int(nframes))
if !ok {
filename = "*file*"
line = 0
} else {
if this_func := runtime.FuncForPC(pc); this_func != nil {
fullname := this_func.Name()
funcname = path.Ext(fullname)
classname, _ = strings.CutSuffix(fullname, funcname)
funcname = funcname[1:]
}
}
return &locationInfo{classname, funcname, filename, line, columnUnknown}
}
@@ -1,323 +0,0 @@
//go:build enable_antithesis_sdk
package assert
import (
"math"
"sync"
"github.com/antithesishq/antithesis-sdk-go/internal"
)
// --------------------------------------------------------------------------------
// IntegerGap is used for:
// - int, int8, int16, int32, int64:
// - uint, uint8, uint16, uint32, uint64, uintptr:
//
// FloatGap is used for:
// - float32, float64
// --------------------------------------------------------------------------------
type numericGapType int
const (
integerGapType numericGapType = iota
floatGapType
)
func gapTypeForOperand[T Number](num T) numericGapType {
gapType := integerGapType
switch any(num).(type) {
case float32, float64:
gapType = floatGapType
}
return gapType
}
// --------------------------------------------------------------------------------
// numericGuidanceTracker - Tracking Info for Numeric Guidance
//
// For GuidanceFnMaximize:
// - gap is the largest value sent so far
//
// For GuidanceFnMinimize:
// - gap is the most negative value sent so far
//
// --------------------------------------------------------------------------------
type numericGuidanceInfo struct {
gap any
descriminator numericGapType
maximize bool
}
type numericGuidanceTracker map[string]*numericGuidanceInfo
var (
numeric_guidance_tracker numericGuidanceTracker = make(numericGuidanceTracker)
numeric_guidance_tracker_mutex sync.Mutex
numeric_guidance_info_mutex sync.Mutex
)
func (tracker numericGuidanceTracker) getTrackerEntry(messageKey string, trackerType numericGapType, maximize bool) *numericGuidanceInfo {
var trackerEntry *numericGuidanceInfo
var ok bool
if tracker == nil {
return nil
}
numeric_guidance_tracker_mutex.Lock()
defer numeric_guidance_tracker_mutex.Unlock()
if trackerEntry, ok = numeric_guidance_tracker[messageKey]; !ok {
trackerEntry = newNumericGuidanceInfo(trackerType, maximize)
tracker[messageKey] = trackerEntry
}
return trackerEntry
}
// Create an numeric guidance entry
func newNumericGuidanceInfo(trackerType numericGapType, maximize bool) *numericGuidanceInfo {
var gap any
if trackerType == integerGapType {
gap = newGapValue(uint64(math.MaxUint64), maximize)
} else {
gap = newGapValue(float64(math.MaxFloat64), maximize)
}
trackerInfo := numericGuidanceInfo{
maximize: maximize,
descriminator: trackerType,
gap: gap,
}
return &trackerInfo
}
func (tI *numericGuidanceInfo) should_maximize() bool {
return tI.maximize
}
func (tI *numericGuidanceInfo) is_integer_gap() bool {
return tI.descriminator == integerGapType
}
// --------------------------------------------------------------------------------
// Represents integral and floating point extremes
// --------------------------------------------------------------------------------
type gapValue[T numConstraint] struct {
gap_size T
gap_is_negative bool
}
func newGapValue[T numConstraint](sz T, is_neg bool) any {
switch any(sz).(type) {
case uint64:
return gapValue[uint64]{gap_size: uint64(sz), gap_is_negative: is_neg}
case float64:
return gapValue[float64]{gap_size: float64(sz), gap_is_negative: is_neg}
}
return nil
}
func is_same_sign(left_val int64, right_val int64) bool {
same_sign := false
if left_val < 0 {
// left is negative
if right_val < 0 {
same_sign = true
}
} else {
// left is non-negative
if right_val >= 0 {
same_sign = true
}
}
return same_sign
}
func abs_int64(val int64) uint64 {
if val >= 0 {
return uint64(val)
}
return uint64(0 - val)
}
func is_greater_than[T numConstraint](left gapValue[T], right gapValue[T]) bool {
if !left.gap_is_negative && !right.gap_is_negative {
return left.gap_size > right.gap_size
}
if !left.gap_is_negative && right.gap_is_negative {
return true // any positive is greater than a negative
}
if left.gap_is_negative && right.gap_is_negative {
return right.gap_size > left.gap_size
}
if left.gap_is_negative && !right.gap_is_negative {
return false // any negative is less than a positive
}
return false
}
func is_less_than[T numConstraint](left gapValue[T], right gapValue[T]) bool {
if !left.gap_is_negative && !right.gap_is_negative {
return left.gap_size < right.gap_size
}
if !left.gap_is_negative && right.gap_is_negative {
return false // any positive is greater than a negative
}
if left.gap_is_negative && right.gap_is_negative {
return right.gap_size < left.gap_size
}
if left.gap_is_negative && !right.gap_is_negative {
return true // any negative is less than a positive
}
return true
}
func send_value_if_needed(tI *numericGuidanceInfo, gI *guidanceInfo) {
if tI == nil {
return
}
numeric_guidance_info_mutex.Lock()
defer numeric_guidance_info_mutex.Unlock()
// if this is a catalog entry (gI.hit is false)
// do not update the reference gap in the tracker (tI *numericGuidanceInfo)
if !gI.Hit {
emitGuidance(gI)
return
}
should_send := false
maximize := tI.should_maximize()
var gap gapValue[uint64]
var float_gap gapValue[float64]
// Needs to have individual case statements to assist
// the compiler to infer the actual type of the var named 'operands'
switch operands := (gI.Data).(type) {
case numericOperands[int32]:
gap = makeGap(operands)
case numericOperands[int64]:
gap = makeGap(operands)
case numericOperands[uint64]:
gap = makeGap(operands)
case numericOperands[float64]:
float_gap = makeFloatGap(operands)
}
var prev_gap gapValue[uint64]
var prev_float_gap gapValue[float64]
has_prev_gap := false
has_prev_float_gap := false
prev_gap, has_prev_gap = tI.gap.(gapValue[uint64])
if !has_prev_gap {
prev_float_gap, has_prev_float_gap = tI.gap.(gapValue[float64])
}
if has_prev_gap {
if maximize {
should_send = is_greater_than(gap, prev_gap)
} else {
should_send = is_less_than(gap, prev_gap)
}
}
if has_prev_float_gap {
if maximize {
should_send = is_greater_than(float_gap, prev_float_gap)
} else {
should_send = is_less_than(float_gap, prev_float_gap)
}
}
if should_send {
if tI.is_integer_gap() {
tI.gap = gap
} else {
tI.gap = float_gap
}
emitGuidance(gI)
}
}
func emitGuidance(gI *guidanceInfo) error {
return internal.Json_data(map[string]any{"antithesis_guidance": gI})
}
// When left and right are the same sign (both negative, or both non-negative)
// Calculate: <result> = (left - right). The gap_size is abs(<result>) and
// gap_is_negative is (right > left)
func makeGap[Op operandConstraint](operand numericOperands[Op]) gapValue[uint64] {
var gap_size uint64
var gap_is_negative bool
switch this_op := any(operand).(type) {
case numericOperands[int32]:
result := int64(this_op.Left) - int64(this_op.Right)
gap_size = abs_int64(result)
gap_is_negative = result < 0
case numericOperands[int64]:
if is_same_sign(this_op.Left, this_op.Right) {
result := int64(this_op.Left) - int64(this_op.Right)
gap_size = abs_int64(result)
gap_is_negative = result < 0
break
}
// Otherwise left and right are opposite signs
// gap = abs(left) + abs(right)
// gap_is_negative = left < right
left_gap_size := abs_int64(this_op.Left)
right_gap_size := abs_int64(this_op.Right)
gap_size = left_gap_size + right_gap_size
gap_is_negative = this_op.Left < this_op.Right
case numericOperands[uint64]:
left_val := this_op.Left
right_val := this_op.Right
gap_is_negative = false
if left_val < right_val {
gap_is_negative = true
gap_size = right_val - left_val
} else {
gap_size = left_val - right_val
}
default:
zero_gap, _ := newGapValue(uint64(0), false).(gapValue[uint64])
return zero_gap
}
this_gap, _ := newGapValue(gap_size, gap_is_negative).(gapValue[uint64])
return this_gap
} // MakeGap
func makeFloatGap[Op operandConstraint](operand numericOperands[Op]) gapValue[float64] {
switch this_op := any(operand).(type) {
case numericOperands[float64]:
left_val := this_op.Left
right_val := this_op.Right
gap_is_negative := false
var gap_size float64
if left_val < right_val {
gap_is_negative = true
gap_size = right_val - left_val
} else {
gap_size = left_val - right_val
}
this_gap, _ := newGapValue(gap_size, gap_is_negative).(gapValue[float64])
return this_gap
default:
zero_gap, _ := newGapValue(float64(0.0), false).(gapValue[float64])
return zero_gap
}
} // MakeFloatGap
@@ -1,330 +0,0 @@
//go:build enable_antithesis_sdk
package assert
// A type for writing raw assertions.
// guidanceFnType allows the assertion to provide guidance to
// the Antithesis platform when testing in Antithesis.
// Regular users of the assert package should not use it.
type guidanceFnType int
const (
guidanceFnMaximize guidanceFnType = iota // Maximize (left - right) values
guidanceFnMinimize // Minimize (left - right) values
guidanceFnWantAll // Encourages fuzzing explorations where boolean values are true
guidanceFnWantNone // Encourages fuzzing explorations where boolean values are false
guidanceFnExplore
)
// guidanceFnExplore
func get_guidance_type_string(gt guidanceFnType) string {
switch gt {
case guidanceFnMaximize, guidanceFnMinimize:
return "numeric"
case guidanceFnWantAll, guidanceFnWantNone:
return "boolean"
case guidanceFnExplore:
return "json"
}
return ""
}
type numericOperands[T operandConstraint] struct {
Left T `json:"left"`
Right T `json:"right"`
}
type guidanceInfo struct {
Data any `json:"guidance_data,omitempty"`
Location *locationInfo `json:"location"`
GuidanceType string `json:"guidance_type"`
Message string `json:"message"`
Id string `json:"id"`
Maximize bool `json:"maximize"`
Hit bool `json:"hit"`
}
type booleanGuidanceInfo struct {
Data any `json:"guidance_data,omitempty"`
Location *locationInfo `json:"location"`
GuidanceType string `json:"guidance_type"`
Message string `json:"message"`
Id string `json:"id"`
Maximize bool `json:"maximize"`
Hit bool `json:"hit"`
}
func uses_maximize(gt guidanceFnType) bool {
return gt == guidanceFnMaximize || gt == guidanceFnWantAll
}
func newOperands[T Number](left, right T) any {
switch any(left).(type) {
case int8, int16, int32:
return numericOperands[int32]{int32(left), int32(right)}
case int, int64:
return numericOperands[int64]{int64(left), int64(right)}
case uint8, uint16, uint32, uint, uint64, uintptr:
return numericOperands[uint64]{uint64(left), uint64(right)}
case float32, float64:
return numericOperands[float64]{float64(left), float64(right)}
}
return nil
}
func build_numeric_guidance[T Number](gt guidanceFnType, message string, left, right T, loc *locationInfo, id string, hit bool) *guidanceInfo {
operands := newOperands(left, right)
if !hit {
operands = nil
}
gI := guidanceInfo{
GuidanceType: get_guidance_type_string(gt),
Message: message,
Id: id,
Location: loc,
Maximize: uses_maximize(gt),
Data: operands,
Hit: hit,
}
return &gI
}
type namedBoolDictionary map[string]bool
func build_boolean_guidance(gt guidanceFnType, message string, named_bools []NamedBool,
loc *locationInfo,
id string, hit bool) *booleanGuidanceInfo {
var guidance_data any
// To ensure the sequence and naming for the named_bool values
if hit {
named_bool_dictionary := namedBoolDictionary{}
for _, named_bool := range named_bools {
named_bool_dictionary[named_bool.First] = named_bool.Second
}
guidance_data = named_bool_dictionary
}
bgI := booleanGuidanceInfo{
GuidanceType: get_guidance_type_string(gt),
Message: message,
Id: id,
Location: loc,
Maximize: uses_maximize(gt),
Data: guidance_data,
Hit: hit,
}
return &bgI
}
func behavior_to_guidance(behavior string) guidanceFnType {
guidance := guidanceFnExplore
switch behavior {
case "maximize":
guidance = guidanceFnMaximize
case "minimize":
guidance = guidanceFnMinimize
case "all":
guidance = guidanceFnWantAll
case "none":
guidance = guidanceFnWantNone
}
return guidance
}
func numericGuidanceImpl[T Number](left, right T, message, id string, loc *locationInfo, guidanceFn guidanceFnType, hit bool) {
tI := numeric_guidance_tracker.getTrackerEntry(id, gapTypeForOperand(left), uses_maximize(guidanceFn))
gI := build_numeric_guidance(guidanceFn, message, left, right, loc, id, hit)
send_value_if_needed(tI, gI)
}
func booleanGuidanceImpl(named_bools []NamedBool, message, id string, loc *locationInfo, guidanceFn guidanceFnType, hit bool) {
tI := boolean_guidance_tracker.getTrackerEntry(id)
bgI := build_boolean_guidance(guidanceFn, message, named_bools, loc, id, hit)
tI.send_value(bgI)
}
// NumericGuidanceRaw is a low-level method designed to be used by third-party frameworks. Regular users of the assert package should not call it.
func NumericGuidanceRaw[T Number](
left, right T,
message, id string,
classname, funcname, filename string,
line int,
behavior string,
hit bool,
) {
loc := &locationInfo{classname, funcname, filename, line, columnUnknown}
guidanceFn := behavior_to_guidance(behavior)
numericGuidanceImpl(left, right, message, id, loc, guidanceFn, hit)
}
// BooleanGuidanceRaw is a low-level method designed to be used by third-party frameworks. Regular users of the assert package should not call it.
func BooleanGuidanceRaw(
named_bools []NamedBool,
message, id string,
classname, funcname, filename string,
line int,
behavior string,
hit bool,
) {
loc := &locationInfo{classname, funcname, filename, line, columnUnknown}
guidanceFn := behavior_to_guidance(behavior)
booleanGuidanceImpl(named_bools, message, id, loc, guidanceFn, hit)
}
func add_numeric_details[T Number](details map[string]any, left, right T) map[string]any {
// ----------------------------------------------------
// Can not use maps.Clone() until go 1.21.0 or above
// enhancedDetails := maps.Clone(details)
// ----------------------------------------------------
enhancedDetails := map[string]any{}
for k, v := range details {
enhancedDetails[k] = v
}
enhancedDetails["left"] = left
enhancedDetails["right"] = right
return enhancedDetails
}
func add_boolean_details(details map[string]any, named_bools []NamedBool) map[string]any {
// ----------------------------------------------------
// Can not use maps.Clone() until go 1.21.0 or above
// enhancedDetails := maps.Clone(details)
// ----------------------------------------------------
enhancedDetails := map[string]any{}
for k, v := range details {
enhancedDetails[k] = v
}
for _, named_bool := range named_bools {
enhancedDetails[named_bool.First] = named_bool.Second
}
return enhancedDetails
}
// Equivalent to asserting Always(left > right, message, details). Information about left and right will automatically be added to the details parameter, with keys left and right. If you use this function for assertions that compare numeric quantities, you may help Antithesis find more bugs.
func AlwaysGreaterThan[T Number](left, right T, message string, details map[string]any) {
loc := newLocationInfo(offsetAPICaller)
id := makeKey(message, loc)
condition := left > right
all_details := add_numeric_details(details, left, right)
assertImpl(condition, message, all_details, loc, wasHit, mustBeHit, universalTest, alwaysDisplay, id)
numericGuidanceImpl(left, right, message, id, loc, guidanceFnMinimize, wasHit)
}
// Equivalent to asserting Always(left >= right, message, details). Information about left and right will automatically be added to the details parameter, with keys left and right. If you use this function for assertions that compare numeric quantities, you may help Antithesis find more bugs.
func AlwaysGreaterThanOrEqualTo[T Number](left, right T, message string, details map[string]any) {
loc := newLocationInfo(offsetAPICaller)
id := makeKey(message, loc)
condition := left >= right
all_details := add_numeric_details(details, left, right)
assertImpl(condition, message, all_details, loc, wasHit, mustBeHit, universalTest, alwaysDisplay, id)
numericGuidanceImpl(left, right, message, id, loc, guidanceFnMinimize, wasHit)
}
// Equivalent to asserting Sometimes(T left > T right, message, details). Information about left and right will automatically be added to the details parameter, with keys left and right. If you use this function for assertions that compare numeric quantities, you may help Antithesis find more bugs.
func SometimesGreaterThan[T Number](left, right T, message string, details map[string]any) {
loc := newLocationInfo(offsetAPICaller)
id := makeKey(message, loc)
condition := left > right
all_details := add_numeric_details(details, left, right)
assertImpl(condition, message, all_details, loc, wasHit, mustBeHit, existentialTest, sometimesDisplay, id)
numericGuidanceImpl(left, right, message, id, loc, guidanceFnMaximize, wasHit)
}
// Equivalent to asserting Sometimes(T left >= T right, message, details). Information about left and right will automatically be added to the details parameter, with keys left and right. If you use this function for assertions that compare numeric quantities, you may help Antithesis find more bugs.
func SometimesGreaterThanOrEqualTo[T Number](left, right T, message string, details map[string]any) {
loc := newLocationInfo(offsetAPICaller)
id := makeKey(message, loc)
condition := left >= right
all_details := add_numeric_details(details, left, right)
assertImpl(condition, message, all_details, loc, wasHit, mustBeHit, existentialTest, sometimesDisplay, id)
numericGuidanceImpl(left, right, message, id, loc, guidanceFnMaximize, wasHit)
}
// Equivalent to asserting Always(left < right, message, details). Information about left and right will automatically be added to the details parameter, with keys left and right. If you use this function for assertions that compare numeric quantities, you may help Antithesis find more bugs.
func AlwaysLessThan[T Number](left, right T, message string, details map[string]any) {
loc := newLocationInfo(offsetAPICaller)
id := makeKey(message, loc)
condition := left < right
all_details := add_numeric_details(details, left, right)
assertImpl(condition, message, all_details, loc, wasHit, mustBeHit, universalTest, alwaysDisplay, id)
numericGuidanceImpl(left, right, message, id, loc, guidanceFnMaximize, wasHit)
}
// Equivalent to asserting Always(left <= right, message, details). Information about left and right will automatically be added to the details parameter, with keys left and right. If you use this function for assertions that compare numeric quantities, you may help Antithesis find more bugs.
func AlwaysLessThanOrEqualTo[T Number](left, right T, message string, details map[string]any) {
loc := newLocationInfo(offsetAPICaller)
id := makeKey(message, loc)
condition := left <= right
all_details := add_numeric_details(details, left, right)
assertImpl(condition, message, all_details, loc, wasHit, mustBeHit, universalTest, alwaysDisplay, id)
numericGuidanceImpl(left, right, message, id, loc, guidanceFnMaximize, wasHit)
}
// Equivalent to asserting Sometimes(T left < T right, message, details). Information about left and right will automatically be added to the details parameter, with keys left and right. If you use this function for assertions that compare numeric quantities, you may help Antithesis find more bugs.
func SometimesLessThan[T Number](left, right T, message string, details map[string]any) {
loc := newLocationInfo(offsetAPICaller)
id := makeKey(message, loc)
condition := left < right
all_details := add_numeric_details(details, left, right)
assertImpl(condition, message, all_details, loc, wasHit, mustBeHit, existentialTest, sometimesDisplay, id)
numericGuidanceImpl(left, right, message, id, loc, guidanceFnMinimize, wasHit)
}
// Equivalent to asserting Sometimes(T left <= T right, message, details). Information about left and right will automatically be added to the details parameter, with keys left and right. If you use this function for assertions that compare numeric quantities, you may help Antithesis find more bugs.
func SometimesLessThanOrEqualTo[T Number](left, right T, message string, details map[string]any) {
loc := newLocationInfo(offsetAPICaller)
id := makeKey(message, loc)
condition := left <= right
all_details := add_numeric_details(details, left, right)
assertImpl(condition, message, all_details, loc, wasHit, mustBeHit, existentialTest, sometimesDisplay, id)
numericGuidanceImpl(left, right, message, id, loc, guidanceFnMinimize, wasHit)
}
// Asserts that every time this is called, at least one bool in named_bools is true. Equivalent to Always(named_bools[0].second || named_bools[1].second || ..., message, details). If you use this for assertions about the behavior of booleans, you may help Antithesis find more bugs. Information about named_bools will automatically be added to the details parameter, and the keys will be the names of the bools.
func AlwaysSome(named_bools []NamedBool, message string, details map[string]any) {
loc := newLocationInfo(offsetAPICaller)
id := makeKey(message, loc)
disjunction := false
for _, named_bool := range named_bools {
if named_bool.Second {
disjunction = true
break
}
}
all_details := add_boolean_details(details, named_bools)
assertImpl(disjunction, message, all_details, loc, wasHit, mustBeHit, universalTest, alwaysDisplay, id)
booleanGuidanceImpl(named_bools, message, id, loc, guidanceFnWantNone, wasHit)
}
// Asserts that at least one time this is called, every bool in named_bools is true. Equivalent to Sometimes(named_bools[0].second && named_bools[1].second && ..., message, details). If you use this for assertions about the behavior of booleans, you may help Antithesis find more bugs. Information about named_bools will automatically be added to the details parameter, and the keys will be the names of the bools.
func SometimesAll(named_bools []NamedBool, message string, details map[string]any) {
loc := newLocationInfo(offsetAPICaller)
id := makeKey(message, loc)
conjunction := true
for _, named_bool := range named_bools {
if !named_bool.Second {
conjunction = false
break
}
}
all_details := add_boolean_details(details, named_bools)
assertImpl(conjunction, message, all_details, loc, wasHit, mustBeHit, existentialTest, sometimesDisplay, id)
booleanGuidanceImpl(named_bools, message, id, loc, guidanceFnWantAll, wasHit)
}
@@ -1,34 +0,0 @@
//go:build !enable_antithesis_sdk
package assert
func AlwaysGreaterThan[T Number](left, right T, message string, details map[string]any) {}
func AlwaysGreaterThanOrEqualTo[T Number](left, right T, message string, details map[string]any) {}
func SometimesGreaterThan[T Number](left, right T, message string, details map[string]any) {}
func SometimesGreaterThanOrEqualTo[T Number](left, right T, message string, details map[string]any) {}
func AlwaysLessThan[T Number](left, right T, message string, details map[string]any) {}
func AlwaysLessThanOrEqualTo[T Number](left, right T, message string, details map[string]any) {}
func SometimesLessThan[T Number](left, right T, message string, details map[string]any) {}
func SometimesLessThanOrEqualTo[T Number](left, right T, message string, details map[string]any) {}
func AlwaysSome(named_bool []NamedBool, message string, details map[string]any) {}
func SometimesAll(named_bool []NamedBool, message string, details map[string]any) {}
func NumericGuidanceRaw[T Number](left, right T,
message, id string,
classname, funcname, filename string,
line int,
behavior string,
hit bool,
) {
}
func BooleanGuidanceRaw(
named_bools []NamedBool,
message, id string,
classname, funcname, filename string,
line int,
behavior string,
hit bool,
) {
}
-111
View File
@@ -1,111 +0,0 @@
//go:build enable_antithesis_sdk
package assert
import (
"runtime"
"sync"
"sync/atomic"
"github.com/antithesishq/antithesis-sdk-go/internal"
)
type trackerInfo struct {
Filename string
Classname string
PassCount int
FailCount int
}
type emitTracker map[string]*trackerInfo
// assert_tracker (global) keeps track of the unique asserts evaluated
var (
assertTracker emitTracker = make(emitTracker)
trackerMutex sync.Mutex
trackerInfoMutex sync.Mutex
)
func (tracker emitTracker) getTrackerEntry(messageKey string, filename, classname string) *trackerInfo {
var trackerEntry *trackerInfo
var ok bool
if tracker == nil {
return nil
}
trackerMutex.Lock()
defer trackerMutex.Unlock()
if trackerEntry, ok = tracker[messageKey]; !ok {
trackerEntry = newTrackerInfo(filename, classname)
tracker[messageKey] = trackerEntry
}
return trackerEntry
}
func newTrackerInfo(filename, classname string) *trackerInfo {
trackerInfo := trackerInfo{
PassCount: 0,
FailCount: 0,
Filename: filename,
Classname: classname,
}
return &trackerInfo
}
func (ti *trackerInfo) emit(ai *assertInfo) {
if ti == nil || ai == nil {
return
}
// Registrations are just sent to voidstar
hit := ai.Hit
if !hit {
emitAssert(ai)
return
}
var err error
cond := ai.Condition
trackerInfoMutex.Lock()
defer trackerInfoMutex.Unlock()
if cond {
if ti.PassCount == 0 {
err = emitAssert(ai)
}
if err == nil {
ti.PassCount++
}
return
}
if ti.FailCount == 0 {
err = emitAssert(ai)
}
if err == nil {
ti.FailCount++
}
}
func versionMessage() {
languageBlock := map[string]any{
"name": "Go",
"version": runtime.Version(),
}
versionBlock := map[string]any{
"language": languageBlock,
"sdk_version": internal.SDK_Version,
"protocol_version": internal.Protocol_Version,
}
internal.Json_data(map[string]any{"antithesis_sdk": versionBlock})
}
// package-level flag
var hasEmitted atomic.Bool // initialzed to false
func emitAssert(ai *assertInfo) error {
if hasEmitted.CompareAndSwap(false, true) {
versionMessage()
}
return internal.Json_data(wrappedAssertInfo{ai})
}
-100
View File
@@ -1,100 +0,0 @@
//go:build enable_antithesis_sdk
package internal
import (
"encoding/json"
"log"
"math/rand"
"os"
)
func Json_data(v any) error {
if data, err := json.Marshal(v); err != nil {
return err
} else {
handler.output(string(data))
return nil
}
}
func Get_random() uint64 {
return handler.random()
}
func Notify(edge uint64) bool {
return handler.notify(edge)
}
func InitCoverage(num_edges uint64, symbols string) uint64 {
return handler.init_coverage(num_edges, symbols)
}
type libHandler interface {
output(message string)
random() uint64
notify(edge uint64) bool
init_coverage(num_edges uint64, symbols string) uint64
}
const (
errorLogLinePrefix = "[* antithesis-sdk-go *]"
)
var handler libHandler
type localHandler struct {
outputFile *os.File // can be nil
}
func (h *localHandler) output(message string) {
msg_len := len(message)
if msg_len == 0 {
return
}
if h.outputFile != nil {
h.outputFile.WriteString(message + "\n")
}
}
func (h *localHandler) random() uint64 {
return rand.Uint64()
}
func (h *localHandler) notify(edge uint64) bool {
return false
}
func (h *localHandler) init_coverage(num_edges uint64, symbols string) uint64 {
return 0
}
func init() {
handler = init_in_antithesis()
if handler == nil {
// Otherwise fallback to the local handler.
handler = openLocalHandler()
}
}
// If `localOutputEnvVar` is set to a non-empty path, attempt to open that path and truncate the file
// to serve as the log file of the local handler.
// Otherwise, we don't have a log file, and logging is a no-op in the local handler.
func openLocalHandler() *localHandler {
path, is_set := os.LookupEnv(localOutputEnvVar)
if !is_set || len(path) == 0 {
return &localHandler{nil}
}
// Open the file R/W (create if needed and possible)
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
log.Printf("%s Failed to open path %s: %v", errorLogLinePrefix, path, err)
file = nil
} else if err = file.Truncate(0); err != nil {
log.Printf("%s Failed to truncate file at %s: %v", errorLogLinePrefix, path, err)
file = nil
}
return &localHandler{file}
}
@@ -1,12 +0,0 @@
package internal
// --------------------------------------------------------------------------------
// Versions
// --------------------------------------------------------------------------------
const SDK_Version = "0.7.0"
const Protocol_Version = "1.1.0"
// --------------------------------------------------------------------------------
// Environment Vars
// --------------------------------------------------------------------------------
const localOutputEnvVar = "ANTITHESIS_SDK_LOCAL_OUTPUT"
@@ -1,160 +0,0 @@
//go:build enable_antithesis_sdk && linux && amd64 && cgo
package internal
import (
"fmt"
"unsafe"
"os"
)
// --------------------------------------------------------------------------------
// To build and run an executable with this package
//
// CC=clang CGO_ENABLED=1 go run ./main.go
// --------------------------------------------------------------------------------
// \/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/
//
// The commented lines below, and the `import "C"` line which must directly follow
// the commented lines are used by CGO. They are load-bearing, and should not be
// changed without first understanding how CGO uses them.
//
// \/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/
// #cgo LDFLAGS: -ldl
//
// #include <dlfcn.h>
// #include <stdbool.h>
// #include <stdint.h>
// #include <stdlib.h>
//
// typedef void (*go_fuzz_json_data_fn)(const char *data, size_t size);
// void
// go_fuzz_json_data(void *f, const char *data, size_t size) {
// ((go_fuzz_json_data_fn)f)(data, size);
// }
//
// typedef void (*go_fuzz_flush_fn)(void);
// void
// go_fuzz_flush(void *f) {
// ((go_fuzz_flush_fn)f)();
// }
//
// typedef uint64_t (*go_fuzz_get_random_fn)(void);
// uint64_t
// go_fuzz_get_random(void *f) {
// return ((go_fuzz_get_random_fn)f)();
// }
//
// typedef bool (*go_notify_coverage_fn)(size_t);
// int
// go_notify_coverage(void *f, size_t edges) {
// bool b = ((go_notify_coverage_fn)f)(edges);
// return b ? 1 : 0;
// }
//
// typedef uint64_t (*go_init_coverage_fn)(size_t num_edges, const char *symbols);
// uint64_t
// go_init_coverage(void *f, size_t num_edges, const char *symbols) {
// return ((go_init_coverage_fn)f)(num_edges, symbols);
// }
//
import "C"
const (
defaultNativeLibraryPath = "/usr/lib/libvoidstar.so"
)
type voidstarHandler struct {
fuzzJsonData unsafe.Pointer
fuzzFlush unsafe.Pointer
fuzzGetRandom unsafe.Pointer
initCoverage unsafe.Pointer
notifyCoverage unsafe.Pointer
}
func (h *voidstarHandler) output(message string) {
msg_len := len(message)
if msg_len == 0 {
return
}
cstrMessage := C.CString(message)
defer C.free(unsafe.Pointer(cstrMessage))
C.go_fuzz_json_data(h.fuzzJsonData, cstrMessage, C.ulong(msg_len))
C.go_fuzz_flush(h.fuzzFlush)
}
func (h *voidstarHandler) random() uint64 {
return uint64(C.go_fuzz_get_random(h.fuzzGetRandom))
}
func (h *voidstarHandler) init_coverage(num_edge uint64, symbols string) uint64 {
cstrSymbols := C.CString(symbols)
defer C.free(unsafe.Pointer(cstrSymbols))
return uint64(C.go_init_coverage(h.initCoverage, C.ulong(num_edge), cstrSymbols))
}
func (h *voidstarHandler) notify(edge uint64) bool {
ival := int(C.go_notify_coverage(h.notifyCoverage, C.ulong(edge)))
return ival == 1
}
// Attempt to load libvoidstar and some symbols from `path`
func openSharedLib(path string) (*voidstarHandler, error) {
cstrPath := C.CString(path)
defer C.free(unsafe.Pointer(cstrPath))
dlError := func(message string) error {
return fmt.Errorf("%s: (%s)", message, C.GoString(C.dlerror()))
}
sharedLib := C.dlopen(cstrPath, C.int(C.RTLD_NOW))
if sharedLib == nil {
return nil, dlError("Can not load the Antithesis native library")
}
loadFunc := func(name string) (symbol unsafe.Pointer, err error) {
cstrName := C.CString(name)
defer C.free(unsafe.Pointer(cstrName))
if symbol = C.dlsym(sharedLib, cstrName); symbol == nil {
err = dlError(fmt.Sprintf("Can not access symbol %s", name))
}
return
}
fuzzJsonData, err := loadFunc("fuzz_json_data")
if err != nil {
return nil, err
}
fuzzFlush, err := loadFunc("fuzz_flush")
if err != nil {
return nil, err
}
fuzzGetRandom, err := loadFunc("fuzz_get_random")
if err != nil {
return nil, err
}
notifyCoverage, err := loadFunc("notify_coverage")
if err != nil {
return nil, err
}
initCoverage, err := loadFunc("init_coverage_module")
if err != nil {
return nil, err
}
return &voidstarHandler{fuzzJsonData, fuzzFlush, fuzzGetRandom, initCoverage, notifyCoverage}, nil
}
// If we have a file at `defaultNativeLibraryPath`, we load the shared library
// (and panic on any error encountered during load).
func init_in_antithesis() libHandler {
if _, err := os.Stat(defaultNativeLibraryPath); err == nil {
handler, err := openSharedLib(defaultNativeLibraryPath)
if err != nil {
panic(err)
}
return handler
}
return nil
}
@@ -1,7 +0,0 @@
//go:build enable_antithesis_sdk && (!linux || !amd64 || !cgo)
package internal
func init_in_antithesis() libHandler {
return nil
}
-202
View File
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-35
View File
@@ -1,35 +0,0 @@
# TPM 2.0 client library
## Tests
This library contains unit tests in `github.com/google/go-tpm/tpm2`, which just
tests that various encoding and error checking functions work correctly. It also
contains more comprehensive integration tests in
`github.com/google/go-tpm/tpm2/test`, which run actual commands on a TPM.
By default, these integration tests are run against the
[`go-tpm-tools`](https://github.com/google/go-tpm-tools)
simulator, which is baesed on the
[Microsoft Reference TPM2 code](https://github.com/microsoft/ms-tpm-20-ref). To
run both the unit and integration tests, run (in this directory)
```bash
go test . ./test
```
These integration tests can also be run against a real TPM device. This is
slightly more complex as the tests often need to be built as a normal user and
then executed as root. For example,
```bash
# Build the test binary without running it
go test -c github.com/google/go-tpm/tpm2/test
# Execute the test binary as root
sudo ./test.test --tpm-path=/dev/tpmrm0
```
On Linux, The `--tpm-path` causes the integration tests to be run against a
real TPM located at that path (usually `/dev/tpmrm0` or `/dev/tpm0`). On Windows, the story is similar, execept that
the `--use-tbs` flag is used instead.
Tip: if your TPM host is remote and you don't want to install Go on it, this
same two-step process can be used. The test binary can be copied to a remote
host and run without extra installation (as the test binary has very few
*runtime* dependancies).
-576
View File
@@ -1,576 +0,0 @@
// Copyright (c) 2018, Google LLC All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tpm2
import (
"crypto"
"crypto/elliptic"
"fmt"
"strings"
// Register the relevant hash implementations to prevent a runtime failure.
_ "crypto/sha1"
_ "crypto/sha256"
_ "crypto/sha512"
"github.com/google/go-tpm/tpmutil"
)
var hashInfo = []struct {
alg Algorithm
hash crypto.Hash
}{
{AlgSHA1, crypto.SHA1},
{AlgSHA256, crypto.SHA256},
{AlgSHA384, crypto.SHA384},
{AlgSHA512, crypto.SHA512},
{AlgSHA3_256, crypto.SHA3_256},
{AlgSHA3_384, crypto.SHA3_384},
{AlgSHA3_512, crypto.SHA3_512},
}
// MAX_DIGEST_BUFFER is the maximum size of []byte request or response fields.
// Typically used for chunking of big blobs of data (such as for hashing or
// encryption).
const maxDigestBuffer = 1024
// Algorithm represents a TPM_ALG_ID value.
type Algorithm uint16
// HashToAlgorithm looks up the TPM2 algorithm corresponding to the provided crypto.Hash
func HashToAlgorithm(hash crypto.Hash) (Algorithm, error) {
for _, info := range hashInfo {
if info.hash == hash {
return info.alg, nil
}
}
return AlgUnknown, fmt.Errorf("go hash algorithm #%d has no TPM2 algorithm", hash)
}
// IsNull returns true if a is AlgNull or zero (unset).
func (a Algorithm) IsNull() bool {
return a == AlgNull || a == AlgUnknown
}
// UsesCount returns true if a signature algorithm uses count value.
func (a Algorithm) UsesCount() bool {
return a == AlgECDAA
}
// UsesHash returns true if the algorithm requires the use of a hash.
func (a Algorithm) UsesHash() bool {
return a == AlgOAEP
}
// Hash returns a crypto.Hash based on the given TPM_ALG_ID.
// An error is returned if the given algorithm is not a hash algorithm or is not available.
func (a Algorithm) Hash() (crypto.Hash, error) {
for _, info := range hashInfo {
if info.alg == a {
if !info.hash.Available() {
return crypto.Hash(0), fmt.Errorf("go hash algorithm #%d not available", info.hash)
}
return info.hash, nil
}
}
return crypto.Hash(0), fmt.Errorf("hash algorithm not supported: 0x%x", a)
}
func (a Algorithm) String() string {
var s strings.Builder
var err error
switch a {
case AlgUnknown:
_, err = s.WriteString("AlgUnknown")
case AlgRSA:
_, err = s.WriteString("RSA")
case AlgSHA1:
_, err = s.WriteString("SHA1")
case AlgHMAC:
_, err = s.WriteString("HMAC")
case AlgAES:
_, err = s.WriteString("AES")
case AlgKeyedHash:
_, err = s.WriteString("KeyedHash")
case AlgXOR:
_, err = s.WriteString("XOR")
case AlgSHA256:
_, err = s.WriteString("SHA256")
case AlgSHA384:
_, err = s.WriteString("SHA384")
case AlgSHA512:
_, err = s.WriteString("SHA512")
case AlgNull:
_, err = s.WriteString("AlgNull")
case AlgRSASSA:
_, err = s.WriteString("RSASSA")
case AlgRSAES:
_, err = s.WriteString("RSAES")
case AlgRSAPSS:
_, err = s.WriteString("RSAPSS")
case AlgOAEP:
_, err = s.WriteString("OAEP")
case AlgECDSA:
_, err = s.WriteString("ECDSA")
case AlgECDH:
_, err = s.WriteString("ECDH")
case AlgECDAA:
_, err = s.WriteString("ECDAA")
case AlgKDF2:
_, err = s.WriteString("KDF2")
case AlgECC:
_, err = s.WriteString("ECC")
case AlgSymCipher:
_, err = s.WriteString("SymCipher")
case AlgSHA3_256:
_, err = s.WriteString("SHA3_256")
case AlgSHA3_384:
_, err = s.WriteString("SHA3_384")
case AlgSHA3_512:
_, err = s.WriteString("SHA3_512")
case AlgCTR:
_, err = s.WriteString("CTR")
case AlgOFB:
_, err = s.WriteString("OFB")
case AlgCBC:
_, err = s.WriteString("CBC")
case AlgCFB:
_, err = s.WriteString("CFB")
case AlgECB:
_, err = s.WriteString("ECB")
default:
return fmt.Sprintf("Alg?<%d>", int(a))
}
if err != nil {
return fmt.Sprintf("Writing to string builder failed: %v", err)
}
return s.String()
}
// Supported Algorithms.
const (
AlgUnknown Algorithm = 0x0000
AlgRSA Algorithm = 0x0001
AlgSHA1 Algorithm = 0x0004
AlgHMAC Algorithm = 0x0005
AlgAES Algorithm = 0x0006
AlgKeyedHash Algorithm = 0x0008
AlgXOR Algorithm = 0x000A
AlgSHA256 Algorithm = 0x000B
AlgSHA384 Algorithm = 0x000C
AlgSHA512 Algorithm = 0x000D
AlgNull Algorithm = 0x0010
AlgRSASSA Algorithm = 0x0014
AlgRSAES Algorithm = 0x0015
AlgRSAPSS Algorithm = 0x0016
AlgOAEP Algorithm = 0x0017
AlgECDSA Algorithm = 0x0018
AlgECDH Algorithm = 0x0019
AlgECDAA Algorithm = 0x001A
AlgKDF2 Algorithm = 0x0021
AlgECC Algorithm = 0x0023
AlgSymCipher Algorithm = 0x0025
AlgSHA3_256 Algorithm = 0x0027
AlgSHA3_384 Algorithm = 0x0028
AlgSHA3_512 Algorithm = 0x0029
AlgCTR Algorithm = 0x0040
AlgOFB Algorithm = 0x0041
AlgCBC Algorithm = 0x0042
AlgCFB Algorithm = 0x0043
AlgECB Algorithm = 0x0044
)
// HandleType defines a type of handle.
type HandleType uint8
// Supported handle types
const (
HandleTypePCR HandleType = 0x00
HandleTypeNVIndex HandleType = 0x01
HandleTypeHMACSession HandleType = 0x02
HandleTypeLoadedSession HandleType = 0x02
HandleTypePolicySession HandleType = 0x03
HandleTypeSavedSession HandleType = 0x03
HandleTypePermanent HandleType = 0x40
HandleTypeTransient HandleType = 0x80
HandleTypePersistent HandleType = 0x81
)
// SessionType defines the type of session created in StartAuthSession.
type SessionType uint8
// Supported session types.
const (
SessionHMAC SessionType = 0x00
SessionPolicy SessionType = 0x01
SessionTrial SessionType = 0x03
)
// SessionAttributes represents an attribute of a session.
type SessionAttributes byte
// Session Attributes (Structures 8.4 TPMA_SESSION)
const (
AttrContinueSession SessionAttributes = 1 << iota
AttrAuditExclusive
AttrAuditReset
_ // bit 3 reserved
_ // bit 4 reserved
AttrDecrypt
AttrEcrypt
AttrAudit
)
// EmptyAuth represents the empty authorization value.
var EmptyAuth []byte
// KeyProp is a bitmask used in Attributes field of key templates. Individual
// flags should be OR-ed to form a full mask.
type KeyProp uint32
// Key properties.
const (
FlagFixedTPM KeyProp = 0x00000002
FlagStClear KeyProp = 0x00000004
FlagFixedParent KeyProp = 0x00000010
FlagSensitiveDataOrigin KeyProp = 0x00000020
FlagUserWithAuth KeyProp = 0x00000040
FlagAdminWithPolicy KeyProp = 0x00000080
FlagNoDA KeyProp = 0x00000400
FlagRestricted KeyProp = 0x00010000
FlagDecrypt KeyProp = 0x00020000
FlagSign KeyProp = 0x00040000
FlagSealDefault = FlagFixedTPM | FlagFixedParent
FlagSignerDefault = FlagSign | FlagRestricted | FlagFixedTPM |
FlagFixedParent | FlagSensitiveDataOrigin | FlagUserWithAuth
FlagStorageDefault = FlagDecrypt | FlagRestricted | FlagFixedTPM |
FlagFixedParent | FlagSensitiveDataOrigin | FlagUserWithAuth
)
// TPMProp represents a Property Tag (TPM_PT) used with calls to GetCapability(CapabilityTPMProperties).
type TPMProp uint32
// TPM Capability Properties, see TPM 2.0 Spec, Rev 1.38, Table 23.
// Fixed TPM Properties (PT_FIXED)
const (
FamilyIndicator TPMProp = 0x100 + iota
SpecLevel
SpecRevision
SpecDayOfYear
SpecYear
Manufacturer
VendorString1
VendorString2
VendorString3
VendorString4
VendorTPMType
FirmwareVersion1
FirmwareVersion2
InputMaxBufferSize
TransientObjectsMin
PersistentObjectsMin
LoadedObjectsMin
ActiveSessionsMax
PCRCount
PCRSelectMin
ContextGapMax
_ // (PT_FIXED + 21) is skipped
NVCountersMax
NVIndexMax
MemoryMethod
ClockUpdate
ContextHash
ContextSym
ContextSymSize
OrderlyCount
CommandMaxSize
ResponseMaxSize
DigestMaxSize
ObjectContextMaxSize
SessionContextMaxSize
PSFamilyIndicator
PSSpecLevel
PSSpecRevision
PSSpecDayOfYear
PSSpecYear
SplitSigningMax
TotalCommands
LibraryCommands
VendorCommands
NVMaxBufferSize
TPMModes
CapabilityMaxBufferSize
)
// Variable TPM Properties (PT_VAR)
const (
TPMAPermanent TPMProp = 0x200 + iota
TPMAStartupClear
HRNVIndex
HRLoaded
HRLoadedAvail
HRActive
HRActiveAvail
HRTransientAvail
CurrentPersistent
AvailPersistent
NVCounters
NVCountersAvail
AlgorithmSet
LoadedCurves
LockoutCounter
MaxAuthFail
LockoutInterval
LockoutRecovery
NVWriteRecovery
AuditCounter0
AuditCounter1
)
// Allowed ranges of different kinds of Handles (TPM_HANDLE)
// These constants have type TPMProp for backwards compatibility.
const (
PCRFirst TPMProp = 0x00000000
HMACSessionFirst TPMProp = 0x02000000
LoadedSessionFirst TPMProp = 0x02000000
PolicySessionFirst TPMProp = 0x03000000
ActiveSessionFirst TPMProp = 0x03000000
TransientFirst TPMProp = 0x80000000
PersistentFirst TPMProp = 0x81000000
PersistentLast TPMProp = 0x81FFFFFF
PlatformPersistent TPMProp = 0x81800000
NVIndexFirst TPMProp = 0x01000000
NVIndexLast TPMProp = 0x01FFFFFF
PermanentFirst TPMProp = 0x40000000
PermanentLast TPMProp = 0x4000010F
)
// Reserved Handles.
const (
HandleOwner tpmutil.Handle = 0x40000001 + iota
HandleRevoke
HandleTransport
HandleOperator
HandleAdmin
HandleEK
HandleNull
HandleUnassigned
HandlePasswordSession
HandleLockout
HandleEndorsement
HandlePlatform
)
// Capability identifies some TPM property or state type.
type Capability uint32
// TPM Capabilities.
const (
CapabilityAlgs Capability = iota
CapabilityHandles
CapabilityCommands
CapabilityPPCommands
CapabilityAuditCommands
CapabilityPCRs
CapabilityTPMProperties
CapabilityPCRProperties
CapabilityECCCurves
CapabilityAuthPolicies
)
// TPM Structure Tags. Tags are used to disambiguate structures, similar to Alg
// values: tag value defines what kind of data lives in a nested field.
const (
TagNull tpmutil.Tag = 0x8000
TagNoSessions tpmutil.Tag = 0x8001
TagSessions tpmutil.Tag = 0x8002
TagAttestCertify tpmutil.Tag = 0x8017
TagAttestQuote tpmutil.Tag = 0x8018
TagAttestCreation tpmutil.Tag = 0x801a
TagAuthSecret tpmutil.Tag = 0x8023
TagHashCheck tpmutil.Tag = 0x8024
TagAuthSigned tpmutil.Tag = 0x8025
)
// StartupType instructs the TPM on how to handle its state during Shutdown or
// Startup.
type StartupType uint16
// Startup types
const (
StartupClear StartupType = iota
StartupState
)
// EllipticCurve identifies specific EC curves.
type EllipticCurve uint16
// ECC curves supported by TPM 2.0 spec.
const (
CurveNISTP192 = EllipticCurve(iota + 1)
CurveNISTP224
CurveNISTP256
CurveNISTP384
CurveNISTP521
CurveBNP256 = EllipticCurve(iota + 10)
CurveBNP638
CurveSM2P256 = EllipticCurve(0x0020)
)
var toGoCurve = map[EllipticCurve]elliptic.Curve{
CurveNISTP224: elliptic.P224(),
CurveNISTP256: elliptic.P256(),
CurveNISTP384: elliptic.P384(),
CurveNISTP521: elliptic.P521(),
}
// Supported TPM operations.
const (
CmdNVUndefineSpaceSpecial tpmutil.Command = 0x0000011F
CmdEvictControl tpmutil.Command = 0x00000120
CmdUndefineSpace tpmutil.Command = 0x00000122
CmdClear tpmutil.Command = 0x00000126
CmdHierarchyChangeAuth tpmutil.Command = 0x00000129
CmdDefineSpace tpmutil.Command = 0x0000012A
CmdPCRAllocate tpmutil.Command = 0x0000012B
CmdCreatePrimary tpmutil.Command = 0x00000131
CmdIncrementNVCounter tpmutil.Command = 0x00000134
CmdWriteNV tpmutil.Command = 0x00000137
CmdWriteLockNV tpmutil.Command = 0x00000138
CmdDictionaryAttackLockReset tpmutil.Command = 0x00000139
CmdDictionaryAttackParameters tpmutil.Command = 0x0000013A
CmdPCREvent tpmutil.Command = 0x0000013C
CmdPCRReset tpmutil.Command = 0x0000013D
CmdSequenceComplete tpmutil.Command = 0x0000013E
CmdStartup tpmutil.Command = 0x00000144
CmdShutdown tpmutil.Command = 0x00000145
CmdActivateCredential tpmutil.Command = 0x00000147
CmdCertify tpmutil.Command = 0x00000148
CmdCertifyCreation tpmutil.Command = 0x0000014A
CmdReadNV tpmutil.Command = 0x0000014E
CmdReadLockNV tpmutil.Command = 0x0000014F
CmdPolicySecret tpmutil.Command = 0x00000151
CmdCreate tpmutil.Command = 0x00000153
CmdECDHZGen tpmutil.Command = 0x00000154
CmdImport tpmutil.Command = 0x00000156
CmdLoad tpmutil.Command = 0x00000157
CmdQuote tpmutil.Command = 0x00000158
CmdRSADecrypt tpmutil.Command = 0x00000159
CmdSequenceUpdate tpmutil.Command = 0x0000015C
CmdSign tpmutil.Command = 0x0000015D
CmdUnseal tpmutil.Command = 0x0000015E
CmdPolicySigned tpmutil.Command = 0x00000160
CmdContextLoad tpmutil.Command = 0x00000161
CmdContextSave tpmutil.Command = 0x00000162
CmdECDHKeyGen tpmutil.Command = 0x00000163
CmdEncryptDecrypt tpmutil.Command = 0x00000164
CmdFlushContext tpmutil.Command = 0x00000165
CmdLoadExternal tpmutil.Command = 0x00000167
CmdMakeCredential tpmutil.Command = 0x00000168
CmdReadPublicNV tpmutil.Command = 0x00000169
CmdPolicyCommandCode tpmutil.Command = 0x0000016C
CmdPolicyOr tpmutil.Command = 0x00000171
CmdReadPublic tpmutil.Command = 0x00000173
CmdRSAEncrypt tpmutil.Command = 0x00000174
CmdStartAuthSession tpmutil.Command = 0x00000176
CmdGetCapability tpmutil.Command = 0x0000017A
CmdGetRandom tpmutil.Command = 0x0000017B
CmdHash tpmutil.Command = 0x0000017D
CmdPCRRead tpmutil.Command = 0x0000017E
CmdPolicyPCR tpmutil.Command = 0x0000017F
CmdReadClock tpmutil.Command = 0x00000181
CmdPCRExtend tpmutil.Command = 0x00000182
CmdEventSequenceComplete tpmutil.Command = 0x00000185
CmdHashSequenceStart tpmutil.Command = 0x00000186
CmdPolicyGetDigest tpmutil.Command = 0x00000189
CmdPolicyPassword tpmutil.Command = 0x0000018C
CmdEncryptDecrypt2 tpmutil.Command = 0x00000193
)
// Regular TPM 2.0 devices use 24-bit mask (3 bytes) for PCR selection.
const sizeOfPCRSelect = 3
const defaultRSAExponent = 1<<16 + 1
// NVAttr is a bitmask used in Attributes field of NV indexes. Individual
// flags should be OR-ed to form a full mask.
type NVAttr uint32
// NV Attributes
const (
AttrPPWrite NVAttr = 0x00000001
AttrOwnerWrite NVAttr = 0x00000002
AttrAuthWrite NVAttr = 0x00000004
AttrPolicyWrite NVAttr = 0x00000008
AttrPolicyDelete NVAttr = 0x00000400
AttrWriteLocked NVAttr = 0x00000800
AttrWriteAll NVAttr = 0x00001000
AttrWriteDefine NVAttr = 0x00002000
AttrWriteSTClear NVAttr = 0x00004000
AttrGlobalLock NVAttr = 0x00008000
AttrPPRead NVAttr = 0x00010000
AttrOwnerRead NVAttr = 0x00020000
AttrAuthRead NVAttr = 0x00040000
AttrPolicyRead NVAttr = 0x00080000
AttrNoDA NVAttr = 0x02000000
AttrOrderly NVAttr = 0x04000000
AttrClearSTClear NVAttr = 0x08000000
AttrReadLocked NVAttr = 0x10000000
AttrWritten NVAttr = 0x20000000
AttrPlatformCreate NVAttr = 0x40000000
AttrReadSTClear NVAttr = 0x80000000
)
var permMap = map[NVAttr]string{
AttrPPWrite: "PPWrite",
AttrOwnerWrite: "OwnerWrite",
AttrAuthWrite: "AuthWrite",
AttrPolicyWrite: "PolicyWrite",
AttrPolicyDelete: "PolicyDelete",
AttrWriteLocked: "WriteLocked",
AttrWriteAll: "WriteAll",
AttrWriteDefine: "WriteDefine",
AttrWriteSTClear: "WriteSTClear",
AttrGlobalLock: "GlobalLock",
AttrPPRead: "PPRead",
AttrOwnerRead: "OwnerRead",
AttrAuthRead: "AuthRead",
AttrPolicyRead: "PolicyRead",
AttrNoDA: "No Do",
AttrOrderly: "Oderly",
AttrClearSTClear: "ClearSTClear",
AttrReadLocked: "ReadLocked",
AttrWritten: "Writte",
AttrPlatformCreate: "PlatformCreate",
AttrReadSTClear: "ReadSTClear",
}
// String returns a textual representation of the set of NVAttr
func (p NVAttr) String() string {
var retString strings.Builder
for iterator, item := range permMap {
if (p & iterator) != 0 {
retString.WriteString(item + " + ")
}
}
if retString.String() == "" {
return "Permission/s not found"
}
return strings.TrimSuffix(retString.String(), " + ")
}
-362
View File
@@ -1,362 +0,0 @@
package tpm2
import (
"fmt"
"github.com/google/go-tpm/tpmutil"
)
type (
// RCFmt0 holds Format 0 error codes
RCFmt0 uint8
// RCFmt1 holds Format 1 error codes
RCFmt1 uint8
// RCWarn holds error codes used in warnings
RCWarn uint8
// RCIndex is used to reference arguments, handles and sessions in errors
RCIndex uint8
)
// Format 0 error codes.
const (
RCInitialize RCFmt0 = 0x00
RCFailure RCFmt0 = 0x01
RCSequence RCFmt0 = 0x03
RCPrivate RCFmt0 = 0x0B
RCHMAC RCFmt0 = 0x19
RCDisabled RCFmt0 = 0x20
RCExclusive RCFmt0 = 0x21
RCAuthType RCFmt0 = 0x24
RCAuthMissing RCFmt0 = 0x25
RCPolicy RCFmt0 = 0x26
RCPCR RCFmt0 = 0x27
RCPCRChanged RCFmt0 = 0x28
RCUpgrade RCFmt0 = 0x2D
RCTooManyContexts RCFmt0 = 0x2E
RCAuthUnavailable RCFmt0 = 0x2F
RCReboot RCFmt0 = 0x30
RCUnbalanced RCFmt0 = 0x31
RCCommandSize RCFmt0 = 0x42
RCCommandCode RCFmt0 = 0x43
RCAuthSize RCFmt0 = 0x44
RCAuthContext RCFmt0 = 0x45
RCNVRange RCFmt0 = 0x46
RCNVSize RCFmt0 = 0x47
RCNVLocked RCFmt0 = 0x48
RCNVAuthorization RCFmt0 = 0x49
RCNVUninitialized RCFmt0 = 0x4A
RCNVSpace RCFmt0 = 0x4B
RCNVDefined RCFmt0 = 0x4C
RCBadContext RCFmt0 = 0x50
RCCPHash RCFmt0 = 0x51
RCParent RCFmt0 = 0x52
RCNeedsTest RCFmt0 = 0x53
RCNoResult RCFmt0 = 0x54
RCSensitive RCFmt0 = 0x55
)
var fmt0Msg = map[RCFmt0]string{
RCInitialize: "TPM not initialized by TPM2_Startup or already initialized",
RCFailure: "commands not being accepted because of a TPM failure",
RCSequence: "improper use of a sequence handle",
RCPrivate: "not currently used",
RCHMAC: "not currently used",
RCDisabled: "the command is disabled",
RCExclusive: "command failed because audit sequence required exclusivity",
RCAuthType: "authorization handle is not correct for command",
RCAuthMissing: "5 command requires an authorization session for handle and it is not present",
RCPolicy: "policy failure in math operation or an invalid authPolicy value",
RCPCR: "PCR check fail",
RCPCRChanged: "PCR have changed since checked",
RCUpgrade: "TPM is in field upgrade mode unless called via TPM2_FieldUpgradeData(), then it is not in field upgrade mode",
RCTooManyContexts: "context ID counter is at maximum",
RCAuthUnavailable: "authValue or authPolicy is not available for selected entity",
RCReboot: "a _TPM_Init and Startup(CLEAR) is required before the TPM can resume operation",
RCUnbalanced: "the protection algorithms (hash and symmetric) are not reasonably balanced; the digest size of the hash must be larger than the key size of the symmetric algorithm",
RCCommandSize: "command commandSize value is inconsistent with contents of the command buffer; either the size is not the same as the octets loaded by the hardware interface layer or the value is not large enough to hold a command header",
RCCommandCode: "command code not supported",
RCAuthSize: "the value of authorizationSize is out of range or the number of octets in the Authorization Area is greater than required",
RCAuthContext: "use of an authorization session with a context command or another command that cannot have an authorization session",
RCNVRange: "NV offset+size is out of range",
RCNVSize: "Requested allocation size is larger than allowed",
RCNVLocked: "NV access locked",
RCNVAuthorization: "NV access authorization fails in command actions",
RCNVUninitialized: "an NV Index is used before being initialized or the state saved by TPM2_Shutdown(STATE) could not be restored",
RCNVSpace: "insufficient space for NV allocation",
RCNVDefined: "NV Index or persistent object already defined",
RCBadContext: "context in TPM2_ContextLoad() is not valid",
RCCPHash: "cpHash value already set or not correct for use",
RCParent: "handle for parent is not a valid parent",
RCNeedsTest: "some function needs testing",
RCNoResult: "returned when an internal function cannot process a request due to an unspecified problem; this code is usually related to invalid parameters that are not properly filtered by the input unmarshaling code",
RCSensitive: "the sensitive area did not unmarshal correctly after decryption",
}
// Format 1 error codes.
const (
RCAsymmetric = 0x01
RCAttributes = 0x02
RCHash = 0x03
RCValue = 0x04
RCHierarchy = 0x05
RCKeySize = 0x07
RCMGF = 0x08
RCMode = 0x09
RCType = 0x0A
RCHandle = 0x0B
RCKDF = 0x0C
RCRange = 0x0D
RCAuthFail = 0x0E
RCNonce = 0x0F
RCPP = 0x10
RCScheme = 0x12
RCSize = 0x15
RCSymmetric = 0x16
RCTag = 0x17
RCSelector = 0x18
RCInsufficient = 0x1A
RCSignature = 0x1B
RCKey = 0x1C
RCPolicyFail = 0x1D
RCIntegrity = 0x1F
RCTicket = 0x20
RCReservedBits = 0x21
RCBadAuth = 0x22
RCExpired = 0x23
RCPolicyCC = 0x24
RCBinding = 0x25
RCCurve = 0x26
RCECCPoint = 0x27
)
var fmt1Msg = map[RCFmt1]string{
RCAsymmetric: "asymmetric algorithm not supported or not correct",
RCAttributes: "inconsistent attributes",
RCHash: "hash algorithm not supported or not appropriate",
RCValue: "value is out of range or is not correct for the context",
RCHierarchy: "hierarchy is not enabled or is not correct for the use",
RCKeySize: "key size is not supported",
RCMGF: "mask generation function not supported",
RCMode: "mode of operation not supported",
RCType: "the type of the value is not appropriate for the use",
RCHandle: "the handle is not correct for the use",
RCKDF: "unsupported key derivation function or function not appropriate for use",
RCRange: "value was out of allowed range",
RCAuthFail: "the authorization HMAC check failed and DA counter incremented",
RCNonce: "invalid nonce size or nonce value mismatch",
RCPP: "authorization requires assertion of PP",
RCScheme: "unsupported or incompatible scheme",
RCSize: "structure is the wrong size",
RCSymmetric: "unsupported symmetric algorithm or key size, or not appropriate for instance",
RCTag: "incorrect structure tag",
RCSelector: "union selector is incorrect",
RCInsufficient: "the TPM was unable to unmarshal a value because there were not enough octets in the input buffer",
RCSignature: "the signature is not valid",
RCKey: "key fields are not compatible with the selected use",
RCPolicyFail: "a policy check failed",
RCIntegrity: "integrity check failed",
RCTicket: "invalid ticket",
RCReservedBits: "reserved bits not set to zero as required",
RCBadAuth: "authorization failure without DA implications",
RCExpired: "the policy has expired",
RCPolicyCC: "the commandCode in the policy is not the commandCode of the command or the command code in a policy command references a command that is not implemented",
RCBinding: "public and sensitive portions of an object are not cryptographically bound",
RCCurve: "curve not supported",
RCECCPoint: "point is not on the required curve",
}
// Warning codes.
const (
RCContextGap RCWarn = 0x01
RCObjectMemory RCWarn = 0x02
RCSessionMemory RCWarn = 0x03
RCMemory RCWarn = 0x04
RCSessionHandles RCWarn = 0x05
RCObjectHandles RCWarn = 0x06
RCLocality RCWarn = 0x07
RCYielded RCWarn = 0x08
RCCanceled RCWarn = 0x09
RCTesting RCWarn = 0x0A
RCReferenceH0 RCWarn = 0x10
RCReferenceH1 RCWarn = 0x11
RCReferenceH2 RCWarn = 0x12
RCReferenceH3 RCWarn = 0x13
RCReferenceH4 RCWarn = 0x14
RCReferenceH5 RCWarn = 0x15
RCReferenceH6 RCWarn = 0x16
RCReferenceS0 RCWarn = 0x18
RCReferenceS1 RCWarn = 0x19
RCReferenceS2 RCWarn = 0x1A
RCReferenceS3 RCWarn = 0x1B
RCReferenceS4 RCWarn = 0x1C
RCReferenceS5 RCWarn = 0x1D
RCReferenceS6 RCWarn = 0x1E
RCNVRate RCWarn = 0x20
RCLockout RCWarn = 0x21
RCRetry RCWarn = 0x22
RCNVUnavailable RCWarn = 0x23
)
var warnMsg = map[RCWarn]string{
RCContextGap: "gap for context ID is too large",
RCObjectMemory: "out of memory for object contexts",
RCSessionMemory: "out of memory for session contexts",
RCMemory: "out of shared object/session memory or need space for internal operations",
RCSessionHandles: "out of session handles",
RCObjectHandles: "out of object handles",
RCLocality: "bad locality",
RCYielded: "the TPM has suspended operation on the command; forward progress was made and the command may be retried",
RCCanceled: "the command was canceled",
RCTesting: "TPM is performing self-tests",
RCReferenceH0: "the 1st handle in the handle area references a transient object or session that is not loaded",
RCReferenceH1: "the 2nd handle in the handle area references a transient object or session that is not loaded",
RCReferenceH2: "the 3rd handle in the handle area references a transient object or session that is not loaded",
RCReferenceH3: "the 4th handle in the handle area references a transient object or session that is not loaded",
RCReferenceH4: "the 5th handle in the handle area references a transient object or session that is not loaded",
RCReferenceH5: "the 6th handle in the handle area references a transient object or session that is not loaded",
RCReferenceH6: "the 7th handle in the handle area references a transient object or session that is not loaded",
RCReferenceS0: "the 1st authorization session handle references a session that is not loaded",
RCReferenceS1: "the 2nd authorization session handle references a session that is not loaded",
RCReferenceS2: "the 3rd authorization session handle references a session that is not loaded",
RCReferenceS3: "the 4th authorization session handle references a session that is not loaded",
RCReferenceS4: "the 5th authorization session handle references a session that is not loaded",
RCReferenceS5: "the 6th authorization session handle references a session that is not loaded",
RCReferenceS6: "the 7th authorization session handle references a session that is not loaded",
RCNVRate: "the TPM is rate-limiting accesses to prevent wearout of NV",
RCLockout: "authorizations for objects subject to DA protection are not allowed at this time because the TPM is in DA lockout mode",
RCRetry: "the TPM was not able to start the command",
RCNVUnavailable: "the command may require writing of NV and NV is not current accessible",
}
// Indexes for arguments, handles and sessions.
const (
RC1 RCIndex = iota + 1
RC2
RC3
RC4
RC5
RC6
RC7
RC8
RC9
RCA
RCB
RCC
RCD
RCE
RCF
)
const unknownCode = "unknown error code"
// Error is returned for all Format 0 errors from the TPM. It is used for general
// errors not specific to a parameter, handle or session.
type Error struct {
Code RCFmt0
}
func (e Error) Error() string {
msg := fmt0Msg[e.Code]
if msg == "" {
msg = unknownCode
}
return fmt.Sprintf("error code 0x%x : %s", e.Code, msg)
}
// VendorError represents a vendor-specific error response. These types of responses
// are not decoded and Code contains the complete response code.
type VendorError struct {
Code uint32
}
func (e VendorError) Error() string {
return fmt.Sprintf("vendor error code 0x%x", e.Code)
}
// Warning is typically used to report transient errors.
type Warning struct {
Code RCWarn
}
func (w Warning) Error() string {
msg := warnMsg[w.Code]
if msg == "" {
msg = unknownCode
}
return fmt.Sprintf("warning code 0x%x : %s", w.Code, msg)
}
// ParameterError describes an error related to a parameter, and the parameter number.
type ParameterError struct {
Code RCFmt1
Parameter RCIndex
}
func (e ParameterError) Error() string {
msg := fmt1Msg[e.Code]
if msg == "" {
msg = unknownCode
}
return fmt.Sprintf("parameter %d, error code 0x%x : %s", e.Parameter, e.Code, msg)
}
// HandleError describes an error related to a handle, and the handle number.
type HandleError struct {
Code RCFmt1
Handle RCIndex
}
func (e HandleError) Error() string {
msg := fmt1Msg[e.Code]
if msg == "" {
msg = unknownCode
}
return fmt.Sprintf("handle %d, error code 0x%x : %s", e.Handle, e.Code, msg)
}
// SessionError describes an error related to a session, and the session number.
type SessionError struct {
Code RCFmt1
Session RCIndex
}
func (e SessionError) Error() string {
msg := fmt1Msg[e.Code]
if msg == "" {
msg = unknownCode
}
return fmt.Sprintf("session %d, error code 0x%x : %s", e.Session, e.Code, msg)
}
// Decode a TPM2 response code and return the appropriate error. Logic
// according to the "Response Code Evaluation" chart in Part 1 of the TPM 2.0
// spec.
func decodeResponse(code tpmutil.ResponseCode) error {
if code == tpmutil.RCSuccess {
return nil
}
if code&0x180 == 0 { // Bits 7:8 == 0 is a TPM1 error
return fmt.Errorf("response status 0x%x", code)
}
if code&0x80 == 0 { // Bit 7 unset
if code&0x400 > 0 { // Bit 10 set, vendor specific code
return VendorError{uint32(code)}
}
if code&0x800 > 0 { // Bit 11 set, warning with code in bit 0:6
return Warning{RCWarn(code & 0x7f)}
}
// error with code in bit 0:6
return Error{RCFmt0(code & 0x7f)}
}
if code&0x40 > 0 { // Bit 6 set, code in 0:5, parameter number in 8:11
return ParameterError{RCFmt1(code & 0x3f), RCIndex((code & 0xf00) >> 8)}
}
if code&0x800 == 0 { // Bit 11 unset, code in 0:5, handle in 8:10
return HandleError{RCFmt1(code & 0x3f), RCIndex((code & 0x700) >> 8)}
}
// Code in 0:5, Session in 8:10
return SessionError{RCFmt1(code & 0x3f), RCIndex((code & 0x700) >> 8)}
}
-116
View File
@@ -1,116 +0,0 @@
// Copyright (c) 2018, Google LLC All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tpm2
import (
"crypto"
"crypto/hmac"
"encoding/binary"
"hash"
)
// KDFa implements TPM 2.0's default key derivation function, as defined in
// section 11.4.9.2 of the TPM revision 2 specification part 1.
// See: https://trustedcomputinggroup.org/resource/tpm-library-specification/
// The key & label parameters must not be zero length.
// The label parameter is a non-null-terminated string.
// The contextU & contextV parameters are optional.
// Deprecated: Use KDFaHash.
func KDFa(hashAlg Algorithm, key []byte, label string, contextU, contextV []byte, bits int) ([]byte, error) {
h, err := hashAlg.Hash()
if err != nil {
return nil, err
}
return KDFaHash(h, key, label, contextU, contextV, bits), nil
}
// KDFe implements TPM 2.0's ECDH key derivation function, as defined in
// section 11.4.9.3 of the TPM revision 2 specification part 1.
// See: https://trustedcomputinggroup.org/resource/tpm-library-specification/
// The z parameter is the x coordinate of one party's private ECC key multiplied
// by the other party's public ECC point.
// The use parameter is a non-null-terminated string.
// The partyUInfo and partyVInfo are the x coordinates of the initiator's and
// Deprecated: Use KDFeHash.
func KDFe(hashAlg Algorithm, z []byte, use string, partyUInfo, partyVInfo []byte, bits int) ([]byte, error) {
h, err := hashAlg.Hash()
if err != nil {
return nil, err
}
return KDFeHash(h, z, use, partyUInfo, partyVInfo, bits), nil
}
// KDFaHash implements TPM 2.0's default key derivation function, as defined in
// section 11.4.9.2 of the TPM revision 2 specification part 1.
// See: https://trustedcomputinggroup.org/resource/tpm-library-specification/
// The key & label parameters must not be zero length.
// The label parameter is a non-null-terminated string.
// The contextU & contextV parameters are optional.
func KDFaHash(h crypto.Hash, key []byte, label string, contextU, contextV []byte, bits int) []byte {
mac := hmac.New(h.New, key)
out := kdf(mac, bits, func() {
mac.Write([]byte(label))
mac.Write([]byte{0}) // Terminating null character for C-string.
mac.Write(contextU)
mac.Write(contextV)
binary.Write(mac, binary.BigEndian, uint32(bits))
})
return out
}
// KDFeHash implements TPM 2.0's ECDH key derivation function, as defined in
// section 11.4.9.3 of the TPM revision 2 specification part 1.
// See: https://trustedcomputinggroup.org/resource/tpm-library-specification/
// The z parameter is the x coordinate of one party's private ECC key multiplied
// by the other party's public ECC point.
// The use parameter is a non-null-terminated string.
// The partyUInfo and partyVInfo are the x coordinates of the initiator's and
// the responder's ECC points, respectively.
func KDFeHash(h crypto.Hash, z []byte, use string, partyUInfo, partyVInfo []byte, bits int) []byte {
hash := h.New()
out := kdf(hash, bits, func() {
hash.Write(z)
hash.Write([]byte(use))
hash.Write([]byte{0}) // Terminating null character for C-string.
hash.Write(partyUInfo)
hash.Write(partyVInfo)
})
return out
}
func kdf(h hash.Hash, bits int, update func()) []byte {
bytes := (bits + 7) / 8
out := []byte{}
for counter := 1; len(out) < bytes; counter++ {
h.Reset()
binary.Write(h, binary.BigEndian, uint32(counter))
update()
out = h.Sum(out)
}
// out's length is a multiple of hash size, so there will be excess
// bytes if bytes isn't a multiple of hash size.
out = out[:bytes]
// As mentioned in the KDFa and KDFe specs mentioned above,
// the unused bits of the most significant octet are masked off.
if maskBits := uint8(bits % 8); maskBits > 0 {
out[0] &= (1 << maskBits) - 1
}
return out
}
-57
View File
@@ -1,57 +0,0 @@
//go:build !windows
// Copyright (c) 2019, Google LLC All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tpm2
import (
"errors"
"fmt"
"io"
"os"
"github.com/google/go-tpm/tpmutil"
)
// OpenTPM opens a channel to the TPM at the given path. If the file is a
// device, then it treats it like a normal TPM device, and if the file is a
// Unix domain socket, then it opens a connection to the socket.
//
// This function may also be invoked with no paths, as tpm2.OpenTPM(). In this
// case, the default paths on Linux (/dev/tpmrm0 then /dev/tpm0), will be used.
func OpenTPM(path ...string) (tpm io.ReadWriteCloser, err error) {
switch len(path) {
case 0:
tpm, err = tpmutil.OpenTPM("/dev/tpmrm0")
if errors.Is(err, os.ErrNotExist) {
tpm, err = tpmutil.OpenTPM("/dev/tpm0")
}
case 1:
tpm, err = tpmutil.OpenTPM(path[0])
default:
return nil, errors.New("cannot specify multiple paths to tpm2.OpenTPM")
}
if err != nil {
return nil, err
}
// Make sure this is a TPM 2.0
_, err = GetManufacturer(tpm)
if err != nil {
tpm.Close()
return nil, fmt.Errorf("open %s: device is not a TPM 2.0", path)
}
return tpm, nil
}
-39
View File
@@ -1,39 +0,0 @@
//go:build windows
// Copyright (c) 2018, Google LLC All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tpm2
import (
"fmt"
"io"
"github.com/google/go-tpm/tpmutil"
"github.com/google/go-tpm/tpmutil/tbs"
)
// OpenTPM opens a channel to the TPM.
func OpenTPM() (io.ReadWriteCloser, error) {
info, err := tbs.GetDeviceInfo()
if err != nil {
return nil, err
}
if info.TPMVersion != tbs.TPMVersion20 {
return nil, fmt.Errorf("openTPM: device is not a TPM 2.0")
}
return tpmutil.OpenTPM()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-211
View File
@@ -1,211 +0,0 @@
// Copyright (c) 2018, Google LLC All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tpmutil
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"reflect"
)
var (
selfMarshalerType = reflect.TypeOf((*SelfMarshaler)(nil)).Elem()
handlesAreaType = reflect.TypeOf((*[]Handle)(nil))
)
// packWithHeader takes a header and a sequence of elements that are either of
// fixed length or slices of fixed-length types and packs them into a single
// byte array using binary.Write. It updates the CommandHeader to have the right
// length.
func packWithHeader(ch commandHeader, cmd ...interface{}) ([]byte, error) {
hdrSize := binary.Size(ch)
body, err := Pack(cmd...)
if err != nil {
return nil, fmt.Errorf("couldn't pack message body: %v", err)
}
bodySize := len(body)
ch.Size = uint32(hdrSize + bodySize)
header, err := Pack(ch)
if err != nil {
return nil, fmt.Errorf("couldn't pack message header: %v", err)
}
return append(header, body...), nil
}
// Pack encodes a set of elements into a single byte array, using
// encoding/binary. This means that all the elements must be encodeable
// according to the rules of encoding/binary.
//
// It has one difference from encoding/binary: it encodes byte slices with a
// prepended length, to match how the TPM encodes variable-length arrays. If
// you wish to add a byte slice without length prefix, use RawBytes.
func Pack(elts ...interface{}) ([]byte, error) {
buf := new(bytes.Buffer)
if err := packType(buf, elts...); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// tryMarshal attempts to use a TPMMarshal() method defined on the type
// to pack v into buf. True is returned if the method exists and the
// marshal was attempted.
func tryMarshal(buf io.Writer, v reflect.Value) (bool, error) {
t := v.Type()
if t.Implements(selfMarshalerType) {
if v.Kind() == reflect.Ptr && v.IsNil() {
return true, fmt.Errorf("cannot call TPMMarshal on a nil pointer of type %T", v)
}
return true, v.Interface().(SelfMarshaler).TPMMarshal(buf)
}
// We might have a non-pointer struct field, but we dont have a
// pointer with which to implement the interface.
// If the pointer of the type implements the interface, we should be
// able to construct a value to call TPMMarshal() with.
// TODO(awly): Try and avoid blowing away private data by using Addr() instead of Set()
if reflect.PtrTo(t).Implements(selfMarshalerType) {
tmp := reflect.New(t)
tmp.Elem().Set(v)
return true, tmp.Interface().(SelfMarshaler).TPMMarshal(buf)
}
return false, nil
}
func packValue(buf io.Writer, v reflect.Value) error {
if v.Type() == handlesAreaType {
v = v.Convert(reflect.TypeOf((*handleList)(nil)))
}
if canMarshal, err := tryMarshal(buf, v); canMarshal {
return err
}
switch v.Kind() {
case reflect.Ptr:
if v.IsNil() {
return fmt.Errorf("cannot pack nil %s", v.Type().String())
}
return packValue(buf, v.Elem())
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
f := v.Field(i)
if err := packValue(buf, f); err != nil {
return err
}
}
default:
return binary.Write(buf, binary.BigEndian, v.Interface())
}
return nil
}
func packType(buf io.Writer, elts ...interface{}) error {
for _, e := range elts {
if err := packValue(buf, reflect.ValueOf(e)); err != nil {
return err
}
}
return nil
}
// tryUnmarshal attempts to use TPMUnmarshal() to perform the
// unpack, if the given value implements SelfMarshaler.
// True is returned if v implements SelfMarshaler & TPMUnmarshal
// was called, along with an error returned from TPMUnmarshal.
func tryUnmarshal(buf io.Reader, v reflect.Value) (bool, error) {
t := v.Type()
if t.Implements(selfMarshalerType) {
if v.Kind() == reflect.Ptr && v.IsNil() {
return true, fmt.Errorf("cannot call TPMUnmarshal on a nil pointer")
}
return true, v.Interface().(SelfMarshaler).TPMUnmarshal(buf)
}
// We might have a non-pointer struct field, which is addressable,
// If the pointer of the type implements the interface, and the
// value is addressable, we should be able to call TPMUnmarshal().
if v.CanAddr() && reflect.PtrTo(t).Implements(selfMarshalerType) {
return true, v.Addr().Interface().(SelfMarshaler).TPMUnmarshal(buf)
}
return false, nil
}
// Unpack is a convenience wrapper around UnpackBuf. Unpack returns the number
// of bytes read from b to fill elts and error, if any.
func Unpack(b []byte, elts ...interface{}) (int, error) {
buf := bytes.NewBuffer(b)
err := UnpackBuf(buf, elts...)
read := len(b) - buf.Len()
return read, err
}
func unpackValue(buf io.Reader, v reflect.Value) error {
if v.Type() == handlesAreaType {
v = v.Convert(reflect.TypeOf((*handleList)(nil)))
}
if didUnmarshal, err := tryUnmarshal(buf, v); didUnmarshal {
return err
}
switch v.Kind() {
case reflect.Ptr:
if v.IsNil() {
return fmt.Errorf("cannot unpack nil %s", v.Type().String())
}
return unpackValue(buf, v.Elem())
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
f := v.Field(i)
if err := unpackValue(buf, f); err != nil {
return err
}
}
return nil
default:
// binary.Read can only set pointer values, so we need to take the address.
if !v.CanAddr() {
return fmt.Errorf("cannot unpack unaddressable leaf type %q", v.Type().String())
}
return binary.Read(buf, binary.BigEndian, v.Addr().Interface())
}
}
// UnpackBuf recursively unpacks types from a reader just as encoding/binary
// does under binary.BigEndian, but with one difference: it unpacks a byte
// slice by first reading an integer with lengthPrefixSize bytes, then reading
// that many bytes. It assumes that incoming values are pointers to values so
// that, e.g., underlying slices can be resized as needed.
func UnpackBuf(buf io.Reader, elts ...interface{}) error {
for _, e := range elts {
v := reflect.ValueOf(e)
if v.Kind() != reflect.Ptr {
return fmt.Errorf("non-pointer value %q passed to UnpackBuf", v.Type().String())
}
if v.IsNil() {
return errors.New("nil pointer passed to UnpackBuf")
}
if err := unpackValue(buf, v); err != nil {
return err
}
}
return nil
}
-10
View File
@@ -1,10 +0,0 @@
//go:build !linux && !darwin
package tpmutil
import (
"os"
)
// Not implemented on Windows.
func poll(_ *os.File) error { return nil }
-32
View File
@@ -1,32 +0,0 @@
//go:build linux || darwin
package tpmutil
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
// poll blocks until the file descriptor is ready for reading or an error occurs.
func poll(f *os.File) error {
var (
fds = []unix.PollFd{{
Fd: int32(f.Fd()),
Events: 0x1, // POLLIN
}}
timeout = -1 // Indefinite timeout
)
if _, err := unix.Poll(fds, timeout); err != nil {
return err
}
// Revents is filled in by the kernel.
// If the expected event happened, Revents should match Events.
if fds[0].Revents != fds[0].Events {
return fmt.Errorf("unexpected poll Revents 0x%x", fds[0].Revents)
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More