forgekey keeps the command runner: the key keeper and key parsing are ssh-client's sshkeys, KindBuilder, ExecRunnerClean and Said the delivery sdk's (S-3, B9f; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
@@ -1,127 +0,0 @@
|
||||
// Package forgekey is the key keeper every module that reaches the forge over SSH shares: ssh-client for a
|
||||
// machine's accounts, a build module for its own (design 47 R4c-3, ADR 0466 §2; issue 1469).
|
||||
package forgekey
|
||||
|
||||
// The key this machine's account uses to reach the forge over SSH (novox/hq ADR 0399, ADR 0400; issue 955,
|
||||
// area S1). It is made here, on the machine, once: ssh-keygen writes ~/.ssh/mesh_ed25519 only when it is
|
||||
// missing, so the private half never leaves the machine and an existing key is never replaced. The public
|
||||
// half and its fingerprint go into this module's `keys` state, one entry per account under <node>.<account>,
|
||||
// for the forge's provider to register. The tools bundle is the one process of this module, so the keeper
|
||||
// runs inside it beside the tools (main.go), the way claude-code reports what it holds.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KeyFile is the key's name under ~/.ssh: never one of the operator's own id_* files.
|
||||
const KeyFile = "mesh_ed25519"
|
||||
|
||||
// The kinds of key (ADR 0400 §1, ADR 0466 §2): a machine's account's own, and a build module's.
|
||||
const (
|
||||
KindMachine = "machine"
|
||||
KindBuilder = "builder"
|
||||
)
|
||||
|
||||
// KeyRetry is how soon it looks again after a failure.
|
||||
const KeyRetry = 30 * time.Second
|
||||
|
||||
// KeyRound is how often the keeper looks again.
|
||||
const KeyRound = 10 * time.Minute
|
||||
|
||||
// KeyEntry is one value of the keys state. It carries the public half only.
|
||||
type KeyEntry struct {
|
||||
Node string `json:"node"`
|
||||
Account string `json:"account"`
|
||||
Kind string `json:"kind"`
|
||||
Public string `json:"public"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
// KeyPutter is the keys state, as far as the keeper needs it.
|
||||
type KeyPutter interface {
|
||||
Put(key string, value any) (uint64, error)
|
||||
}
|
||||
|
||||
// KeyKeeper keeps one account's key and its entry.
|
||||
type KeyKeeper struct {
|
||||
Home, Node, Account string
|
||||
Kind string // KindMachine or KindBuilder
|
||||
Run Runner
|
||||
State KeyPutter
|
||||
Now func() time.Time
|
||||
// After waits between rounds; time.After when nil. A test sets it to see which pause was chosen.
|
||||
After func(time.Duration) <-chan time.Time
|
||||
|
||||
reported string // the public half and fingerprint last put
|
||||
}
|
||||
|
||||
func (k *KeyKeeper) path() string { return filepath.Join(k.Home, ".ssh", KeyFile) }
|
||||
|
||||
// Once makes the key when it is missing and puts the entry when it differs from the one last put.
|
||||
func (k *KeyKeeper) Once(ctx context.Context) error {
|
||||
if err := k.ensure(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(k.path() + ".pub")
|
||||
if err != nil {
|
||||
return fmt.Errorf("the public half: %w", err)
|
||||
}
|
||||
pk, err := ParseKeyLine(string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s.pub: %w", k.path(), err)
|
||||
}
|
||||
public := strings.TrimSpace(string(raw))
|
||||
if sig := public + "|" + pk.Fingerprint; sig == k.reported {
|
||||
return nil
|
||||
}
|
||||
entry := KeyEntry{Node: k.Node, Account: k.Account, Kind: k.Kind, Public: public,
|
||||
Fingerprint: pk.Fingerprint, At: k.Now().UTC().Format(time.RFC3339)}
|
||||
if _, err := k.State.Put(k.Node+"."+k.Account, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
k.reported = public + "|" + pk.Fingerprint
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensure makes the pair when the private half is missing. Never over an existing key.
|
||||
func (k *KeyKeeper) ensure(ctx context.Context) error {
|
||||
if _, err := os.Lstat(k.path()); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(k.path()), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
r := k.Run(ctx, nil, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", k.Account+"@"+k.Node+" (mesh)", "-f", k.path())
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return fmt.Errorf("ssh-keygen: %s", Said(r.Err+"\n"+r.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keep runs Once now and every KeyRound, reporting a failure through say and trying again at the next round.
|
||||
func (k *KeyKeeper) Keep(ctx context.Context, say func(string, ...any)) {
|
||||
for {
|
||||
pause := KeyRound
|
||||
if err := k.Once(ctx); err != nil {
|
||||
say("the forge key: %v", err)
|
||||
pause = KeyRetry // the state or the bus's grant may arrive after the process
|
||||
}
|
||||
after := k.After
|
||||
if after == nil {
|
||||
after = time.After
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-after(pause):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package forgekey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue 955, area S1: the key the machine's account makes for the forge, and its entry in the keys state.
|
||||
|
||||
type fakeState struct{ puts map[string][]byte }
|
||||
|
||||
func (f *fakeState) Put(key string, value any) (uint64, error) {
|
||||
raw, _ := json.Marshal(value)
|
||||
if f.puts == nil {
|
||||
f.puts = map[string][]byte{}
|
||||
}
|
||||
f.puts[key] = raw
|
||||
return uint64(len(f.puts)), nil
|
||||
}
|
||||
|
||||
// ed25519Line is a public key line with a well-formed ed25519 blob.
|
||||
func ed25519Line(seed byte, comment string) string {
|
||||
blob := []byte{}
|
||||
for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} {
|
||||
part2 := append([]byte(nil), part...)
|
||||
if len(part2) == 32 {
|
||||
part2[0] = seed
|
||||
}
|
||||
l := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(l, uint32(len(part2)))
|
||||
blob = append(append(blob, l...), part2...)
|
||||
}
|
||||
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment
|
||||
}
|
||||
|
||||
// keygenRunner stands in for ssh-keygen -t ed25519: it writes the pair the command names.
|
||||
func keygenRunner(calls *[][]string) Runner {
|
||||
return func(ctx context.Context, env []string, name string, args ...string) Ran {
|
||||
*calls = append(*calls, append([]string{name}, args...))
|
||||
var file, comment string
|
||||
for i, a := range args {
|
||||
switch a {
|
||||
case "-f":
|
||||
file = args[i+1]
|
||||
case "-C":
|
||||
comment = args[i+1]
|
||||
}
|
||||
}
|
||||
if name != "ssh-keygen" || file == "" {
|
||||
return Ran{Status: 1, Stderr: "unexpected"}
|
||||
}
|
||||
os.WriteFile(file, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE-SECRET\n"), 0o600)
|
||||
os.WriteFile(file+".pub", []byte(ed25519Line(7, comment)+"\n"), 0o644)
|
||||
return Ran{}
|
||||
}
|
||||
}
|
||||
|
||||
func newKeeper(t *testing.T, home string, st *fakeState, calls *[][]string) *KeyKeeper {
|
||||
return &KeyKeeper{Home: home, Node: "g14", Account: "nox", Kind: KindMachine, Run: keygenRunner(calls), State: st,
|
||||
Now: func() time.Time { return time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) }}
|
||||
}
|
||||
|
||||
func TestKeeperMakesTheKeyAndWritesItsEntry(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
st, calls := &fakeState{}, &[][]string{}
|
||||
if err := newKeeper(t, home, st, calls).Once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, ok := st.puts["g14.nox"]
|
||||
if !ok || len(st.puts) != 1 {
|
||||
t.Fatalf("entries = %v, want one under g14.nox", st.puts)
|
||||
}
|
||||
var e map[string]string
|
||||
json.Unmarshal(raw, &e)
|
||||
if e["node"] != "g14" || e["account"] != "nox" || e["kind"] != "machine" || e["at"] != "2026-10-16T12:00:00Z" {
|
||||
t.Errorf("entry = %v", e)
|
||||
}
|
||||
if !strings.HasPrefix(e["public"], "ssh-ed25519 ") || !strings.HasSuffix(e["public"], " nox@g14 (mesh)") {
|
||||
t.Errorf("public = %q", e["public"])
|
||||
}
|
||||
if !strings.HasPrefix(e["fingerprint"], "SHA256:") {
|
||||
t.Errorf("fingerprint = %q", e["fingerprint"])
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
|
||||
t.Errorf("the entry carries private material: %s", raw)
|
||||
}
|
||||
cmd := strings.Join((*calls)[0], " ")
|
||||
for _, want := range []string{"-t ed25519", "-N ", "-C nox@g14 (mesh)", filepath.Join(home, ".ssh", "mesh_ed25519")} {
|
||||
if !strings.Contains(cmd, want) {
|
||||
t.Errorf("ssh-keygen call %q lacks %q", cmd, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeeperKeepsAnExistingKeyByteForByte(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(home, ".ssh"), 0o700)
|
||||
priv, pub := filepath.Join(home, ".ssh", "mesh_ed25519"), filepath.Join(home, ".ssh", "mesh_ed25519.pub")
|
||||
os.WriteFile(priv, []byte("mine"), 0o600)
|
||||
os.WriteFile(pub, []byte(ed25519Line(9, "nox@g14 (mesh)")+"\n"), 0o644)
|
||||
st, calls := &fakeState{}, &[][]string{}
|
||||
k := newKeeper(t, home, st, calls)
|
||||
if err := k.Once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*calls) != 0 {
|
||||
t.Errorf("ssh-keygen ran over an existing key: %v", *calls)
|
||||
}
|
||||
if b, _ := os.ReadFile(priv); string(b) != "mine" {
|
||||
t.Errorf("private file changed: %q", b)
|
||||
}
|
||||
if _, ok := st.puts["g14.nox"]; !ok {
|
||||
t.Errorf("no entry for the existing key")
|
||||
}
|
||||
// A second round with nothing changed writes nothing.
|
||||
delete(st.puts, "g14.nox")
|
||||
k.Once(context.Background())
|
||||
if len(st.puts) != 0 {
|
||||
t.Errorf("an unchanged key was put again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeeperNeverTouchesTheOperatorsOwnKeys(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(home, ".ssh"), 0o700)
|
||||
own := filepath.Join(home, ".ssh", "id_ed25519")
|
||||
os.WriteFile(own, []byte("operator"), 0o600)
|
||||
st, calls := &fakeState{}, &[][]string{}
|
||||
newKeeper(t, home, st, calls).Once(context.Background())
|
||||
if b, _ := os.ReadFile(own); string(b) != "operator" {
|
||||
t.Errorf("the operator's key changed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1469/AC1: an empty home, kind builder, account mesh-build-go: ssh-keygen once, for the one file, and the entry
|
||||
// carries the kind, the public half and its fingerprint.
|
||||
func TestBuilderKeyIsMadeOnceAndPutWithItsKind(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
st, calls := &fakeState{}, &[][]string{}
|
||||
k := newKeeper(t, home, st, calls)
|
||||
k.Account, k.Kind = "mesh-build-go", KindBuilder
|
||||
if err := k.Once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*calls) != 1 || !strings.Contains(strings.Join((*calls)[0], " "), filepath.Join(home, ".ssh", "mesh_ed25519")) {
|
||||
t.Errorf("ssh-keygen calls = %v, want one for mesh_ed25519 in the home", *calls)
|
||||
}
|
||||
var e KeyEntry
|
||||
if err := json.Unmarshal(st.puts["g14.mesh-build-go"], &e); err != nil {
|
||||
t.Fatalf("entry under g14.mesh-build-go: %v (%v)", err, st.puts)
|
||||
}
|
||||
pub, _ := os.ReadFile(filepath.Join(home, ".ssh", "mesh_ed25519.pub"))
|
||||
pk, _ := ParseKeyLine(string(pub))
|
||||
if e.Kind != "builder" || e.Public != strings.TrimSpace(string(pub)) || e.Fingerprint != pk.Fingerprint || e.Fingerprint == "" {
|
||||
t.Errorf("entry = %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 1469/AC2: an existing key, Once twice: ssh-keygen never, one put.
|
||||
func TestExistingKeyIsPutOnceAcrossTwoRounds(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(home, ".ssh"), 0o700)
|
||||
os.WriteFile(filepath.Join(home, ".ssh", "mesh_ed25519"), []byte("mine"), 0o600)
|
||||
os.WriteFile(filepath.Join(home, ".ssh", "mesh_ed25519.pub"), []byte(ed25519Line(9, "x")+"\n"), 0o644)
|
||||
st, calls := &countState{}, &[][]string{}
|
||||
k := newKeeper(t, home, &fakeState{}, calls)
|
||||
k.State = st
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := k.Once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(*calls) != 0 || st.n != 1 {
|
||||
t.Errorf("ssh-keygen calls = %d, puts = %d, want 0 and 1", len(*calls), st.n)
|
||||
}
|
||||
}
|
||||
|
||||
type countState struct{ n int }
|
||||
|
||||
func (c *countState) Put(string, any) (uint64, error) { c.n++; return uint64(c.n), nil }
|
||||
|
||||
// 1469/AC3: the entry's JSON is ssh-client's own (mesh-catalog modules/ssh-client keykeeper_test.go's keys
|
||||
// entry: node, account, kind, public, fingerprint, at, in that order); mesh-catalog's gitea provider reads it.
|
||||
func TestKeyEntryJSONIsSSHClients(t *testing.T) {
|
||||
raw, err := json.Marshal(KeyEntry{Node: "g14", Account: "nox", Kind: "machine", Public: "ssh-ed25519 AAAA nox@g14 (mesh)",
|
||||
Fingerprint: "SHA256:abc", At: "2026-10-16T12:00:00Z"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"node":"g14","account":"nox","kind":"machine","public":"ssh-ed25519 AAAA nox@g14 (mesh)","fingerprint":"SHA256:abc","at":"2026-10-16T12:00:00Z"}`
|
||||
if string(raw) != want {
|
||||
t.Errorf("entry JSON = %s\nwant %s", raw, want)
|
||||
}
|
||||
}
|
||||
|
||||
type failState struct{}
|
||||
|
||||
func (failState) Put(string, any) (uint64, error) { return 0, errors.New("no grant yet") }
|
||||
|
||||
// 1469/AC4: a failing put is said, and the next look is after KeyRetry, not KeyRound.
|
||||
func TestKeepSaysAFailedPutAndRetriesSoon(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
k := newKeeper(t, home, &fakeState{}, &[][]string{})
|
||||
k.State = failState{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var said []string
|
||||
var pauses []time.Duration
|
||||
k.After = func(d time.Duration) <-chan time.Time {
|
||||
pauses = append(pauses, d)
|
||||
cancel()
|
||||
return make(chan time.Time)
|
||||
}
|
||||
k.Keep(ctx, func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) })
|
||||
if len(said) != 1 || !strings.Contains(said[0], "no grant yet") {
|
||||
t.Errorf("said = %v", said)
|
||||
}
|
||||
if len(pauses) != 1 || pauses[0] != KeyRetry {
|
||||
t.Errorf("pauses = %v, want [%v]", pauses, KeyRetry)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package forgekey
|
||||
|
||||
// Keys, by their public half only. A fingerprint is computed here from the public key's bytes, as
|
||||
// ssh-keygen -l does (SHA256 of the key blob, unpadded base64); a private key is never read by this
|
||||
// process — its first line, the PEM header, says it is one, and ssh-keygen is asked about it.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeyTypes are the public key algorithms an authorized_keys or known_hosts line can carry.
|
||||
var KeyTypes = map[string]bool{
|
||||
"ssh-ed25519": true, "ssh-rsa": true, "ssh-dss": true,
|
||||
"ecdsa-sha2-nistp256": true, "ecdsa-sha2-nistp384": true, "ecdsa-sha2-nistp521": true,
|
||||
"sk-ssh-ed25519@openssh.com": true, "sk-ecdsa-sha2-nistp256@openssh.com": true,
|
||||
}
|
||||
|
||||
// PublicKey is one public key as a line carries it.
|
||||
type PublicKey struct {
|
||||
Type string `json:"type"`
|
||||
Bits int `json:"bits"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Comment string `json:"comment"`
|
||||
Options string `json:"options,omitempty"`
|
||||
Weak string `json:"weak,omitempty"`
|
||||
}
|
||||
|
||||
// ParseKeyLine reads one authorized_keys line (options, type, key, comment) or a public key file's.
|
||||
func ParseKeyLine(line string) (PublicKey, error) {
|
||||
fields := fieldsQuoted(strings.TrimSpace(line))
|
||||
for i, f := range fields {
|
||||
if !KeyTypes[f] || i+1 >= len(fields) {
|
||||
continue
|
||||
}
|
||||
k := PublicKey{Type: f, Options: strings.Join(fields[:i], " "), Comment: strings.Join(fields[i+2:], " ")}
|
||||
blob, err := base64.StdEncoding.DecodeString(fields[i+1])
|
||||
if err != nil {
|
||||
return k, fmt.Errorf("the key after %s is not base64", f)
|
||||
}
|
||||
sum := sha256.Sum256(blob)
|
||||
k.Fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
|
||||
k.Bits = bitsOf(f, blob)
|
||||
switch {
|
||||
case f == "ssh-dss":
|
||||
k.Weak = "DSA: refused by current OpenSSH"
|
||||
case f == "ssh-rsa" && k.Bits > 0 && k.Bits < 3072:
|
||||
k.Weak = fmt.Sprintf("RSA of %d bits: below 3072", k.Bits)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
return PublicKey{}, fmt.Errorf("no public key on this line")
|
||||
}
|
||||
|
||||
// fieldsQuoted splits on spaces outside double quotes, as sshd reads an options field.
|
||||
func fieldsQuoted(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
quoted := false
|
||||
for _, ch := range s {
|
||||
switch {
|
||||
case ch == '"':
|
||||
quoted = !quoted
|
||||
cur.WriteRune(ch)
|
||||
case (ch == ' ' || ch == '\t') && !quoted:
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bitsOf reads a key's size from its blob: the RSA modulus, the curve, or ed25519's fixed 256.
|
||||
func bitsOf(kind string, blob []byte) int {
|
||||
strs := [][]byte{}
|
||||
for len(blob) >= 4 {
|
||||
n := binary.BigEndian.Uint32(blob)
|
||||
if int(n) > len(blob)-4 {
|
||||
break
|
||||
}
|
||||
strs = append(strs, blob[4:4+n])
|
||||
blob = blob[4+n:]
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(kind, "ed25519"):
|
||||
return 256
|
||||
case kind == "ssh-rsa" && len(strs) >= 3:
|
||||
return new(big.Int).SetBytes(strs[2]).BitLen()
|
||||
case kind == "ssh-dss" && len(strs) >= 2:
|
||||
return new(big.Int).SetBytes(strs[1]).BitLen()
|
||||
case strings.Contains(kind, "nistp256"):
|
||||
return 256
|
||||
case strings.Contains(kind, "nistp384"):
|
||||
return 384
|
||||
case strings.Contains(kind, "nistp521"):
|
||||
return 521
|
||||
}
|
||||
return 0
|
||||
}
|
||||
+9
-72
@@ -7,9 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Ran is what a command did: its output, its exit status, and why it never ran to an answer.
|
||||
@@ -38,33 +36,19 @@ func ExecRunner(ctx context.Context, env []string, name string, args ...string)
|
||||
// step's own context ends a command, and a fetch may outlast a tool call's thirty seconds.
|
||||
func ExecRunnerFor(bound time.Duration) Runner {
|
||||
return func(ctx context.Context, env []string, name string, args ...string) Ran {
|
||||
return execRun(ctx, bound, false, env, name, args...)
|
||||
return execRun(ctx, bound, env, name, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// ExecRunnerClean is ExecRunnerFor whose commands get exactly the env argument as their environment, and nothing of
|
||||
// this process's (issue 1497; novox/hq ADR 0466 §4: no registry password in a fetch's environment). The caller
|
||||
// gives PATH and HOME itself. A directory is the command's own business (`git -C`, `go -C`): it is started in the HOME
|
||||
// it is given, or the machine's temporary directory, never in this process's (stableDir).
|
||||
func ExecRunnerClean(bound time.Duration) Runner {
|
||||
return func(ctx context.Context, env []string, name string, args ...string) Ran {
|
||||
return execRun(ctx, bound, true, env, name, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, bound time.Duration, clean bool, env []string, name string, args ...string) Ran {
|
||||
func execRun(ctx context.Context, bound time.Duration, env []string, name string, args ...string) Ran {
|
||||
if bound > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, bound)
|
||||
defer cancel()
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if clean {
|
||||
cmd.Env = append([]string{}, env...) // non-nil, so an empty list is an empty environment
|
||||
} else {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
cmd.Dir = stableDir(clean, env)
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
cmd.Dir = stableDir()
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||
err := cmd.Run()
|
||||
@@ -88,58 +72,11 @@ func execRun(ctx context.Context, bound time.Duration, clean bool, env []string,
|
||||
|
||||
// stableDir is the directory a command starts in when it names none (novox/hq issue 734). **Never one that may be
|
||||
// deleted under it**: a module runs in the directory its build was unpacked in, which a new build replaces, and a
|
||||
// command started there inherits it — `go version` then fails "getwd: no such file or directory" before it reads
|
||||
// anything. A clean command starts in the HOME it is given, or the temporary directory; any other keeps this
|
||||
// process's directory while it exists, and starts in the temporary directory once it is gone. "" is this process's.
|
||||
func stableDir(clean bool, env []string) string {
|
||||
if !clean {
|
||||
if _, err := os.Getwd(); err == nil {
|
||||
return ""
|
||||
}
|
||||
return os.TempDir()
|
||||
}
|
||||
for i := len(env) - 1; i >= 0; i-- {
|
||||
if home, ok := strings.CutPrefix(env[i], "HOME="); ok {
|
||||
if info, err := os.Stat(home); err == nil && info.IsDir() {
|
||||
return home
|
||||
}
|
||||
break
|
||||
}
|
||||
// command started there inherits it. It keeps this process's directory ("") while it exists, and starts in the
|
||||
// temporary directory once it is gone.
|
||||
func stableDir() string {
|
||||
if _, err := os.Getwd(); err == nil {
|
||||
return ""
|
||||
}
|
||||
return os.TempDir()
|
||||
}
|
||||
|
||||
// SaidBound bounds what Said keeps of a command's words, so a reason stays a line of a step's record and of a log.
|
||||
const SaidBound = 600
|
||||
|
||||
// Said is what a command said of its failure in its own words, for a reason: every line that says something, trimmed
|
||||
// and joined with "; ", bounded at SaidBound bytes. A line that is a bare prefix and nothing else (git writes "error:"
|
||||
// alone before and after the forge's own message, "remote:" or "fatal:" likewise) is left out, so the cause is never
|
||||
// cut off behind it: the first line alone of `git fetch` from a repository the account cannot read was "error:".
|
||||
func Said(s string) string {
|
||||
var keep []string
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
l = strings.TrimSpace(l)
|
||||
if l == "" || barePrefix(l) {
|
||||
continue
|
||||
}
|
||||
keep = append(keep, l)
|
||||
}
|
||||
out := strings.Join(keep, "; ")
|
||||
if len(out) > SaidBound {
|
||||
cut := SaidBound
|
||||
for cut > 0 && !utf8.RuneStart(out[cut]) {
|
||||
cut--
|
||||
}
|
||||
out = out[:cut] + " …"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// barePrefix says whether a line is one word and a colon, such as "error:" or "remote:", with nothing after it.
|
||||
func barePrefix(l string) bool {
|
||||
if !strings.HasSuffix(l, ":") {
|
||||
return false
|
||||
}
|
||||
return !strings.ContainsAny(strings.TrimSuffix(l, ":"), " \t:")
|
||||
}
|
||||
|
||||
@@ -48,26 +48,15 @@ func TestExecRunnerForZeroIsCutByItsContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1497/AC4: ExecRunnerClean(0) runs env with only the given words: PATH and HOME, not the process's secret. And issue
|
||||
// 734: run from a process whose own directory was deleted under it (a module whose build a new one replaced), a clean
|
||||
// command starts in its HOME, and any other in the temporary directory, rather than failing to find where it is.
|
||||
func TestExecRunnerCleanGivesTheCommandOnlyTheEnvArgument(t *testing.T) {
|
||||
t.Setenv("MESH_TEST_SECRET", "x")
|
||||
r := ExecRunnerClean(0)(context.Background(), []string{"PATH=" + os.Getenv("PATH"), "HOME=/h"}, "env")
|
||||
if r.Status != 0 || !strings.Contains(r.Stdout, "HOME=/h") || !strings.Contains(r.Stdout, "PATH=") || strings.Contains(r.Stdout, "MESH_TEST_SECRET") {
|
||||
t.Errorf("ran = %+v", r)
|
||||
}
|
||||
|
||||
home, gone := t.TempDir(), t.TempDir()
|
||||
// Issue 734: run from a process whose own directory was deleted under it (a module whose build a new one replaced), a
|
||||
// command starts in the temporary directory rather than failing to find where it is.
|
||||
func TestExecRunnerForStartsInTheTemporaryDirectoryWhenItsOwnIsGone(t *testing.T) {
|
||||
gone := t.TempDir()
|
||||
t.Chdir(gone)
|
||||
if err := os.Remove(gone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = ExecRunnerClean(0)(context.Background(), []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home}, "pwd")
|
||||
if r.Status != 0 || strings.TrimSpace(r.Stdout) != home {
|
||||
t.Errorf("a clean command run from a deleted directory = %+v, want it started in its HOME %s", r, home)
|
||||
}
|
||||
r = ExecRunnerFor(0)(context.Background(), nil, "pwd")
|
||||
r := ExecRunnerFor(0)(context.Background(), nil, "pwd")
|
||||
if r.Status != 0 || strings.TrimSpace(r.Stdout) != os.TempDir() {
|
||||
t.Errorf("a command run from a deleted directory = %+v, want it started in %s", r, os.TempDir())
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package forgekey
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// git's own words survive as the reason: a bare "error:" or "fatal:" line is never the whole reason (the mirror of
|
||||
// jschoubben/car-hunter "is not used: git -C: error:", 2026-10-26), every line that says something is kept, and the
|
||||
// words are bounded.
|
||||
func TestSaidKeepsTheCauseBehindABarePrefix(t *testing.T) {
|
||||
forge := "error: \nerror: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.\nerror: \nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.\n"
|
||||
for _, c := range []struct{ name, in, want string }{
|
||||
{"forge refusal", forge, "error: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.; fatal: Could not read from remote repository.; Please make sure you have the correct access rights; and the repository exists."},
|
||||
{"one line", "fatal: couldn't find remote ref x\n", "fatal: couldn't find remote ref x"},
|
||||
{"bare prefixes only", "error:\nremote:\n", ""},
|
||||
{"a colon inside stays", " ENOENT \nfatal: bad: thing\n", "ENOENT; fatal: bad: thing"},
|
||||
{"empty", "", ""},
|
||||
} {
|
||||
if got := Said(c.in); got != c.want {
|
||||
t.Errorf("%s: Said = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
long := Said(strings.Repeat("é", SaidBound))
|
||||
if !strings.HasSuffix(long, " …") || len(long) > SaidBound+len(" …") || !strings.HasPrefix(long, "é") {
|
||||
t.Errorf("long: %d bytes, ends %q", len(long), long[len(long)-6:])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user