Artifact 26c93ba6edd8cc854eb6fba418c16095fcfb314a718764831aa14d4c0df31196:
- File
testutil/isolate_test.go
— part of check-in
[612f2e7acc]
at
2026-10-23 20:05:29
on branch main
— feat(signing): sign check-ins and horizon seeds with gpg or ssh
go-libfossil signed nothing and verified nothing, so a structural-horizon clone could not tell a seed its origin minted from one substituted on disk (draft-fossil-horizon-00 ยง5.2.4, HZ-5.2-5 and HZ-5.2-6), and a repository with fossil's clearsign setting on stored its check-ins unsigned.
Signing follows fossil's contract, the same one zit follows. With clearsign on (and the legacy omitsign off), every check-in a commit or branch creation stores is clear-signed by pgp-command: unset, gpg --clearsign -o, run as COMMAND OUTFILE INFILE; a command named ssh* runs as COMMAND INFILE and its detached signature is wrapped in fossil's SSH envelope. A signer that fails, or returns text that does not strip back to the manifest, abandons the commit. NoSign and --nosign store one check-in unsigned.
An origin with clearsign on signs each seed it mints. It signs only after the request is authorized, outside the request's transaction, keeps one signature per seed and command so the seed's name is stable, and re-signs a kept signature its own horizon-seed-key no longer verifies. Keeping is best effort, so a read-only mirror still serves.
A clone given its origin's key (CloneOpts.SeedKey, repo clone --seed-key, kept as horizon-seed-key) verifies every seed on clone, pull and rebuild, and falls back to the content horizon from one unsigned or signed by another key. An SSH key is verified in process: namespace fossilscm, the configured key, no SHA-1 ssh-rsa, and exactly fossil's envelope. An OpenPGP fingerprint is verified by gpg, which must report one good signature by that key over text byte-equal to what the parser reads, with nothing after the signature. A verifier that cannot run keeps the held seed and fails the sync instead of discarding it.
fossil 2.28 and zit read the signed check-ins; a check-in zit signs with the same SSH key verifies here; zit clones a signing origin and holds its seed. (user: [email protected], size: 4492) [annotate] [blame] [check-ins using] [more...]
package testutil import ( "io/fs" "os" "os/exec" "path/filepath" "strings" "testing" ) // fossilConfigPaths lists the per-user databases fossil may create under home. func fossilConfigPaths(home string) []string { return []string{ filepath.Join(home, ".fossil"), filepath.Join(home, ".config", "fossil.db"), } } // TestIsolateFossilHomeKeepsGlobalConfigOut proves a fossil run after // RequireFossilBin writes its global config under the per-test directory (#284). // It does not compare the real files' mtimes: other processes on a shared // machine legitimately touch them, which would make the test flaky. func TestIsolateFossilHomeKeepsGlobalConfigOut(t *testing.T) { realHome, err := os.UserHomeDir() if err != nil { t.Skipf("no real home to compare against: %v", err) } bin := RequireFossilBin(t) home := os.Getenv("FOSSIL_HOME") if home == "" || home == realHome || os.Getenv("HOME") != home { t.Fatalf("HOME=%q FOSSIL_HOME=%q: want both set to one per-test dir, not %q", os.Getenv("HOME"), home, realHome) } if !strings.HasPrefix(os.Getenv("XDG_CONFIG_HOME"), home) { t.Fatalf("XDG_CONFIG_HOME=%q is outside the isolated home %q", os.Getenv("XDG_CONFIG_HOME"), home) } // `fossil new` records the repository in the global config. repoPath := filepath.Join(t.TempDir(), "iso.fossil") if out, err := exec.Command(bin, "new", repoPath).CombinedOutput(); err != nil { t.Fatalf("fossil new: %v\n%s", err, out) } found := false for _, p := range fossilConfigPaths(home) { if _, err := os.Stat(p); err == nil { found = true } } if !found { t.Fatalf("fossil wrote no global config under %q; it is using another home", home) } } // fossilIsolationMarkers are the substrings that show a test file resolves // fossil through the isolating helpers or sets its own private home. var fossilIsolationMarkers = []string{ "requirefossilbin(", "isolatefossilhome(", "newtestrepo(", "fossil_home=", } // notFossilExec lists test files whose exec.Command calls never run fossil. var notFossilExec = map[string]string{ "cmd/libfossil/main_test.go": "re-executes the test binary", "cmd/libfossil/driver_dependencies_test.go": "runs the go tool", "testutil/server_unix.go": "runs the bin its caller resolved", "testutil/server_other.go": "runs the bin its caller resolved", "conformance/zit_interop_test.go": "runs zit, under a HOME of its own", "internal/clearsign/exec.go": "runs the repository's signing command and gpg", "internal/clearsign/clearsign_test.go": "runs gpg, under the GNUPGHOME signtest sets", "internal/clearsign/signtest/signtest.go": "runs ssh-keygen and gpg, under a GNUPGHOME of its own", } // TestFossilInvocationsIsolateHome fails when a Go file runs a subprocess // without any means of isolating fossil's home. It is a static net for new // tests: a test that shells out to fossil must resolve it through // RequireFossilBin (or call IsolateFossilHome), so the developer's real // ~/.fossil and ~/.config/fossil.db are never written (#284). // // The check is per file, not per function: one isolating marker anywhere in a // file clears every exec.Command in it, so a file whose helpers call fossil // directly still relies on its tests calling RequireFossilBin first. The // markers match whole call names ("newtestrepo(" does not match // NewTestRepoFromPath), so a helper that does not isolate does not count. func TestFossilInvocationsIsolateHome(t *testing.T) { root := ".." err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { if n := d.Name(); n == ".git" || n == "node_modules" || n == "docs" { return fs.SkipDir } return nil } if !strings.HasSuffix(path, ".go") { return nil } src, rerr := os.ReadFile(path) if rerr != nil { return rerr } text := string(src) if !strings.Contains(text, "exec.Command") { return nil } rel := filepath.ToSlash(strings.TrimPrefix(path, root+string(filepath.Separator))) if _, ok := notFossilExec[rel]; ok { return nil } lower := strings.ToLower(text) for _, m := range fossilIsolationMarkers { if strings.Contains(lower, m) { return nil } } t.Errorf("%s runs exec.Command without testutil.RequireFossilBin or "+ "IsolateFossilHome; fossil would write the real global config", rel) return nil }) if err != nil { t.Fatal(err) } }