Author SHA1 Message Date
jochen 05c0396f6b mesh-reads: drop the binary a local build left in the tree (issue 418)
mesh/merge-gate fail: builds new: modules/mesh-reads, sent nowhere; no bus step; a manifest the change touches fails the module check: modules/mesh-reads/m…
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 25
mesh/delivery-group group fix/418-node-reads rejected: a member's own check failed
mesh/delivery stopped: the pull request closed unmerged
2026-10-12 14:04:40 +02:00
jochen c36677b9e5 mesh-reads: read a node's files and accounts as an account that holds no secret (hq ADR 0310, issue 418)
Reading a file, searching one, listing a directory, the owner and mode of each
component of a path and looking up an account went through the login shell's
execute as the operator's account, which sees the operator's keys and the
agent's credentials, and execute is not served on the control-node. This module
holds node-reads on every node, runs as mesh-reads (no login, no group beside
its own), refuses the node's secret places by name before any read (the
controller's list and the known credential files), withholds any line shaped
like a secret through mesh-sdk's outward rule, and bounds every answer.
2026-10-12 14:04:12 +02:00
31 changed files with 56 additions and 1106 deletions
-2
View File
@@ -19,5 +19,3 @@ modules/mesh-issues/cmd/mesh-issues/mesh-issues
modules/mounts/cmd/mounts/mounts
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
modules/mesh-knowledge/mesh-knowledge
modules/mesh-reads/mesh-reads
modules/mesh-reads/cmd/mesh-reads/mesh-reads
@@ -104,9 +104,6 @@ func statusOf(d *Delivery) (string, string) {
case Superseded:
return "warning", clip("superseded: "+why, 140)
case Stopped:
if d.coveredStop() {
return "success", clip(d.coveredStopSaid(), 140) // its change reached the machines (novox/hq issue 480)
}
return "error", clip("stopped: "+why, 140)
case Retired:
return "success", "history: merged before this owner delivered anything, never walked"
@@ -127,9 +124,6 @@ const viewMarker = "<!-- mesh-delivery:view -->"
func ViewBody(d *Delivery, g *Group) string {
var b strings.Builder
fmt.Fprintf(&b, "%s\n**Delivery** `%s` — **%s** since %s\n\n", viewMarker, d.ID, d.State, d.Since.UTC().Format(time.RFC3339))
if d.coveredStop() {
fmt.Fprintf(&b, "**Covered** after its stop: a later merge that contains it was delivered: %s\n\n", d.CoveredBy)
}
if d.HeldWhy != "" && d.State == Held {
fmt.Fprintf(&b, "Held for a person: %s — `mesh-delivery.release` with why lets it go on.\n\n", d.HeldWhy)
}
@@ -284,16 +278,6 @@ func (h *Holder) Flush() {
d.Owed = owed(d.Owed)
h.keep(d)
}
// Counted for delivery_status, so a window's writes can be judged against its tries (novox/hq issue 407).
h.tried.last = h.Now()
switch {
case err == nil:
h.tried.done++
case dropped != "":
h.tried.givenUp++
default:
h.tried.failed++
}
if dropped != "" {
h.refuse(fmt.Errorf("%s: its owed %s given up — %s: %v", j.id, j.e.Kind, dropped, err))
}
@@ -67,16 +67,6 @@ type Holder struct {
// leaveRefused is, by walk, what the controller answered when it refused both to leave a stopped merge out and
// to stop the walk whole.
leaveRefused map[string]string
// tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
tried owedTries
}
// owedTries is how many owed effects were tried since this owner started, and when the last was: three numbers
// and a time, however many were tried. Not kept: a restarted owner counts from its start, and says since when, so a
// window's count is the difference of two readings in the same run (novox/hq issue 407).
type owedTries struct {
done, failed, givenUp int
last time.Time
}
// unmatched is a walk no delivery is known for yet: its merge not heard, or merged before this owner held.
@@ -963,16 +953,12 @@ func (h *Holder) plan() []func() {
// grace, it is stopped saying so — never left waiting for a start that cannot come.
for _, d := range h.deliveries {
ended := d.endedUnstarted()
if d.coveredStop() && !d.CoverTold {
h.tellCovered(d, now) // covered before this holder told it, or before a restart (novox/hq issue 480)
continue
}
if strandedUncovered(d) {
// Stopped and final, but still read for a later merge that delivered its modules: until one did,
// `stalled` keeps listing it (review of novox/hq issue 474).
var ask []string
if d.CoveredBy, ask = h.coverOf(d); d.CoveredBy != "" {
h.tellCovered(d, now)
h.keep(d)
}
for _, in := range ask {
if a := h.askContains(d, in, now); a != nil {
@@ -1117,25 +1103,6 @@ func (h *Holder) laterOnItsWay(d *Delivery) string {
return ""
}
// tellCovered tells the forge that a stop is covered since, once: its status says covered and by which merge, its view
// says so, and its note keeps the line on its head and its merge commit (novox/hq issue 480). No transition: the stop
// stays final, and the bus's transition event is not said again.
func (h *Holder) tellCovered(d *Delivery, now time.Time) {
d.CoverTold = true
line := oneLine(fmt.Sprintf("%s delivery %s: stopped, %s", now.UTC().Format(time.RFC3339), d.ID, d.coveredStopSaid()))
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Line: line, Since: now})
if d.MergedAs != "" && !sameCommit(d.MergedAs, d.Commit) {
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.MergedAs, Line: line, Since: now})
}
if d.shownOnItsPullRequest() {
d.owe(Effect{Kind: EffectView, Since: now})
state, desc := statusOf(d)
d.owe(Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery", State: state, Line: desc, Since: now})
}
h.Logf("[mesh-delivery] %s: stopped, %s", d.ID, d.coveredStopSaid())
h.keep(d)
}
// strandedUncovered is whether a delivery stopped because its ended walk delivered nothing is still uncovered: a
// merged change that never reached the machines, which `stalled` keeps listing until a later delivered merge
// carries its modules (review of novox/hq issue 474).
@@ -1,105 +0,0 @@
package main
import (
"testing"
"time"
)
// triedSaid is delivery_status's count of owed effects tried (novox/hq issue 407), read as a test reads it.
func triedSaid(t *testing.T, w *world) map[string]any {
t.Helper()
got, ok := w.h.Status("listening")["owed-tried"].(map[string]any)
if !ok {
t.Fatalf("delivery_status says no owed-tried: %v", w.h.Status("listening"))
}
return got
}
func countOf(t *testing.T, m map[string]any, key string) int {
t.Helper()
n, ok := m[key].(int)
if !ok {
t.Fatalf("owed-tried has no count %q: %v", key, m)
}
return n
}
// novox/hq issue 407: delivery_status says how many owed effects were tried since this owner started, how
// many were done, failed to be tried again, or were given up, and when the last was tried — so a window's
// writes can be checked against the tries made in it (issue 326's rule) by reading it at both ends.
func TestDeliveryStatusCountsTheOwedEffectsTried(t *testing.T) {
w := newWorld(t)
start := w.now
tried := triedSaid(t, w)
if countOf(t, tried, "tried") != 0 || countOf(t, tried, "done") != 0 || countOf(t, tried, "failed") != 0 ||
countOf(t, tried, "given-up") != 0 {
t.Fatalf("a holder that tried nothing says %v", tried)
}
if tried["since"] != start.Format(time.RFC3339) {
t.Fatalf("its count is since %v, the holder started %s", tried["since"], start.Format(time.RFC3339))
}
if _, there := tried["last"]; there {
t.Fatalf("nothing tried, yet a last try is said: %v", tried)
}
// A head seen: its event and its note are owed and done.
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.settleAll()
tried = triedSaid(t, w)
done := countOf(t, tried, "done")
if done < len(w.said)+len(w.forge.notesOn(head)) || done == 0 {
t.Fatalf("said %d and noted %d, yet done counts %d", len(w.said), len(w.forge.notesOn(head)), done)
}
if countOf(t, tried, "tried") != done || countOf(t, tried, "failed") != 0 {
t.Fatalf("every try succeeded, yet %v", tried)
}
if tried["last"] != w.now.Format(time.RFC3339) {
t.Fatalf("last try said %v, tried at %s", tried["last"], w.now.Format(time.RFC3339))
}
// The forge away: its effects fail and are counted failed; quiet passes try nothing more.
w.forge.down = true
w.later(time.Minute)
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.Flush()
tried = triedSaid(t, w)
failed := countOf(t, tried, "failed")
if failed == 0 {
t.Fatalf("the forge away failed nothing: %v", tried)
}
if countOf(t, tried, "tried") != countOf(t, tried, "done")+failed+countOf(t, tried, "given-up") {
t.Fatalf("tries do not add up: %v", tried)
}
if tried["last"] != w.now.Format(time.RFC3339) {
t.Fatalf("last try said %v, tried at %s", tried["last"], w.now.Format(time.RFC3339))
}
before := countOf(t, tried, "tried")
w.h.Flush() // before each failure's next try: nothing is tried
if n := countOf(t, triedSaid(t, w), "tried"); n != before {
t.Fatalf("a pass before any next try tried %d", n-before)
}
// The forge back, past every next try: the failed effects are done.
w.forge.down = false
w.later(time.Hour)
w.h.Flush()
tried = triedSaid(t, w)
if countOf(t, tried, "tried") != before+failed || countOf(t, tried, "done") <= done {
t.Fatalf("the forge back: tried %d (was %d, %d failed), %v", countOf(t, tried, "tried"), before, failed, tried)
}
}
// The count is this run's: a restarted holder says it counts since its own start, from nothing.
func TestOwedTriedCountsSinceTheStart(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.settleAll()
if countOf(t, triedSaid(t, w), "tried") == 0 {
t.Fatal("nothing counted before the restart")
}
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute))
tried := triedSaid(t, again)
if countOf(t, tried, "tried") != 0 || tried["since"] != again.now.Format(time.RFC3339) {
t.Fatalf("a restarted holder says %v", tried)
}
}
@@ -1,48 +0,0 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
)
// **An unanswered line says the state of the delivery it holds, as data** (novox/hq issue 451): for a delivery in
// ready or rejected, whose check answered once, the controller advises a recheck rather than the build queue, and
// it reads that state from the line, never from its words. A head with no delivery held says none.
func TestAnUnansweredLineNamesTheStateOfItsDelivery(t *testing.T) {
at := time.Date(2026, 10, 11, 0, 43, 0, 0, time.UTC)
for _, c := range []struct {
gate string
want State
}{{"pass", Ready}, {"fail", Rejected}} {
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), at)
id := heldIn(t, w, c.gate, c.want)
w.forge.open = openPullsOf(t, readyRepoCheckPending)
w.h.SeeOpenPulls()
w.later(Bounds[Proposed].For + time.Minute)
w.h.SeeOpenPulls()
lines := w.h.Stalled()
if len(lines) != 1 || lines[0].ID != id || lines[0].State != Unanswered || lines[0].Held != c.want {
t.Fatalf("a %s delivery's unanswered line does not say its state as data: %+v", c.want, lines)
}
raw, _ := json.Marshal(lines[0])
if !strings.Contains(string(raw), `"held":"`+string(c.want)+`"`) {
t.Fatalf("the line the controller reads carries no held state: %s", raw)
}
}
// The same head with no delivery held here: unanswered, and no state to say.
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), at)
w.forge.open = openPullsOf(t, readyRepoCheckPending)
w.h.SeeOpenPulls()
w.later(Bounds[Proposed].For + time.Minute)
w.h.SeeOpenPulls()
lines := w.h.Stalled()
if len(lines) != 1 || lines[0].State != Unanswered || lines[0].Held != "" {
t.Fatalf("a head with no delivery held says a state: %+v", lines)
}
if raw, _ := json.Marshal(lines[0]); strings.Contains(string(raw), `"held"`) {
t.Fatalf("a line with no delivery held carries a held field: %s", raw)
}
}
@@ -1,134 +0,0 @@
package main
import (
"strings"
"testing"
"time"
)
// novox/hq issue 480: a delivery stopped because its walk ended done before its word without building its modules
// (issue 474) is still read for a later merge that delivers them, and kept CoveredBy once one did. Its state stays
// stopped — a final state never moves, as for a covered superseded member (issue 442) — but the forge's status, its
// view, its note and `show` went on saying "stopped" for a change that reached every machine. Once covered they say
// covered, and by which merge. And while `stalled` lists such a stop, `deliveries` lists it too, past a day.
// strandedNight is theNight with #215 stopped, its walk past the grace with nothing later carrying build-agent.
func strandedNight(t *testing.T) (*world, string) {
w := theNight(t)
id := IDOf(ctl474, head215)
w.later(strandedAfter + 5*time.Minute)
w.settleAll()
if d := w.delivery(id); d.State != Stopped || d.Stranded == "" {
t.Fatalf("#215 is %s, stranded %q", d.State, d.Stranded)
}
return w, id
}
func TestAStopALaterMergeCoversReadsCoveredByThatMerge(t *testing.T) {
w, id := strandedNight(t)
if got := statusOn(w, ctl474, head215); !strings.HasPrefix(got, "error stopped: ") {
t.Fatalf("stopped, its status says %q", got)
}
w.forge.contains[merge215+" in "+merge219] = true
by := deliverLater(w, ctl474, 219, "main", head219, merge219, w.now, walkDone, "build-agent")
w.later(2 * time.Minute)
w.settleAll()
d := w.delivery(id)
if d.State != Stopped || !strings.Contains(d.CoveredBy, by) {
t.Fatalf("after #219 it is %s, covered by %q", d.State, d.CoveredBy)
}
got := statusOn(w, ctl474, head215)
if !strings.HasPrefix(got, "success covered") || !strings.Contains(got, by) {
t.Fatalf("covered by %s, its status still says %q", by, got)
}
shown, err := w.h.Show(id)
if err != nil {
t.Fatal(err)
}
if reads, _ := shown.(map[string]any)["reads"].(string); !strings.HasPrefix(reads, "covered") || !strings.Contains(reads, by) {
t.Fatalf("show reads %q", reads)
}
view := w.forge.views[ctl474+"#215"]
if !strings.Contains(view, "Covered") || !strings.Contains(view, by) {
t.Fatalf("its view does not say it was covered by %s:\n%s", by, view)
}
noted := false
for _, l := range w.forge.notesOn(merge215) {
noted = noted || strings.Contains(l, "covered") && strings.Contains(l, by)
}
if !noted {
t.Fatalf("its merge's note does not say it was covered: %v", w.forge.notesOn(merge215))
}
// Told once: further ticks owe nothing more.
w.later(time.Hour)
w.settleAll()
if d := w.delivery(id); len(d.Owed) != 0 || !d.CoverTold {
t.Fatalf("still owes %+v, told %v", d.Owed, d.CoverTold)
}
}
// A stop covered before this was built (CoveredBy kept, nothing told) is told after a restart, from what was kept.
func TestACoveredStopKeptBeforeIsToldAfterARestart(t *testing.T) {
w, id := strandedNight(t)
w.h.mu.Lock()
d := w.h.deliveries[id]
d.CoveredBy = IDOf(ctl474, head219) + " (merged as " + merge219[:8] + ")"
if err := w.store.PutDelivery(d); err != nil {
t.Fatal(err)
}
w.h.mu.Unlock()
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Hour))
again.settleAll()
if got := statusOn(again, ctl474, head215); !strings.HasPrefix(got, "success covered") ||
!strings.Contains(got, IDOf(ctl474, head219)) {
t.Fatalf("after a restart its status says %q", got)
}
if d := again.delivery(id); d.State != Stopped || len(d.Transitions) != len(w.delivery(id).Transitions) {
t.Fatalf("a restart moved it: %s", d.State)
}
}
// Only a stop for a walk that never built its modules reads covered: a person's stop, or one the cover test has
// not answered, keeps its error.
func TestOnlyACoveredStrandedStopReadsCovered(t *testing.T) {
for _, d := range []*Delivery{
{State: Stopped, CoveredBy: "x"}, // a person's stop, with a cover found while it was held
{State: Stopped, Stranded: "never reached"}, // stranded, not covered
} {
if state, _ := statusOf(d); state != "error" {
t.Fatalf("%+v reads %s", d, state)
}
if d.coveredStop() {
t.Fatalf("%+v reads as a covered stop", d)
}
}
}
func TestAStopStalledListsStaysInDeliveries(t *testing.T) {
w, id := strandedNight(t)
w.later(30 * time.Hour)
w.settleAll()
listed := func() *Line {
for _, l := range w.h.Deliveries("", "", "", false) {
if l.ID == id {
return &l
}
}
return nil
}
if len(w.h.Stalled()) != 1 {
t.Fatalf("stalled lists %+v", w.h.Stalled())
}
l := listed()
if l == nil || !l.Stalled || !strings.Contains(l.Waits, "build-agent") {
t.Fatalf("listed by stalled, deliveries shows %+v", l)
}
// Covered, it ends like any final delivery: a day after its stop.
w.forge.contains[merge215+" in "+merge219] = true
deliverLater(w, ctl474, 219, "main", head219, merge219, w.now, walkDone, "build-agent")
w.later(2 * time.Minute)
w.settleAll()
if len(w.h.Stalled()) != 0 || listed() != nil {
t.Fatalf("covered, stalled lists %+v and deliveries %+v", w.h.Stalled(), listed())
}
}
@@ -192,7 +192,7 @@ func tools(h *Holder, l *listening) []stdio.Tool {
seat := "mesh-delivery."
return []stdio.Tool{
{Name: seat + "deliveries",
Description: "Every delivery not final, those that ended in the last day, and any stop `stalled` still lists, one line each: its id " +
Description: "Every delivery not final, and those that ended in the last day, one line each: its id " +
"(owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, " +
"repository or group.",
Input: map[string]any{"type": "object", "properties": map[string]any{"state": str("one state"),
@@ -347,8 +347,7 @@ func tools(h *Holder, l *listening) []stdio.Tool {
}},
{Name: "delivery_status",
Description: "Whether this owner works: how many deliveries in each state, the groups, what is owed to " +
"the forge and the bus and not yet done, how many owed effects were tried since it started (done, failed " +
"and tried again, given up) and when the last was, walks with no delivery yet, whether its events arrive, when " +
"the forge and the bus and not yet done, walks with no delivery yet, whether its events arrive, when " +
"the controller's walks were last read or why they could not be, and the last refusals.",
Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }},
}
@@ -68,9 +68,6 @@ type Delivery struct {
// Stranded says why a merge whose walk ended done before its word, without moving every module it moves, is
// stopped: nothing later delivered them either (novox/hq issue 474). Set once the cover test says no.
Stranded string `json:"stranded,omitempty"`
// CoverTold is whether the forge was told that such a stop was covered since: its status, its view and its note
// (novox/hq issue 480). Kept, so a restart tells it once, and a stop covered before this was built is told too.
CoverTold bool `json:"cover_told,omitempty"`
// TurnAwaits says, for a group member whose turn waits for a later merge to carry a superseded member before it,
// which merges and until when; TurnAwaitsUntil is that moment, which bounds the wait (ADR 0276 decision 9,
// novox/hq issue 445). `show` shows both, and `stalled` says them past that moment.
@@ -251,20 +248,6 @@ func (d *Delivery) FromForgesWord() bool {
// shownOnItsPullRequest is whether its view and status are kept on its pull request: not for history, whose
// pull request the forge may no longer hold — or holds under its number another one.
// coveredStop is whether a delivery stopped because its walk ended done before its word without building its modules
// (novox/hq issue 474) was covered since: a later merge into its trunk that contains it was delivered, carrying every
// module it moves to every machine running it (ADR 0270's cover test, kept as CoveredBy). Its state stays stopped —
// final, as a covered superseded member stays superseded (issue 442) — and it reads covered, by that merge (novox/hq
// issue 480). A stop for any other reason never reads covered.
func (d *Delivery) coveredStop() bool {
return d.State == Stopped && d.Stranded != "" && d.CoveredBy != ""
}
// coveredStopSaid is how a covered stop reads: on the forge's status, in its view and note, and in `show`.
func (d *Delivery) coveredStopSaid() string {
return "covered after its stop: a later merge that contains it was delivered: " + d.CoveredBy
}
func (d *Delivery) shownOnItsPullRequest() bool {
return d.Number > 0 && d.History == "" && d.State != Retired
}
@@ -250,8 +250,8 @@ func (h *Holder) uncheckedLines(now time.Time) []StalledLine {
h2 = "none: its delivery is " + string(u.held) + ", a state the operator moves — the operator's"
}
out = append(out, StalledLine{ID: id, Number: u.pull.Number, State: state, For: now.Sub(u.first).Round(time.Second).String(),
Bound: u.bound().String(), Held: u.held,
H2: h2, Says: says, Waiting: waiting})
Bound: u.bound().String(),
H2: h2, Says: says, Waiting: waiting})
}
return out
}
@@ -71,11 +71,6 @@ func (h *Holder) waitsFor(d *Delivery) string {
if d.Walk != nil {
return "its walk: " + clip(d.Walk.Note, 120)
}
case Stopped:
if strandedUncovered(d) {
return "a later delivered merge to carry " + strings.Join(d.notMovedBy(), ", ") + ": it is stopped, and " +
"listed by stalled until one does (novox/hq issue 480)"
}
}
return ""
}
@@ -91,8 +86,7 @@ func (h *Holder) Deliveries(state, repository, group string, all bool) []Line {
group != "" && d.Group != group {
continue
}
// A final delivery `stalled` still lists stays, however old: the two never disagree (novox/hq issue 480).
if d.State.Final() && !all && now.Sub(d.Since) > 24*time.Hour && !strandedUncovered(d) {
if d.State.Final() && !all && now.Sub(d.Since) > 24*time.Hour {
continue
}
l := Line{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), For: now.Sub(d.Since).Round(time.Second).String(),
@@ -103,7 +97,7 @@ func (h *Holder) Deliveries(state, repository, group string, all bool) []Line {
if d.Walk != nil {
l.Walk = d.Walk.ID
}
l.Stalled = pastBound(d, now) || strandedUncovered(d)
l.Stalled = pastBound(d, now)
out = append(out, l)
}
sort.Slice(out, func(i, j int) bool {
@@ -121,9 +115,6 @@ func (h *Holder) Show(id string) (any, error) {
defer h.mu.Unlock()
if d := h.deliveries[id]; d != nil {
out := map[string]any{"delivery": d, "waits": h.waitsFor(d)}
if d.coveredStop() {
out["reads"] = d.coveredStopSaid() // stopped, and its change reached the machines (novox/hq issue 480)
}
if g := h.groups[d.Group]; g != nil {
state, why := GroupState(g, h.membersOf(g))
out["group"] = map[string]any{"group": g, "state": state, "why": why}
@@ -199,14 +190,10 @@ type StalledLine struct {
// the operator's own zone, which it cannot do inside a finished sentence (novox/hq issue 443). Waiting stays
// beside them for a controller that does not read them yet.
Checks []WaitingCheck `json:"checks,omitempty"`
// Held is the state of the delivery a line of the state Unanswered is about, ready or rejected, when one is held
// here (novox/hq issues 441, 451): its check answered once, so the controller advises a recheck of it rather than
// the build queue, reading the state here and never from Says. Empty for a head with no delivery.
Held State `json:"held,omitempty"`
For string `json:"for"`
Bound string `json:"bound"`
H2 string `json:"h2"`
Says string `json:"says"`
For string `json:"for"`
Bound string `json:"bound"`
H2 string `json:"h2"`
Says string `json:"says"`
}
// Stalled is the `stalled` verb: what the controller's self-check raises as `delivery.<id>.stalled`.
@@ -648,15 +635,6 @@ func (h *Holder) Status(listening string) map[string]any {
out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty),
"walks-unmatched": len(h.unmatched), "events": listening, "started": h.started.Format(time.RFC3339),
"seat-on-record": h.held}
// The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again,
// or given up, and when the last was tried. A count since the start, said with that start: two readings in
// one run give a window's tries.
tried := map[string]any{"since": h.started.Format(time.RFC3339), "done": h.tried.done, "failed": h.tried.failed,
"given-up": h.tried.givenUp, "tried": h.tried.done + h.tried.failed + h.tried.givenUp}
if !h.tried.last.IsZero() {
tried["last"] = h.tried.last.Format(time.RFC3339)
}
out["owed-tried"] = tried
// Each held delivery by name, with why and the verb that releases it (novox/hq issue 529): a count alone
// pointed a reader at nothing.
var held []string
+2 -8
View File
@@ -23,7 +23,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that
| `reserve` | `kind`, `for` | the next decision or research number, asked of the issue tracker (`issue-tracker.reserve`); spent whether or not its record is accepted |
| `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`). Each version proposed is then reviewed by the knowledge base itself (below) |
| `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number |
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number |
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again |
`ask`, `get`, `holds` and `history` stay within `bound` bytes (4096 unless asked for, at most 65536) and say what they cut
and how to read the rest. The module's own tool `knowledge_health` answers its health (below).
@@ -78,7 +78,7 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
- 25 on the structure: kind, reserved number, title, topic, date, deciders, research status, at least one
clause, clause numbering, bold lead, clause size, the Context, Consequences and "How it is checked" sections,
a research effort's overview, link kind, link target, link fit, no link to itself, what it extends, what it
supersedes or changes is accepted, a changed clause is in force or changed (a drafted one too), a superseded record cited beside its
supersedes or changes is accepted, a changed clause is in force, a superseded record cited beside its
superseder, a graduated effort says what it became, and the links in its text resolve;
- 4 on the prose: no word the glossary retired, no daemon's line, a deferral names its tracking issue, and a
progressive insight in the dated, marked form.
@@ -178,12 +178,6 @@ the older clause marked `changed`. When every prose record of a topic has been t
operator is told once (`operator-channel.notify`, a warning) with the counts. `return` sends a record back to be
drafted again.
A drafted clause may be changed by a later record (`changes 0281 §9`, novox/hq issue 494, ADR 0321): on its acceptance the
clause is marked `changed` with the change named, keeps its number and stays `drafted`, so it is still never served
as in force, and `get` says both. A redraft numbers a record's clauses anew, so `return` refuses a record any of
whose clauses another record changed or links by number, served or proposed, naming each link: a link never comes
to point at another clause.
## Where it keeps its records
In a database in the store: the module requires a `postgres-database` provision named `knowledge`, as
@@ -70,13 +70,6 @@ func held(r *Record, c Clause) string {
if c.Unconfirmed != "" {
s += " (the second judgement did not confirm it: " + c.Unconfirmed + ")"
}
if len(c.ChangedBy) > 0 {
if c.Status == ClauseSuperseded {
s += "; superseded by " + strings.Join(c.ChangedBy, ", ")
} else {
s += "; changed by " + strings.Join(c.ChangedBy, ", ") + ": read both"
}
}
return s
case r.Status == StatusProposed || c.Status == ClauseProposed:
return "proposed: not in force"
@@ -503,15 +503,6 @@ func (k *KB) Return(caller string, key Key, reason string) (map[string]any, erro
if r.Drafting == nil {
return nil, fmt.Errorf("%s's clauses were written by its author, not drafted: a written clause is changed by a new record", key)
}
all, err := k.Store.All(ctx)
if err != nil {
return nil, err
}
if held := clauseHolds(r, all); len(held) > 0 {
return nil, fmt.Errorf("%s is not returned: drafting it again would number its clauses anew, and these hold them to their numbers: %s. "+
"A clause keeps its number once another record links it (novox/hq issue 494). To say a drafted clause is wrong "+
"or no longer holds, propose a record that changes it (changes %s §N)", key, strings.Join(held, "; "), strings.TrimPrefix(key.String(), "ADR "))
}
r.Clauses = nil
r.Drafting = &Drafting{State: DraftWaiting, Returned: reason, At: k.now()}
why := "returned to be drafted again: " + reason
@@ -521,36 +512,3 @@ func (k *KB) Return(caller string, key Key, reason string) (map[string]any, erro
k.emit(EventReturned, r, caller, why, e.Versions+1)
return map[string]any{"record": key.String(), "says": fmt.Sprintf("%s's clauses are taken back and will be drafted again, with your reason; until then it serves none.", key)}, nil
}
// clauseHolds is what holds a record's clauses to their numbers: each clause another record changed or superseded,
// and each link of another record, served or proposed, that names one of its clauses. A redraft numbers the clauses
// anew, so a record any of these name is never returned: its links would point at other clauses.
func clauseHolds(r *Record, all []*Entry) []string {
var out []string
for _, c := range r.Clauses {
if len(c.ChangedBy) > 0 && (c.Status == ClauseChanged || c.Status == ClauseSuperseded) {
out = append(out, fmt.Sprintf("§%d is %s by %s", c.N, c.Status, strings.Join(c.ChangedBy, ", ")))
}
}
key := r.Key()
seen := map[string]bool{}
for _, e := range all {
for _, v := range []*Record{e.Current, e.Pending} {
if v == nil || v.Key() == key || v.Status == StatusWithdrawn {
continue
}
for _, l := range v.Links {
to, ok := l.To.Record()
if !ok || to != key || l.To.Clause == 0 {
continue
}
s := fmt.Sprintf("%s (%s) %s §%d", v.Key(), v.Status, l.Kind, l.To.Clause)
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
}
}
return out
}
@@ -1,108 +0,0 @@
package main
import (
"context"
"strings"
"testing"
)
// novox/hq issue 494: a record may change a clause the knowledge base holds only as drafted, and a record whose
// clauses another record links by number is never drafted again, so no link ever points at another clause.
// draftedNotConfirmed drafts 0240 and has the second judgement reject its first clause: it stays drafted.
func draftedNotConfirmed(t *testing.T, w *world) Key {
t.Helper()
k := Key{KindDecision, 240}
draftUntil(t, w, k, DraftDrafted)
e := mustGet(t, w.k.Store, k)
r := e.Current.Clone()
r.Clauses[0].Text += " NOT EXACT"
if err := w.k.Store.Save(context.Background(), Version{By: "test", Why: "a draft the checker will reject", Record: r}, true, false, e.Revision); err != nil {
t.Fatal(err)
}
r = draftUntil(t, w, k, DraftDone)
if r.Clauses[0].Source != SourceDrafted || !r.Accepted() {
t.Fatalf("0240 §1 is not a drafted clause of an accepted record: %s %+v", r.Status, r.Clauses[0])
}
return k
}
func TestARecordMayChangeAClauseHeldOnlyAsDrafted(t *testing.T) {
eachStore(t, func(t *testing.T, s Store) {
w := imported(t, s)
k := draftedNotConfirmed(t, w)
n := w.reserved(t, "author@test")
p := valid(n)
p.Links = append(p.Links, LinkAsk{Kind: LinkChanges, To: "0240 §1", From: 2})
if _, err := w.k.Propose("author@test", p); err != nil {
t.Fatalf("a change of a drafted clause was refused: %v", err)
}
w.reviewAll(t)
if e := mustGet(t, s, Key{KindDecision, n}); !e.Current.Accepted() {
t.Fatalf("not accepted: %s", e.Current.Status)
}
// The drafted clause is changed by it, keeps its number and stays drafted: still never served as in force.
r := mustGet(t, s, k).Current
c := r.Clause(1)
want := itoa4(n) + " §2"
if c == nil || c.Status != ClauseChanged || c.Source != SourceDrafted || len(c.ChangedBy) != 1 || !strings.HasSuffix(c.ChangedBy[0], want) {
t.Fatalf("0240 §1 after the change: %+v", c)
}
if Served(r, *c) {
t.Fatal("a drafted clause that was changed is served as holding")
}
g, err := w.k.Get(GetAsk{Kind: KindDecision, Number: 240})
if err != nil {
t.Fatal(err)
}
if h := g.Clauses[0].Holds; !strings.HasPrefix(h, "drafted, not confirmed") || !strings.Contains(h, "changed by ADR "+want) {
t.Fatalf("get says of 0240 §1: %q", h)
}
// The whole-set rules find nothing wrong with the change.
f, _ := s.Findings(context.Background())
for _, x := range f {
if x.Rule == "partial" {
t.Fatalf("a whole-set finding: %+v", x)
}
}
})
}
func TestARecordWhoseClausesAreLinkedByNumberIsNeverReturned(t *testing.T) {
eachStore(t, func(t *testing.T, s Store) {
w := imported(t, s)
k := draftedNotConfirmed(t, w)
// A proposed record cites 0240 §2: returning 0240 would point it at whatever is drafted second next time.
n := w.reserved(t, "author@test")
p := valid(n)
p.Links = append(p.Links, LinkAsk{Kind: LinkCites, To: "0240 §2", From: 1})
w.judge.review = `{"verdict":"changes","findings":["not yet"]}`
if _, err := w.k.Propose("author@test", p); err != nil {
t.Fatal(err)
}
w.reviewAll(t)
before := mustGet(t, s, k)
_, err := w.k.Return("operator@shanks", k, "clause 1 says more than the section")
if err == nil {
t.Fatal("a record a proposed record links by clause was returned")
}
if !strings.Contains(err.Error(), "ADR "+itoa4(n)+" (proposed) cites §2") || !strings.Contains(err.Error(), "changes 0240 §N") {
t.Fatalf("the refusal does not say what holds the clauses or the way forward: %v", err)
}
after := mustGet(t, s, k)
if after.Versions != before.Versions || len(after.Current.Clauses) != len(before.Current.Clauses) || len(w.said.of(EventReturned)) != 0 {
t.Fatalf("a refused return changed 0240: %d versions (was %d), %d clauses", after.Versions, before.Versions, len(after.Current.Clauses))
}
// A clause another record changed holds the record to its numbers too, with no link left to scan.
w2 := imported(t, newMemStore())
k2 := draftedNotConfirmed(t, w2)
n2 := w2.reserved(t, "author@test")
p2 := valid(n2)
p2.Links = append(p2.Links, LinkAsk{Kind: LinkChanges, To: "0240 §1", From: 2})
w2.accepted(t, "author@test", p2)
if _, err := w2.k.Return("operator@shanks", k2, "again"); err == nil || !strings.Contains(err.Error(), "§1 is changed by ADR "+itoa4(n2)+" §2") {
t.Fatalf("a record with a changed clause was returned, or the refusal does not name the change: %v", err)
}
})
}
@@ -1010,8 +1010,7 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
{Name: Seat + ".return",
Description: "Send the clauses drafted from a prose decision back to be drafted again, with your reason, which the next " +
"drafting reads. Until they are drafted and confirmed again the record serves none. Refused: a record whose clauses its " +
"author wrote, an empty reason, and a record whose clauses another record changed or links by number (a redraft " +
"numbers them anew; a record that changes the drafted clause says it is wrong instead).",
"author wrote, an empty reason.",
Input: schema([]string{"number", "reason"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"),
"reason": text("what is wrong with the drafted clauses")}),
Run: func(m map[string]any) (any, error) {
@@ -48,7 +48,7 @@ var PerRecordRules = []Rule{
{"self-link", "no link points at the record itself", "records.py supersession"},
{"rests-on", "a record it extends is accepted or proposed, never superseded or withdrawn", "records.py rests-on"},
{"supersedes-accepted", "a record it supersedes or changes is accepted", "records.py supersession, partial-supersession"},
{"changes-in-force", "a clause it changes is in force or changed, never superseded or proposed; a drafted one may be changed", "records.py partial-supersession"},
{"changes-in-force", "a clause it changes is in force or changed, never superseded, proposed or drafted", "records.py partial-supersession"},
{"live-citation", "a superseded record it cites is cited beside the record that replaced it", "records.py live-citation"},
{"became", "a graduated research effort names what it became", "cycle.py research"},
{"text-links", "every link its text writes to a decision record or research effort names one the knowledge base holds", "records.py links"},
@@ -261,10 +261,8 @@ func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusa
if !target.Accepted() || target.Status == StatusSuperseded {
refuse("supersedes-accepted", "%s changes %s, which is %s, not accepted", self, to, target.Status)
} else if c := target.Clause(l.To.Clause); c != nil {
// A drafted clause may be changed (novox/hq issue 494, ADR 0321): the change is recorded against the clause the
// knowledge base holds, by its number, which nothing renumbers once another record links it (Return).
if c.Status != ClauseInForce && c.Status != ClauseChanged {
refuse("changes-in-force", "%s changes %s §%d, which is %s", self, to, l.To.Clause, c.Status)
if (c.Status != ClauseInForce && c.Status != ClauseChanged) || c.Source == SourceDrafted {
refuse("changes-in-force", "%s changes %s §%d, which is %s (%s)", self, to, l.To.Clause, c.Status, c.Source)
}
}
case LinkCites:
+3 -14
View File
@@ -27,25 +27,14 @@ Before any read, whatever the file modes would allow (ADR 0310 §5):
names (`id_ed25519`, `*.key`, `ssh_host_*_key`); `/etc/shadow`; `/etc/ssl/private`, `/etc/wireguard`
and the network manager's connections.
- **Histories and profiles**: shell and program histories (`.zsh_history`, `.bash_history`, `.psql_history`,
`.viminfo` …), browser and mail profiles (`.mozilla`, `.config/google-chrome`, `.config/chromium` …), a
process's `environ`, `mem`, `fd`, `map_files`, `root` and `cwd` under `/proc`, and `/etc/rancher` and
`/etc/kubernetes/admin.conf`.
**What was opened is judged, not the name.** The type is checked before the open, so a device or a fifo is
never opened; the file is opened non-blocking (in a search without following a link), checked again on its
descriptor, and the path the kernel gives for that descriptor is judged by name like any path asked. A link
flipped between the check and the open therefore reaches nothing a name refuses, and a deleted file is refused.
**Limit:** a hard link to a secret file under another name is not seen by the name rule; the file's own mode,
and the kernel's protected hard links, are what bound it. `stat` and `readlink` say a secret place's owner
A link is judged by where it leads as well as by its name. `stat` and `readlink` say a secret place's owner
and mode, and mark it, because those are not its content; `list`, `read` and `search` refuse it.
## The content rule
Every answer passes the content rule's secret class, from `mesh-sdk`'s `outward` package (ADR 0310 §4): a
line of a file or a matching line shaped like a secret is withheld and its number said, as is a link target
or an account's words. Every line of a `BEGIN … END` block — a private key's or a certificate's — is withheld whole, because one
base64 line of a key can pass the rule alone. A path and an entry's name are not judged: they are what the caller named or what the
or an account's words. A path and an entry's name are not judged: they are what the caller named or what the
file system calls a thing, and the rule would take a temporary directory's or a container layer's name for a
token.
@@ -57,7 +46,7 @@ token.
| `readlink` | `readlink`, `readlink -f`, `realpath` | one path |
| `list` | `ls`, `ls -la` | 2000 entries, cut and said |
| `read` | `cat`, `head`, `tail`, `less` | 64 KiB, or up to 256 KiB when asked; cut at a line and said; text only |
| `search` | `grep`, `grep -r`, `grep -F` | a fixed string; 100 matches (500 asked), 4 deep (8 asked), 5000 files, 4 MiB a file, 20 seconds (checked inside a file too); the named path's file system only, the others named; the bound met is said |
| `search` | `grep`, `grep -r`, `grep -F` | a fixed string; 100 matches (500 asked), 4 deep (8 asked), 5000 files, 4 MiB a file, 20 seconds; the bound met is said |
| `account` | `getent passwd`, `getent group`, `id`, `groups` | 2000 accounts and groups; never a password field |
No answer is larger than 1 MiB. Nothing here writes a file.
+2 -2
View File
@@ -174,8 +174,8 @@ func tools(r Reader) []stdio.Tool {
}),
verb("search", "Find a fixed string (never a pattern) in one file or under one directory: every matching "+
"line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories "+
"deep (4 unless more is asked), 5000 files, 4 MiB per file and 20 seconds, on the file system of the path "+
"named; links are not followed and binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like "+
"deep (4 unless more is asked), 5000 files, 4 MiB per file and 20 seconds; links are not followed and "+
"binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like "+
"a secret is withheld. The answer says which bound it met.",
object(map[string]any{"text": str("the fixed string to find"),
"path": str("an absolute path to a file or a directory"),
+22 -139
View File
@@ -90,82 +90,6 @@ func why(err error) string {
return err.Error()
}
// errRefusedByName marks a refusal by name, which a search names rather than counts as unreadable.
var errRefusedByName = errors.New("refused by name")
// openJudged opens a path and judges what was opened, not its name (the review of #244: a link flipped between
// the name check and the open read a secret place 3744 times in 20000): the type is checked before the open, so
// a device or a fifo is never opened; the descriptor is opened non-blocking, without a controlling terminal, and
// in a search without following a link; then it is checked again by fstat, and the path the kernel says it is
// (/proc/self/fd) is judged by name like any path asked. A deleted file, or one the kernel names no path for, is
// refused.
func (r Reader) openJudged(path string, nofollow, dir bool) (*os.File, fs.FileInfo, error) {
stat := os.Stat
if nofollow {
stat = os.Lstat
}
before, err := stat(path)
if err != nil {
return nil, nil, fmt.Errorf("%s cannot be read: %s", path, why(err))
}
if err := wantType(path, before, dir); err != nil {
return nil, nil, err
}
flags := os.O_RDONLY | syscall.O_NONBLOCK | syscall.O_NOCTTY | syscall.O_CLOEXEC
if nofollow {
flags |= syscall.O_NOFOLLOW
}
if dir {
flags |= syscall.O_DIRECTORY
}
f, err := os.OpenFile(path, flags, 0)
if err != nil {
return nil, nil, fmt.Errorf("%s cannot be read: %s", path, why(err))
}
info, err := f.Stat()
if err == nil {
err = wantType(path, info, dir)
}
if err != nil {
f.Close()
return nil, nil, err
}
real, err := os.Readlink(fmt.Sprintf("/proc/self/fd/%d", f.Fd()))
if err != nil || !strings.HasPrefix(real, "/") || strings.HasSuffix(real, " (deleted)") {
f.Close()
return nil, nil, fmt.Errorf("%s is refused: the kernel names no path for what was opened, so it cannot be "+
"judged by name (novox/hq ADR 0310 §5)", path)
}
if why := r.Refused(real); why != "" {
f.Close()
return nil, nil, fmt.Errorf("%s is %s, which is refused by name: %s (novox/hq ADR 0310 §5): %w", path, real, why, errRefusedByName)
}
return f, info, nil
}
func wantType(path string, info fs.FileInfo, dir bool) error {
switch {
case dir && !info.IsDir():
return fmt.Errorf("%s is a %s, not a directory", path, typeOf(info.Mode()))
case !dir && !info.Mode().IsRegular():
return fmt.Errorf("%s is a %s, not a file: list a directory, stat anything else", path, typeOf(info.Mode()))
}
return nil
}
// deviceOf is the file system a path is on.
func deviceOf(path string) (uint64, bool) {
info, err := os.Lstat(path)
if err != nil {
return 0, false
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(st.Dev), true
}
// ---- stat ----
// Component is one component of a path as lstat sees it.
@@ -437,16 +361,10 @@ func (r Reader) List(raw string) (ListAnswer, error) {
if err := r.refusedRead(path); err != nil {
return ListAnswer{}, err
}
dir, _, err := r.openJudged(path, false, true)
if err != nil {
return ListAnswer{}, err
}
entries, err := dir.ReadDir(-1)
dir.Close()
entries, err := os.ReadDir(path)
if err != nil {
return ListAnswer{}, fmt.Errorf("%s cannot be listed: %s", path, why(err))
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
names := r.names()
a := ListAnswer{Path: path, Count: len(entries), Entries: []Entry{}}
if len(entries) > MaxEntries {
@@ -521,11 +439,19 @@ func (r Reader) Read(raw string, ask ReadAsk) (ReadAnswer, error) {
if ask.Lines < 0 {
return ReadAnswer{}, errors.New("lines is a count: name how many, or none for as many as fit")
}
f, info, err := r.openJudged(path, false, false)
// Non-blocking, so a fifo answers rather than waits; and judged by what was opened, not by its name.
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
if err != nil {
return ReadAnswer{}, err
return ReadAnswer{}, fmt.Errorf("%s cannot be read: %s", path, why(err))
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return ReadAnswer{}, fmt.Errorf("%s cannot be read: %s", path, why(err))
}
if !info.Mode().IsRegular() {
return ReadAnswer{}, fmt.Errorf("%s is a %s, not a file: list a directory, stat anything else", path, typeOf(info.Mode()))
}
a := ReadAnswer{Path: path, Size: info.Size()}
lines, cut, err := pick(io.LimitReader(f, MaxScan), ask, bound)
if err != nil {
@@ -538,9 +464,6 @@ func (r Reader) Read(raw string, ask ReadAsk) (ReadAnswer, error) {
var text strings.Builder
for _, l := range lines {
s, what := clean(l.text)
if l.block {
s, what = "‹withheld: a key or certificate block›", "a key or certificate block"
}
if what != "" {
a.Withheld = append(a.Withheld, l.n)
a.Why = "a line shaped like a secret is withheld by the content rule (novox/hq ADR 0310 §4)"
@@ -559,7 +482,6 @@ type line struct {
text string
newline bool
size int
block bool // inside a BEGIN … END block, whose every line is withheld
}
// pick reads lines and keeps the ones asked, within bound bytes. A NUL byte is a binary file, refused.
@@ -571,7 +493,6 @@ func pick(in io.Reader, ask ReadAsk, bound int) ([]line, string, error) {
if from == 0 {
from = 1
}
var blocks blockState
for {
raw, err := br.ReadString('\n')
if raw == "" && err != nil {
@@ -584,8 +505,7 @@ func pick(in io.Reader, ask ReadAsk, bound int) ([]line, string, error) {
if strings.IndexByte(raw, 0) >= 0 {
return nil, "", errors.New("it is a binary file, and read answers text")
}
l := line{n: n, text: strings.TrimSuffix(raw, "\n"), newline: strings.HasSuffix(raw, "\n"), size: len(raw),
block: blocks.in(raw)}
l := line{n: n, text: strings.TrimSuffix(raw, "\n"), newline: strings.HasSuffix(raw, "\n"), size: len(raw)}
if from > 0 {
if n < from {
continue
@@ -638,7 +558,6 @@ type SearchAnswer struct {
Bound string `json:"bound-met,omitempty"`
Skipped []string `json:"refused-by-name,omitempty"`
Unreadable int `json:"unreadable,omitempty"`
OtherFS []string `json:"other-file-systems-skipped,omitempty"`
Binary int `json:"binary-skipped,omitempty"`
Withheld []Withheld `json:"withheld,omitempty"`
}
@@ -688,7 +607,6 @@ func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
}
a := SearchAnswer{Text: ask.Text, Path: root, Matches: []Match{}}
deadline := r.Now().Add(SearchTime)
rootDev, hasDev := deviceOf(root)
base := strings.Count(root, "/")
if root == "/" {
base = 0
@@ -718,12 +636,6 @@ func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
if p != root && strings.Count(p, "/")-base > depth {
return fs.SkipDir
}
// The root's file system only, as grep -r with find's -xdev: a hard network mount would hang a
// read past every bound, and /proc and /sys are searched only from within them.
if dev, ok := deviceOf(p); p != root && hasDev && ok && dev != rootDev {
a.OtherFS = append(a.OtherFS, p)
return fs.SkipDir
}
return nil
}
if !d.Type().IsRegular() {
@@ -734,7 +646,7 @@ func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
return errBound
}
a.Files++
return r.searchFile(p, needle, ask.CaseInsensitive, matches, deadline, &a)
return r.searchFile(p, needle, ask.CaseInsensitive, matches, &a)
})
if err != nil && !errors.Is(err, errBound) {
return SearchAnswer{}, fmt.Errorf("%s cannot be searched: %s", root, why(err))
@@ -742,14 +654,10 @@ func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
return a, nil
}
func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time.Time, a *SearchAnswer) error {
f, _, err := r.openJudged(p, true, false)
func (r Reader) searchFile(p, needle string, fold bool, bound int, a *SearchAnswer) error {
f, err := os.OpenFile(p, os.O_RDONLY|syscall.O_NONBLOCK, 0)
if err != nil {
if errors.Is(err, errRefusedByName) {
a.Skipped = append(a.Skipped, p)
} else {
a.Unreadable++
}
a.Unreadable++
return nil
}
defer f.Close()
@@ -758,7 +666,6 @@ func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time
a.Binary++
return nil
}
var blocks blockState
n := 0
for {
raw, err := br.ReadString('\n')
@@ -766,11 +673,6 @@ func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time
return nil
}
n++
if n%1000 == 0 && r.Now().After(deadline) {
a.Bound = fmt.Sprintf("time: a search runs at most %s", SearchTime)
return errBound
}
block := blocks.in(raw)
hay := raw
if fold {
hay = strings.ToLower(hay)
@@ -784,14 +686,14 @@ func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time
if r := []rune(text); len(r) > MaxLine {
text = string(r[:MaxLine]) + "…"
}
if s, what := clean(text); what != "" || block {
if block {
s, what = "‹withheld: a key or certificate block›", "a key or certificate block"
file := p
for _, f := range []*string{&text} {
if s, what := clean(*f); what != "" {
a.Withheld = append(a.Withheld, Withheld{At: "a matching line", Class: what})
*f = s
}
a.Withheld = append(a.Withheld, Withheld{At: "a matching line", Class: what})
text = s
}
a.Matches = append(a.Matches, Match{File: p, Line: n, Text: text})
a.Matches = append(a.Matches, Match{File: file, Line: n, Text: text})
}
if err != nil {
return nil
@@ -799,25 +701,6 @@ func (r Reader) searchFile(p, needle string, fold bool, bound int, deadline time
}
}
// blockState follows BEGIN … END blocks through a file (the review of #244): a private key's body is base64 a
// line at a time, and one line of it can pass the content rule alone, so every line from a BEGIN through its
// END is withheld — a certificate's too, which costs nothing a read needs.
type blockState struct{ open bool }
func (b *blockState) in(raw string) bool {
switch {
case strings.Contains(raw, "-----BEGIN "):
b.open = !strings.Contains(raw, "-----END ")
return true
case b.open:
if strings.Contains(raw, "-----END ") {
b.open = false
}
return true
}
return false
}
// ---- accounts ----
// Account is one account as the account file says it, never its password field.
@@ -1,11 +1,7 @@
package main
import (
crand "crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"path/filepath"
@@ -454,109 +450,3 @@ func TestItServesTheSeatsVerbs(t *testing.T) {
t.Errorf("verbs: %v", names)
}
}
// The review of #244 (required 1): a link flipped between a harmless file and a secret place, while it is read
// and searched, never answers the secret place's content — what was opened is judged, not the name.
func TestALinkFlippedDuringAReadNeverReadsASecretPlace(t *testing.T) {
r, dir := testReader(t)
secret := filepath.Join(dir, "var/lib/mesh-reads")
r.Places = []string{secret}
write(t, filepath.Join(secret, "broker"), "THE-BROKER-CREDENTIAL\n")
write(t, filepath.Join(dir, "plain"), "THE-PLAIN-WORDS\n")
link := filepath.Join(dir, "shared/link")
if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
t.Fatal(err)
}
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
targets := []string{filepath.Join(dir, "plain"), filepath.Join(secret, "broker")}
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
tmp := link + ".new"
os.Remove(tmp)
if os.Symlink(targets[i%2], tmp) == nil {
os.Rename(tmp, link)
}
}
}()
leaked := 0
for i := 0; i < 5000; i++ {
if a, err := r.Read(link, ReadAsk{}); err == nil && strings.Contains(a.Text, "BROKER") {
leaked++
}
if a, err := r.Search(SearchAsk{Text: "BROKER", Path: filepath.Dir(link)}); err == nil && len(a.Matches) > 0 {
leaked++
}
}
close(stop)
<-done
if leaked > 0 {
t.Errorf("a secret place was answered %d times through a flipped link", leaked)
}
}
// The review of #244 (required 2): every line of a private key's block is withheld, in a read and in a search,
// though the file's name is not refused.
func TestEveryLineOfAKeyBlockIsWithheld(t *testing.T) {
r, dir := testReader(t)
key, err := rsa.GenerateKey(crand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
body := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
p := filepath.Join(dir, "server.pem")
write(t, p, "# the server's key\n"+body+"after = the block\n")
a, err := r.Read(p, ReadAsk{})
if err != nil {
t.Fatal(err)
}
lines := strings.Count(body, "\n")
for _, l := range strings.Split(strings.TrimSpace(body), "\n")[1:] {
if strings.Contains(a.Text, l) && !strings.HasPrefix(l, "-----END") {
t.Fatalf("a line of the key was answered: %q", l)
}
}
if len(a.Withheld) != lines || !strings.Contains(a.Text, "after = the block") || !strings.Contains(a.Text, "the server's key") {
t.Errorf("withheld %d of the block's %d lines; text:\n%s", len(a.Withheld), lines, a.Text)
}
// A search for a character every base64 line holds answers none of the key.
m, err := r.Search(SearchAsk{Text: "A", Path: p, Matches: MaxMatches})
if err != nil {
t.Fatal(err)
}
for _, x := range m.Matches {
if !strings.HasPrefix(x.Text, "‹withheld") {
t.Errorf("a search answered a line of the key: %q", x.Text)
}
}
}
// The review of #244 (required 3 and should): histories, browser profiles, a process's private entries and the
// cluster credentials are refused by name.
func TestHistoriesProfilesAndProcessesAreRefusedByName(t *testing.T) {
var r Refuser
for _, p := range []string{"/home/ops/.zsh_history", "/home/ops/.bash_history", "/root/.psql_history",
"/home/ops/.viminfo", "/home/ops/.mozilla/firefox/x.default/logins.json",
"/home/ops/.config/google-chrome/Default/Cookies", "/home/ops/.config/chromium/Default/Login Data",
"/proc/self/environ", "/proc/1234/mem", "/proc/self/fd/3", "/proc/thread-self/root/etc/shadow",
"/etc/rancher/k3s/k3s.yaml", "/etc/kubernetes/admin.conf"} {
if r.Refused(p) == "" {
t.Errorf("%s is not refused", p)
}
}
for _, p := range []string{"/proc/mdstat", "/proc/self/status", "/home/ops/.config/i3/config", "/etc/hosts"} {
if why := r.Refused(p); why != "" {
t.Errorf("%s is refused: %s", p, why)
}
}
}
@@ -18,7 +18,6 @@ import (
// knownDirs are directories that hold nothing but credentials, wherever they are: an account's keys.
var knownDirs = map[string]bool{
".ssh": true, ".gnupg": true, ".password-store": true, ".aws": true, ".kube": true, ".azure": true,
".mozilla": true, ".thunderbird": true, // browser and mail profiles: saved logins and cookies
}
// knownNames are file names that are a credential whatever directory they are in.
@@ -29,10 +28,6 @@ var knownNames = map[string]bool{
"id_rsa": true, "id_dsa": true, "id_ecdsa": true, "id_ed25519": true, "id_ecdsa_sk": true, "id_ed25519_sk": true,
"shadow": true, "gshadow": true, "shadow-": true, "gshadow-": true, "opasswd": true,
"broker": true, // the mesh's name for a module's bus credential
// Shell and program histories: where a token is typed.
".bash_history": true, ".zsh_history": true, ".histfile": true, ".python_history": true, ".psql_history": true,
".mysql_history": true, ".lesshst": true, ".viminfo": true, ".node_repl_history": true, ".sqlite_history": true,
".rediscli_history": true,
}
// knownSuffixes are file name endings a credential has.
@@ -42,17 +37,8 @@ var knownSuffixes = []string{".key", ".p12", ".pfx", ".jks", ".keystore", ".cred
var knownPaths = []string{
"/etc/ssl/private", "/etc/wireguard", "/etc/NetworkManager/system-connections", "/etc/ipsec.d/private",
"/var/lib/iwd", "/etc/docker/key.json", "/root/.docker/config.json", "/var/lib/sss/secrets",
"/etc/rancher", "/etc/kubernetes/admin.conf",
}
// knownProfiles are directories under an account's .config that hold a browser's saved logins and cookies.
var knownProfiles = map[string]bool{"google-chrome": true, "chromium": true, "BraveSoftware": true, "vivaldi": true,
"microsoft-edge": true}
// procPrivate are the entries of a process under /proc that reach its memory, its environment or its files.
var procPrivate = map[string]bool{"environ": true, "mem": true, "fd": true, "map_files": true, "root": true,
"cwd": true}
// Refuser says whether a path is a place a secret is kept.
type Refuser struct {
// Places are the mesh's, from the controller: refused with everything beneath them.
@@ -73,13 +59,7 @@ func (r Refuser) Refused(path string) string {
}
}
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
if len(parts) >= 3 && parts[0] == "proc" && procPrivate[parts[2]] {
return "it reaches a process's " + parts[2]
}
for i, part := range parts {
if i > 0 && parts[i-1] == ".config" && knownProfiles[part] {
return "it is " + part + "'s profile, which keeps saved logins"
}
if knownDirs[part] {
return "it is in " + part + ", which holds an account's credentials"
}
+1 -1
View File
@@ -2,4 +2,4 @@ module mesh-reads
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23
+2 -2
View File
@@ -1,2 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2 h1:1E5iapAO1o4vkwV/8CUBQvNfL3OjXRtiQGBaKHXJVW8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012122749-67bfa22132f2/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23 h1:/F4NwABPAuFZlBx1yK4rdj6sVL5K1XvRlOnZZfNnlsQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+2 -6
View File
@@ -148,10 +148,7 @@ new message is shown, so it rings once and no second message stands beside the f
notification by its id; Telegram deletes the earlier message). A message said again goes alone, never as a
line of a digest beside the message it follows; one first said inside a digest gets a message of its own the
next time, and later words replace or edit that one. An ask's buttons live on that one message, so an ask that
ends takes them away everywhere; its expiry is said with its day when it is not today. An ask replaced by a
newer one about the same thing ends its own message too, which then says "Replaced by a newer question"; the
newer ask shows a message of its own (issue 548). A message left open by an ask that already ended is ended on
the next minute's sweep.
ends takes them away everywhere; its expiry is said with its day when it is not today.
- **Routing:** urgent to every kind that can send; a warning to the desk where it can send, otherwise to
the channel that reaches the operator away; an ask to every kind that carries one of its answers.
@@ -190,8 +187,7 @@ The controller's `plan-moved` says each walk's start and end: `walk`, `phase` (`
open, one line each; the ones that cleared counted. Delivered with nothing held and nothing open: one line —
and nothing on a kind listed in the setting `only-what-needs-me`. What it carries is marked told on a kind
only once the message was sent there. Until then each is kept owed in the state (`owed`): a message lost with a
restart before it was sent leaves them untold and owed, and the restarted router says each on its own. A
record that clears owes nothing more.
restart before it was sent leaves them untold and owed, and the restarted router says each on its own.
- **The bound:** at its start plus `bound_seconds` (900 when not given), what is held and still open is said,
with "The walk … is still running, since HH:MM", and the cleared counted; the walk holds nothing more. A walk
whose end never comes is forgotten a day after it started. A start heard after its end opens nothing: the
+3 -32
View File
@@ -691,58 +691,30 @@ func (h *Holder) showClosed(rec AskRecord) {
}
told := h.toldLine(rec) // read before the lock: it reads the ask's record
h.mu.Lock()
ownMessage := r.Origin == "ask"
// The first line is how it ended, which an ask's own message shows as its title (sayOn); the rest follows.
switch {
case rec.Warrant != nil && rec.Warrant.Outcome == asks.OutcomeChosen:
r.Answered = chosenLine(rec.Warrant, rec.Closed.In(h.zone())) + "\n" + told
case rec.State == string(asks.OutcomeExpired):
r.Answered = "No longer answerable: nobody answered in time.\n" + strings.TrimSuffix(capital(rec.Ask.OnExpiry), ".") + "."
case rec.State == string(asks.OutcomeReplaced) && !ownMessage:
// On a condition's message, which the newer ask takes over and shows in place.
r.Answered = ""
case rec.State == string(asks.OutcomeReplaced):
// Its own message: the newer ask looks for its message under its own key and opens one of its own, so
// this one ends here, on every channel that showed it (issue 548).
r.Answered = "Replaced by a newer question.\n" + rec.Asker + " asked again about the same thing; that question is the one to answer."
r.Answered = ""
default:
r.Answered = "No longer needed."
}
ownMessage := r.Origin == "ask"
h.mu.Unlock()
if r.told() {
h.edit(r, "answered", false)
}
if ownMessage {
if ownMessage && rec.State != string(asks.OutcomeReplaced) {
h.mu.Lock()
r.Cleared = h.Now()
r.Owed = "" // an ended question is owed to nobody (the review of PR 238)
h.mu.Unlock()
}
h.persist(r)
}
// endLeftovers ends the own message of an ask that already ended and whose message stayed open: one left by a
// router before issue 548 was fixed, whose replaced asks kept their messages for good. Its copies say how the
// ask ended, as its end would have.
func (h *Holder) endLeftovers() {
h.mu.Lock()
var ended []AskRecord
for _, r := range h.open {
if r.Origin != "ask" || r.Ask == "" || !r.Cleared.IsZero() {
continue
}
if e := h.asks[r.Ask]; e != nil && e.rec.State != StateOpen && e.rec.Record == r.Key {
ended = append(ended, e.rec)
}
}
h.mu.Unlock()
sort.Slice(ended, func(i, j int) bool { return ended[i].Record < ended[j].Record })
for _, rec := range ended {
h.Logf("[messenger] the message of %s stayed open after its ask ended (%s): ended now", rec.Record, rec.State)
h.showClosed(rec)
}
}
// chosenLine is how a chosen ask ended, in the operator's words: "You chose Approve at 13:30 on Telegram."
func chosenLine(w *asks.Warrant, at time.Time) string {
who, where := "Somebody", ""
@@ -803,7 +775,6 @@ func (h *Holder) sweepAsks(now time.Time) {
}
}
h.mu.Unlock()
h.endLeftovers()
sort.Strings(expired)
for _, k := range expired {
e := h.askOf(k)
@@ -687,7 +687,6 @@ func (h *Holder) cleared(key string, quiet bool) {
}
old.Cleared = now
old.Pending = ""
old.Owed = "" // what a walk held of it is owed no more: it ended (the review of PR 238)
old.Folded = false
old.Seen = now
told := old.told()
@@ -1,111 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// openAskRecords are the records still open that show an ask as their own message.
func openAskRecords(h *Holder) []string {
h.mu.Lock()
defer h.mu.Unlock()
var out []string
for k, r := range h.open {
if r.Origin == "ask" && r.Cleared.IsZero() {
out = append(out, k)
}
}
return out
}
// Issue 548: an ask replaced by a newer one about the same thing kept its own message open for good, its
// buttons on the phone answering nothing. Two asks with the same About from one asker leave exactly one open
// record, and the older one's message says it was replaced, its buttons gone, on every channel that showed it.
func TestAReplacedAsksMessageEndsWithIt(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
a := ackAsk(c, "d-1")
a.About = "delivery.hq"
submit(t, h, "mesh-delivery", a)
settle(h, c)
if len(tg.sends) != 1 || len(dt.sends) != 1 {
t.Fatalf("the first ask was not shown: telegram %d, desktop %d", len(tg.sends), len(dt.sends))
}
b := ackAsk(c, "d-2")
b.About = "delivery.hq"
b.Headline = "Delivery of hq held, asked again"
submit(t, h, "mesh-delivery", b)
settle(h, c)
if got := openAskRecords(h); len(got) != 1 || got[0] != "ask.mesh-delivery.d-2" {
t.Fatalf("open ask records: %v; want only the newer ask's", got)
}
for _, ch := range []*fakeChannel{tg, dt} {
id := map[string]string{"telegram": "1", "desktop": "g14=1"}[ch.name]
m, edited := ch.edits[id]
for _, r := range ch.replaced {
if r.id == id {
m, edited = r.m, true
}
}
if !edited || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m))
}
}
if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) {
t.Errorf("the older ask was not replaced: %s", store.asks["mesh-delivery.d-1"].State)
}
}
// The leftover of issue 548: a record from before the fix whose ask already ended keeps its message open. The
// next tick ends it, as the ask's end would have.
func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
a := ackAsk(c, "d-1")
a.About = "delivery.hq"
submit(t, h, "mesh-delivery", a)
settle(h, c)
// Ended as replaced the way the router did before the fix: the ask's record only, its message untouched.
key := "mesh-delivery.d-1"
rec := store.asks[key]
rec.State, rec.Closed, rec.Told = string(asks.OutcomeReplaced), c.now(), true
rec.Warrant = &asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", About: a.About, Outcome: asks.OutcomeReplaced,
Words: "a newer ask about the same thing replaced it", At: c.now()}
store.asks[key] = rec
h.keepAsk(key, rec, 99)
if got := openAskRecords(h); len(got) != 1 {
t.Fatalf("the leftover is not set up: %v", got)
}
c.pass(time.Minute)
stand(h, c)
h.Tick()
h.Flush()
if got := openAskRecords(h); len(got) != 0 {
t.Fatalf("the message of an ended ask is still open: %v", got)
}
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
t.Errorf("the leftover's copy was not ended: %v %q %v", ok, m.Text(), labelsOf(m))
}
}
// The review of PR 238: what a walk owed of a record is dropped when the record clears, so a router that
// restarts never says a held message of something that already ended.
func TestAClearedRecordOwesNothing(t *testing.T) {
h, _, _, c, st := newHolder(t)
k := cond("machine.shanks.units", Warning, "2 failed services on shanks")
k.Raised = c.now()
h.Condition(EventRaised, k)
h.mu.Lock()
h.open[k.Key].Owed = "raised"
h.mu.Unlock()
h.cleared(k.Key, false)
if r := h.open[k.Key]; r.Owed != "" {
t.Errorf("a cleared record still owes %q", r.Owed)
}
if r := st.recs[k.Key]; r.Owed != "" {
t.Errorf("a cleared record still owes %q in the state", r.Owed)
}
}
+4 -12
View File
@@ -29,7 +29,7 @@ func limitOf(a map[string]any, fallback int) int {
return fallback
}
func tools(monitor Monitor, users func(context.Context) ([]byte, error), written func(context.Context) (time.Time, error)) []stdio.Tool {
func tools(monitor Monitor, users func(context.Context) ([]byte, error)) []stdio.Tool {
get := func(path string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
@@ -123,28 +123,20 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
return Users(c, arg(a, "user")), nil
}},
{Name: "nats_user_can",
Description: "Whether a bus user may publish or subscribe to a subject, and the grant that allows it — the question behind a request that timed out because the bus refused it. Also whether the running server holds it: `in_force` is true only when the user list on the machine allows it and the server reloaded its configuration after that list was written (novox/hq issue 353: a list sent is not a list the bus enforces until it reloads).",
Description: "Whether a bus user may publish or subscribe to a subject, and the grant that allows it — the question behind a request that timed out because the bus refused it.",
Input: map[string]any{"user": str("the user exactly, e.g. g14.node-tools"), "action": str("publish or subscribe"), "subject": str("a concrete subject, e.g. $KV.claude-code_servers.all.x")},
Run: func(a map[string]any) (any, error) {
c, err := conf()
if err != nil {
return nil, err
}
answer, err := UserCan(c, arg(a, "user"), arg(a, "action"), arg(a, "subject"))
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
at, werr := written(ctx)
varz, verr := get("/varz")
return InForce(answer, at, werr, varz, verr), nil
return UserCan(c, arg(a, "user"), arg(a, "action"), arg(a, "subject"))
}},
}
}
func main() {
if err := stdio.Serve("", tools(LiveMonitor(), ReadUsers, UsersWritten)); err != nil {
if err := stdio.Serve("", tools(LiveMonitor(), ReadUsers)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
-21
View File
@@ -65,24 +65,3 @@ func ReadUsers(ctx context.Context) ([]byte, error) {
}
return out, nil
}
// UsersWritten is when the composed user list the server includes was last written, read in the container
// to the nanosecond (`stat -c %y`): what the server's last reload must come after for the list to be in force.
func UsersWritten(ctx context.Context) (time.Time, error) {
container := env("MESH_NATS_CONTAINER", "mesh-broker-nats")
path := env("MESH_NATS_USERS_FILE", "/etc/nats/accounts.conf")
out, err := exec.CommandContext(ctx, "docker", "exec", container, "stat", "-c", "%y", path).Output()
if err != nil {
return time.Time{}, fmt.Errorf("when the bus's user list was written could not be read inside %s: %w", container, err)
}
return ParseWritten(string(out))
}
// ParseWritten reads `stat -c %y`: "2026-10-09 17:38:13.422211788 +0000".
func ParseWritten(s string) (time.Time, error) {
t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700", strings.TrimSpace(s))
if err != nil {
return time.Time{}, fmt.Errorf("when the bus's user list was written is not a time: %q", strings.TrimSpace(s))
}
return t, nil
}
-36
View File
@@ -536,39 +536,3 @@ func UserCan(conf []byte, user, action, subject string) (map[string]any, error)
}
return nil, fmt.Errorf("the bus has no user %q; nats_users lists them", user)
}
// InForce adds to a UserCan answer whether the running server holds what the file says (novox/hq issue 353):
// `in_force` only when the grant is allowed in the file and the server's last configuration load
// (`config_load_time` of /varz) came after the file was last written. Anything not known is not in force, said
// in `why`: an asker waiting for its grant must not take a list sent for a list the bus enforces.
func InForce(answer map[string]any, written time.Time, writtenErr error, varz []byte, varzErr error) map[string]any {
answer["in_force"] = false
switch {
case writtenErr != nil:
answer["why"] = writtenErr.Error()
return answer
case varzErr != nil:
answer["why"] = "the server's last configuration load could not be read: " + varzErr.Error()
return answer
}
var v struct {
ConfigLoadTime time.Time `json:"config_load_time"`
}
if err := json.Unmarshal(varz, &v); err != nil || v.ConfigLoadTime.IsZero() {
answer["why"] = "the server does not say when it last loaded its configuration"
return answer
}
answer["file_written"] = written.UTC().Format(time.RFC3339Nano)
answer["server_loaded"] = v.ConfigLoadTime.UTC().Format(time.RFC3339Nano)
loaded := !v.ConfigLoadTime.Before(written)
allowed, _ := answer["allowed"].(bool)
switch {
case !allowed:
answer["why"] = "the user list on the machine does not allow it"
case !loaded:
answer["why"] = "the user list on the machine allows it, and the server has not reloaded since it was written"
default:
answer["in_force"] = true
}
return answer
}
-38
View File
@@ -2,8 +2,6 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
@@ -172,39 +170,3 @@ func TestClosedConnectionsArePerUserAndNameAndSayWhichWereDropped(t *testing.T)
t.Errorf("narrowed: %+v", got)
}
}
// novox/hq issue 353: a grant written to the user list is in force only once the server reloaded after the
// write; anything not known is not in force.
func TestAGrantIsInForceOnlyWhenTheServerReloadedAfterTheListWasWritten(t *testing.T) {
written, err := ParseWritten("2026-10-09 17:55:56.422211788 +0200\n")
if err != nil {
t.Fatal(err)
}
varz := func(at string) []byte { return []byte(`{"config_load_time":"` + at + `"}`) }
allowed := func() map[string]any { return map[string]any{"allowed": true} }
cases := []struct {
name string
answer map[string]any
werr error
varz []byte
verr error
want bool
says string
}{
{"reloaded after the write", allowed(), nil, varz("2026-10-09T15:56:05.1Z"), nil, true, ""},
{"reloaded before the write", allowed(), nil, varz("2026-10-09T15:55:56.4Z"), nil, false, "has not reloaded"},
{"not allowed in the file", map[string]any{"allowed": false}, nil, varz("2026-10-09T15:56:05Z"), nil, false, "does not allow"},
{"the write time unknown", allowed(), errors.New("no container"), varz("2026-10-09T15:56:05Z"), nil, false, "no container"},
{"the server unknown", allowed(), nil, nil, errors.New("refused"), false, "could not be read"},
{"a server that says no load time", allowed(), nil, []byte(`{}`), nil, false, "does not say"},
}
for _, c := range cases {
got := InForce(c.answer, written, c.werr, c.varz, c.verr)
if got["in_force"] != c.want || (c.says != "" && !strings.Contains(fmt.Sprint(got["why"]), c.says)) {
t.Errorf("%s: %v", c.name, got)
}
}
if _, err := ParseWritten("yesterday"); err == nil {
t.Error("a write time that is not one was read")
}
}