diff --git a/internal/diff/safe.go b/internal/diff/safe.go new file mode 100644 index 0000000..a320234 --- /dev/null +++ b/internal/diff/safe.go @@ -0,0 +1,184 @@ +// Package diff owns the output-safe diff records of PLAN.md Section 9.6: +// tagged SafeRecord values carrying only precomputed printable unified-diff +// lines, ordinary-file sizes and hashes, or secret classification. No ANSI +// sequence, terminal width, writer, raw secret byte, or go-difflib type +// crosses the package boundary. +package diff + +import ( + "bytes" + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/reconcile" + "github.com/pmezard/go-difflib/difflib" +) + +// Tag names the safe category of one diff record. +type Tag int + +const ( + // TagNone marks equal content on both sides; only metadata such as a + // mode correction may remain to report. + TagNone Tag = iota + // TagText marks a printable unified diff computed from both sides. + TagText + // TagBinary marks a binary or oversized ordinary file; the record + // carries sizes and hashes only. + TagBinary + // TagSecret marks a differing secret; the record carries no content, + // size, hash, or SOPS metadata. + TagSecret +) + +// Valid reports whether tag is one of the supported constants. +func (t Tag) Valid() bool { return t >= TagNone && t <= TagSecret } + +// SafeRecord is one immutable, output-safe diff record for a destination. +// Text records carry precomputed printable unified-diff lines, binary records +// carry ordinary-file sizes and hashes, and secret records carry no payload +// at all (PLAN.md Sections 9.6 and 12.4). +type SafeRecord struct { + targetPath string + tag Tag + sourceLabel string + targetLabel string + lines string + sourceSize int64 + targetSize int64 + sourceHash deployment.Digest + targetHash deployment.Digest +} + +func (r SafeRecord) TargetPath() string { return r.targetPath } +func (r SafeRecord) Tag() Tag { return r.tag } +func (r SafeRecord) SourceLabel() string { return r.sourceLabel } +func (r SafeRecord) TargetLabel() string { return r.targetLabel } +func (r SafeRecord) Lines() string { return r.lines } +func (r SafeRecord) SourceSize() int64 { return r.sourceSize } +func (r SafeRecord) TargetSize() int64 { return r.targetSize } +func (r SafeRecord) SourceHash() deployment.Digest { + return r.sourceHash +} +func (r SafeRecord) TargetHash() deployment.Digest { + return r.targetHash +} + +// maxTextBytes caps one diff side at 1 MiB; larger content is binary for +// output purposes (PLAN.md Section 9.6). +const maxTextBytes = 1 << 20 + +// textEligible reports whether one side may appear in a unified diff: valid +// UTF-8, at most maxTextBytes, and only newline, tab, or printable runes. +// Controls, carriage return, DEL, bidi and zero-width format runes, and every +// other non-printing rune demote the side to binary instead. +func textEligible(data []byte) bool { + if len(data) > maxTextBytes { + return false + } + if !utf8.Valid(data) { + return false + } + for _, r := range string(data) { + if r != '\n' && r != '\t' && !unicode.IsPrint(r) { + return false + } + } + return true +} + +// Build derives the safe record for one file evaluation. The target bytes +// must be the exact bytes captured beside the target snapshot; the record +// never retains them. Equal content yields TagNone, printable text at most +// maxTextBytes per side yields a TagText unified diff with escaped labels, +// and every other content difference yields TagBinary or TagSecret facts +// (PLAN.md Section 9.6). +func Build(evaluation reconcile.Evaluation, targetBytes []byte) (SafeRecord, error) { + if evaluation.Entry != reconcile.PlanEntryFile { + return SafeRecord{}, fmt.Errorf("diff: record requires a file plan entry at %q", evaluation.TargetPath) + } + if !evaluation.File.Kind.Valid() { + return SafeRecord{}, fmt.Errorf("diff: record requires a valid source kind at %q", evaluation.TargetPath) + } + record := SafeRecord{ + targetPath: evaluation.TargetPath, + sourceLabel: "repo/" + escapeLabel(evaluation.File.SourceRepositoryPath), + targetLabel: "$HOME/" + escapeLabel(evaluation.TargetPath), + } + sourceBytes := evaluation.Source.Bytes() + if bytes.Equal(sourceBytes, targetBytes) { + return record, nil + } + if evaluation.File.Kind == deployment.FileSecret { + record.tag = TagSecret + return record, nil + } + if textEligible(sourceBytes) && textEligible(targetBytes) { + return textRecord(record, sourceBytes, targetBytes) + } + return binaryRecord(record, evaluation), nil +} + +// textRecord renders one printable unified diff whose headers carry the +// already-escaped labels. +func textRecord(record SafeRecord, sourceBytes, targetBytes []byte) (SafeRecord, error) { + lines, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: textLines(sourceBytes), + B: textLines(targetBytes), + FromFile: record.sourceLabel, + ToFile: record.targetLabel, + Context: 3, + }) + if err != nil { + return SafeRecord{}, fmt.Errorf("diff: render unified diff: %w", err) + } + record.tag = TagText + record.lines = lines + return record, nil +} + +// binaryRecord fills the size and hash facts of an ordinary file that is +// binary or too large for a unified diff. +func binaryRecord(record SafeRecord, evaluation reconcile.Evaluation) SafeRecord { + record.tag = TagBinary + record.sourceSize = evaluation.Source.Snapshot().Identity().Size() + record.targetSize = evaluation.Target.Identity().Size() + record.sourceHash = evaluation.Source.Snapshot().Semantic() + record.targetHash = evaluation.Target.Digest() + return record +} + +// textLines splits one printable side into lines that each keep their own +// trailing newline, without inventing a final empty line. +func textLines(data []byte) []string { + if len(data) == 0 { + return nil + } + lines := strings.SplitAfter(string(data), "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + last := len(lines) - 1 + if !strings.HasSuffix(lines[last], "\n") { + lines[last] += "\n" + } + return lines +} + +// escapeLabel neutralizes every rune that could forge or reorder diff lines: +// newlines and other non-printing runes become "\x.." escapes, so a hostile +// path can never inject a terminal control or a fabricated header line. +func escapeLabel(label string) string { + var builder strings.Builder + for _, r := range label { + if r == '\t' || unicode.IsPrint(r) { + builder.WriteRune(r) + continue + } + builder.WriteString(fmt.Sprintf("\\x%x", r)) + } + return builder.String() +} diff --git a/internal/diff/safe_test.go b/internal/diff/safe_test.go new file mode 100644 index 0000000..23df3ea --- /dev/null +++ b/internal/diff/safe_test.go @@ -0,0 +1,223 @@ +package diff + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "unicode" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/reconcile" +) + +const textLimit = 1 << 20 + +// TestSafeDiffRecord pins the PLAN.md Section 9.6 record contract: the four +// tagged variants for printable text, binary/large ordinary files, +// metadata-only changes, and secrets, with control/bidi/invalid-UTF-8 runes +// demoted to binary, escaped labels, verified sizes and hashes, and zero +// payload on None and Secret records. +func TestSafeDiffRecord(t *testing.T) { + for _, row := range safeDiffCases { + t.Run(row.name, func(t *testing.T) { + checkSafeDiff(t, row) + }) + } +} + +// safeDiffCase is one row of the record-construction matrix. +type safeDiffCase struct { + name string + kind deployment.FileKind + repoPath string + targetPath string + sourceBytes []byte + targetBytes []byte + wantTag Tag + wantZero bool + wantSource string + wantTarget string + wantLines []string +} + +// safeDiffCases enumerates the Section 9.6 rules: printable text produces a +// unified diff, equal content produces a metadata-only record, carriage +// return/ESC/bidi/invalid UTF-8 or an oversized side demotes to binary, +// secrets carry only classification, and labels never expose control runes. +var safeDiffCases = []safeDiffCase{ + {name: "text diff", repoPath: "files/config", targetPath: "config", + sourceBytes: []byte("alpha\nbeta\n"), targetBytes: []byte("alpha\ngamma\n"), + wantTag: TagText, wantSource: "repo/files/config", wantTarget: "$HOME/config", + wantLines: []string{"--- repo/files/config", "+++ $HOME/config", "-beta", "+gamma"}}, + {name: "text against absent target", repoPath: "files/config", targetPath: "config", + sourceBytes: []byte("line1\nline2\n"), + wantTag: TagText, + wantLines: []string{"-line1", "-line2"}}, + {name: "metadata only", repoPath: "files/config", targetPath: "config", + sourceBytes: []byte("same\n"), targetBytes: []byte("same\n"), + wantTag: TagNone, wantZero: true}, + {name: "carriage return binary", repoPath: "files/config", targetPath: "config", + sourceBytes: []byte("a\rb\n"), targetBytes: []byte("short\n"), + wantTag: TagBinary}, + {name: "escape control binary", repoPath: "files/config", targetPath: "config", + sourceBytes: []byte("a\x1bb\n"), targetBytes: []byte("short\n"), + wantTag: TagBinary}, + {name: "bidi format binary", repoPath: "files/config", targetPath: "config", + sourceBytes: []byte("a\u202eb\n"), targetBytes: []byte("short\n"), + wantTag: TagBinary}, + {name: "invalid utf8 binary", repoPath: "files/config", targetPath: "config", + sourceBytes: []byte{0xff, 0xfe}, targetBytes: []byte("short\n"), + wantTag: TagBinary}, + {name: "source over limit", repoPath: "files/config", targetPath: "config", + sourceBytes: bytes.Repeat([]byte{'a'}, textLimit+1), targetBytes: []byte("short\n"), + wantTag: TagBinary}, + {name: "target over limit", repoPath: "files/config", targetPath: "config", + sourceBytes: []byte("short\n"), targetBytes: bytes.Repeat([]byte{'b'}, textLimit+1), + wantTag: TagBinary}, + {name: "secret differs", kind: deployment.FileSecret, repoPath: "app/token", targetPath: "token", + sourceBytes: []byte(`{"data":"c2VjcmV0","sops":{"version":"3.9.0"}}`), + targetBytes: []byte(`{"data":"QUFB","sops":{"version":"3.9.0"}}`), + wantTag: TagSecret, wantZero: true}, + {name: "secret metadata only", kind: deployment.FileSecret, repoPath: "app/token", targetPath: "token", + sourceBytes: []byte(`{"data":"c2VjcmV0","sops":{"version":"3.9.0"}}`), + targetBytes: []byte(`{"data":"c2VjcmV0","sops":{"version":"3.9.0"}}`), + wantTag: TagNone, wantZero: true}, + {name: "escaped labels", repoPath: "files/\x1bconfig", targetPath: "config\x1b", + sourceBytes: []byte("alpha\nbeta\n"), targetBytes: []byte("alpha\ngamma\n"), + wantTag: TagText, wantSource: "repo/files/\\x1bconfig", wantTarget: "$HOME/config\\x1b", + wantLines: []string{"--- repo/files/\\x1bconfig"}}, +} + +// checkSafeDiff builds one record from its row and compares every fact. +func checkSafeDiff(t *testing.T, row safeDiffCase) { + t.Helper() + record, err := Build(materialize(t, t.TempDir(), row), row.targetBytes) + if err != nil { + t.Fatalf("%s: Build: %v", row.name, err) + } + checkIdentified(t, row, record) + checkLabels(t, row, record) + if row.wantZero { + checkZeroPayload(t, row, record) + } + if row.wantTag == TagBinary { + checkBinaryFacts(t, row, record) + } + requirePrintable(t, row, record.Lines()) + requireFragments(t, row, record.Lines()) +} + +// checkIdentified verifies the tag and destination path of one record. +func checkIdentified(t *testing.T, row safeDiffCase, record SafeRecord) { + t.Helper() + if record.Tag() != row.wantTag { + t.Fatalf("%s: tag = %v, want %v", row.name, record.Tag(), row.wantTag) + } + if record.TargetPath() != row.targetPath { + t.Fatalf("%s: target path = %q, want %q", row.name, record.TargetPath(), row.targetPath) + } +} + +// checkLabels verifies the escaped diff header labels of one record. +func checkLabels(t *testing.T, row safeDiffCase, record SafeRecord) { + t.Helper() + if row.wantSource != "" && record.SourceLabel() != row.wantSource { + t.Fatalf("%s: source label = %q, want %q", row.name, record.SourceLabel(), row.wantSource) + } + if row.wantTarget != "" && record.TargetLabel() != row.wantTarget { + t.Fatalf("%s: target label = %q, want %q", row.name, record.TargetLabel(), row.wantTarget) + } +} + +// requireFragments fails when any expected unified-diff fragment is absent. +func requireFragments(t *testing.T, row safeDiffCase, lines string) { + t.Helper() + for _, fragment := range row.wantLines { + if !strings.Contains(lines, fragment) { + t.Fatalf("%s: lines lack %q:\n%s", row.name, fragment, lines) + } + } +} + +// materialize writes the source and optional target files, captures both +// snapshots, and assembles the evaluation of one table row. +func materialize(t *testing.T, root string, row safeDiffCase) reconcile.Evaluation { + t.Helper() + sourcePath := filepath.Join(root, "source") + if err := os.WriteFile(sourcePath, row.sourceBytes, 0o644); err != nil { + t.Fatal(err) + } + file := deployment.ManagedFile{ + Kind: row.kind, Layer: deployment.LayerBase, + SourceAbsolutePath: sourcePath, SourceRepositoryPath: row.repoPath, + TargetRelativePath: row.targetPath, + } + if file.Kind == "" { + file.Kind = deployment.FileOrdinary + } + observation, err := reconcile.CaptureSource(file, nil) + if err != nil { + t.Fatal(err) + } + target, err := captureTarget(t, root, row) + if err != nil { + t.Fatal(err) + } + return reconcile.Evaluation{ + TargetPath: row.targetPath, Entry: reconcile.PlanEntryFile, + File: file, Source: observation, Target: target, + } +} + +// captureTarget captures the destination of one row, writing its bytes first +// so a missing side freezes as an absent target. +func captureTarget(t *testing.T, root string, row safeDiffCase) (reconcile.TargetSnapshot, error) { + if len(row.targetBytes) > 0 { + path := filepath.Join(root, "target") + if err := os.WriteFile(path, row.targetBytes, 0o644); err != nil { + t.Fatal(err) + } + } + return reconcile.CaptureTarget(reconcile.Destination{Root: root, Relative: "target"}) +} + +// checkZeroPayload requires the None and Secret variants to carry no lines, +// sizes, or hashes. +func checkZeroPayload(t *testing.T, row safeDiffCase, record SafeRecord) { + t.Helper() + if record.Lines() != "" || record.SourceSize() != 0 || record.TargetSize() != 0 { + t.Fatalf("%s: content or size leaked", row.name) + } + if record.SourceHash() != (deployment.Digest{}) || record.TargetHash() != (deployment.Digest{}) { + t.Fatalf("%s: hash leaked", row.name) + } +} + +// checkBinaryFacts verifies that a binary record carries exactly the captured +// sizes and semantic hashes and nothing else. +func checkBinaryFacts(t *testing.T, row safeDiffCase, record SafeRecord) { + t.Helper() + if record.SourceSize() != int64(len(row.sourceBytes)) || record.TargetSize() != int64(len(row.targetBytes)) { + t.Fatalf("%s: sizes = %d/%d, want %d/%d", row.name, record.SourceSize(), record.TargetSize(), + len(row.sourceBytes), len(row.targetBytes)) + } + if record.SourceHash() != deployment.Ordinary(row.sourceBytes) || record.TargetHash() != deployment.Ordinary(row.targetBytes) { + t.Fatalf("%s: hashes mismatch", row.name) + } + if record.Lines() != "" { + t.Fatalf("%s: binary record carried lines", row.name) + } +} + +// requirePrintable fails when any rendered line contains a rune a terminal +// could interpret as control or formatting. +func requirePrintable(t *testing.T, row safeDiffCase, lines string) { + t.Helper() + for _, r := range lines { + if r != '\n' && r != '\t' && !unicode.IsPrint(r) { + t.Fatalf("%s: unsafe rune %U in lines", row.name, r) + } + } +}