Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,15 @@ claims that are *already written* into the change:
(sorted path + per-file content hash, deletions marked absent), so a
mid-branch receipt over staged, unstaged, or untracked work — which no
commit SHA identifies — is reproducible and comparable too.
- **Probe details are machine-clean by a categorical rule, not a blocklist.**
A receipt is shareable, and runners echo arbitrary tool output (a failing
test's own message, a compiler error) into evidence details. Every detail
passes one sanitization chokepoint: the repository root becomes `.` (in-repo
paths stay fully readable), every OTHER absolute path collapses to
`…/<basename>` — closing temp paths, toolchain roots, other users' homes,
and sibling project names in one rule instead of enumerating known-bad
roots — and the machine's hostname is scrubbed. File:line actionability
survives the collapse (`…/testing.go:1576`); URLs pass untouched.
- **Same-id claims merge; accept/reject pairs earn T2.** Every test named for
one invariant becomes a probe of the same claim — all run, any can refute.
When one of those tests is accept-polarity and another is reject-polarity
Expand Down
55 changes: 48 additions & 7 deletions internal/receipt/receipt.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"strings"

"github.com/joshft/correctful/internal/gitdiff"
Expand Down Expand Up @@ -211,21 +213,60 @@ func exclusionNote(excl []schema.Exclusion) string {
return "scope excluded " + strings.Join(parts, " · ")
}

// sanitizePaths scrubs local filesystem locations from probe detail text: the
// repository root becomes "." and the home directory "~". External tools
// (compilers, test runners) print absolute paths freely, and a receipt that
// echoes them leaks machine layout into a shareable artifact.
// sanitizePaths scrubs machine details from probe detail text. Assemble is
// the CHOKEPOINT: every Evidence.Detail passes through here regardless of
// which runner produced it, so a runner that echoes arbitrary tool output
// (a failing test's own message, a compiler error) cannot leak machine
// layout into a shareable receipt.
//
// The rule is categorical, not an enumerated blocklist: the repository root
// becomes "." (keeping in-repo paths fully readable, relative), and every
// OTHER absolute path collapses to "…/<basename>". Enumerating known-bad
// roots (the old repoRoot + $HOME pair) is incomplete by construction — it
// left temp paths, toolchain roots, other users' homes, and sibling project
// names (a $HOME replacement renders "~/src/<sibling>/…", which still names
// the sibling). An absolute path outside the repo is never the receipt
// reader's business; its basename preserves the actionable part
// ("…/testing.go:1576"). The machine's hostname is scrubbed to "<host>".
// URLs pass untouched: "//" never matches the path shape, and a path
// preceded by an alphanumeric (https:…) is not token-initial.
func sanitizePaths(detail, repoRoot string) string {
if detail == "" {
return detail
}
if repoRoot != "" && repoRoot != "." {
detail = strings.ReplaceAll(detail, repoRoot, ".")
}
if home, err := os.UserHomeDir(); err == nil && home != "" && home != "/" {
detail = strings.ReplaceAll(detail, home, "~")
return scrubHost(collapseAbsPaths(detail), hostname)
}

// absPathRe matches a token-initial absolute path of at least two
// components. The colon is a terminator, not a path character, so
// "/a/b/x.go:12: msg" collapses to "…/x.go:12: msg" — the file:line
// actionability survives the redaction.
var absPathRe = regexp.MustCompile("(^|[\\s\"'`=,;(\\[])(/[^/\\x00\\s\"'`:,;)\\]]+(?:/[^/\\x00\\s\"'`:,;)\\]]+)+)")

func collapseAbsPaths(s string) string {
return absPathRe.ReplaceAllStringFunc(s, func(m string) string {
sub := absPathRe.FindStringSubmatch(m)
return sub[1] + "…/" + path.Base(sub[2])
})
}

// hostname is resolved once; the empty string (lookup failure) disables the
// scrub rather than failing a receipt over it.
var hostname = func() string { h, _ := os.Hostname(); return h }()

// scrubHost replaces whole-word occurrences of the machine's hostname.
// Hostnames shorter than 3 bytes are left alone — a host named "go" would
// otherwise redact ordinary prose, and a leak needs a name distinctive
// enough to identify anything.
func scrubHost(s, host string) string {
if len(host) < 3 || !strings.Contains(s, host) {
return s
}
return detail
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(host) + `\b`)
return re.ReplaceAllString(s, "<host>")
}

// weigh reduces a claim's evidence to a status and an effective tier.
Expand Down
40 changes: 40 additions & 0 deletions internal/receipt/receipt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,3 +346,43 @@ func TestReceiptSanitizesPathsAndPinsSHAs(t *testing.T) {
t.Errorf("text header missing SHA pins:\n%s", text.String())
}
}

// TestDetailSanitizationIsCategorical: the sanitizer is a categorical rule at
// the Assemble chokepoint, not an enumerated blocklist — EVERY absolute path
// outside the repo collapses to its basename, closing the classes a
// root+home enumeration left open: temp paths, toolchain roots, sibling
// project names, other users' homes. In-repo paths stay fully readable, and
// file:line actionability survives the collapse.
func TestDetailSanitizationIsCategorical(t *testing.T) {
const repo = "/home/someone/src/proj"
cases := []struct{ in, want string }{
// In-repo: full relative path preserved (the reader's business).
{repo + "/pkg/x_test.go:12: boom", "./pkg/x_test.go:12: boom"},
// Temp path.
{"wrote /tmp/probe-x1/out.json", "wrote …/out.json"},
// Sibling project beside the repo: the name must not survive.
{"read /home/someone/src/otherproj/secret.cs:3", "read …/secret.cs:3"},
// Toolchain root, file:line intact.
{"panic at /usr/lib/go/src/testing/testing.go:1576: died", "panic at …/testing.go:1576: died"},
// Flag-glued path.
{"-coverprofile=/tmp/cov1/prof.out", "-coverprofile=…/prof.out"},
// URLs are not filesystem paths and pass untouched.
{"GET http://example.com/a/b: refused", "GET http://example.com/a/b: refused"},
// Repo-relative output was never a leak.
{"internal/probe/gotest.go:41: ok", "internal/probe/gotest.go:41: ok"},
// Single-component absolute names identify nothing.
{"read /dev: is a directory", "read /dev: is a directory"},
}
for _, c := range cases {
if got := sanitizePaths(c.in, repo); got != c.want {
t.Errorf("sanitizePaths(%q)\n got %q\n want %q", c.in, got, c.want)
}
}

if got := scrubHost("dial tcp devbox42:8080: refused", "devbox42"); got != "dial tcp <host>:8080: refused" {
t.Errorf("hostname survived: %q", got)
}
if got := scrubHost("go test ok", "go"); got != "go test ok" {
t.Errorf("short-hostname guard failed: %q", got)
}
}
Loading