125 lines
5.3 KiB
Go
125 lines
5.3 KiB
Go
package main
|
|
|
|
// The tools never open a password file (novox/hq ADR 0436 §12, issue 1193): ReadSecret and SecretPath moved
|
|
// into cmd/mesh-mail/internal/secret, imported only by keep (imap.go) and — once issue 1195 adds it — the
|
|
// sender's own process (sender.go). Since issue 1194, Decide no longer reaches any actor that reads a secret
|
|
// at all (it records approved and queues the send for the sender); the import list this test checks still
|
|
// holds, now even more plainly. The build seat runs every test under one account, so no permission test can
|
|
// show the split (context file, "Clause 12's proof"); this is the file-level proxy the lead chose instead: an
|
|
// AST check over the tools' own files.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// secretPackage is the import path the tools' bundle must never carry.
|
|
const secretPackage = "mesh-mail/cmd/mesh-mail/internal/secret"
|
|
|
|
// 1193/AC1: none of the tools' bundle files (main.go's serve path, tools.go, send.go, health.go) imports the
|
|
// password-reading package; the failure names the offending file.
|
|
func TestTheToolsBundleNeverImportsThePasswordPackage(t *testing.T) {
|
|
for _, file := range []string{"main.go", "tools.go", "send.go", "health.go"} {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", file, err)
|
|
}
|
|
for _, imp := range f.Imports {
|
|
path := strings.Trim(imp.Path.Value, `"`)
|
|
if path == secretPackage {
|
|
t.Errorf("%s imports %s, which only keep and the sender's actor may (novox/hq ADR 0436 §12)", file, secretPackage)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1193/AC2: keep (imap.go) and the sender (sender.go, issue 1195) read the password through the moved
|
|
// package; SecretGiven (stat only) stays in config.go, available to the tools, and never reads the password
|
|
// itself. (sender.go's own account file is read through secret.Path too, though its own account is not a
|
|
// password; the real per-message password read is issue 1196's.)
|
|
func TestKeepAndTheSenderReadThePasswordThroughTheMovedPackage(t *testing.T) {
|
|
for _, file := range []string{"imap.go", "sender.go"} {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", file, err)
|
|
}
|
|
found := false
|
|
for _, imp := range f.Imports {
|
|
if strings.Trim(imp.Path.Value, `"`) == secretPackage {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("%s does not import %s", file, secretPackage)
|
|
}
|
|
}
|
|
// SecretGiven itself (config.go) never calls ReadFile, os.Open or the moved package: whether a password was
|
|
// given is read from the file's presence and size alone (issue 733, kept by config_test.go's own check too).
|
|
src, err := os.ReadFile("config.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := string(src)
|
|
body = body[strings.Index(body, "func SecretGiven("):]
|
|
body = body[:strings.Index(body, "\n}\n")]
|
|
for _, bad := range []string{"ReadSecret", "ReadFile", "os.Open", "secret.Read"} {
|
|
if strings.Contains(body, bad) {
|
|
t.Fatalf("SecretGiven reads the password (%s):\n%s", bad, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1193/AC3: with the secrets directory absent, mail_send raises its ask, the warrant of an Approve is heard,
|
|
// and mail_send_status reads it back — each succeeds, and no error mentions a secret file. Since issue 1194
|
|
// the tools never read a secret on this path at all (they queue the send for the sender), so this also proves
|
|
// the stronger property the tools' bundle never touches b.Env.Secrets.
|
|
func TestTheToolPathSucceedsWithTheSecretsDirectoryAbsent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
missing := filepath.Join(dir, "never-created")
|
|
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: missing}
|
|
writeFile(t, env.Accounts, `{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be",
|
|
"incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"},
|
|
"outgoing": {"host": "h", "port": 465, "security": "tls"}}}}`)
|
|
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
|
|
router, sends, queue := newSendRouter(), newMemSends(), newMemQueue()
|
|
b := &Bundle{Env: env, Now: func() time.Time { return judgedAt }, Asks: router, Sends: sends, Queue: queue.publish}
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if isErr {
|
|
t.Fatalf("mail_send: %s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
|
|
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
|
router.close(w)
|
|
d, err := Decide(sends, b.queuePublish(), w, judgedAt.Add(2*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("the warrant was not heard cleanly: %v", err)
|
|
}
|
|
if d.Outcome != "queued" {
|
|
t.Fatalf("the decision: %+v; want queued (the tools never read the secrets directory)", d)
|
|
}
|
|
if strings.Contains(d.Outcome, missing) || strings.Contains(strings.ToLower(d.Outcome), "secret") {
|
|
t.Fatalf("the decision names a secret file: %q", d.Outcome)
|
|
}
|
|
|
|
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
|
if isErr {
|
|
t.Fatalf("mail_send_status: %s", text)
|
|
}
|
|
if strings.Contains(text, missing) || strings.Contains(strings.ToLower(text), "secret") {
|
|
t.Fatalf("mail_send_status names a secret file: %s", text)
|
|
}
|
|
}
|