From 9666676766bf21364f4fc4d0d1d336e72f2a91b4 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 6 Oct 2026 12:57:23 -0400 Subject: refactor: split casino.go, dedup helpers, remove audit docs --- internal/persistence/txn.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) (limited to 'internal/persistence/txn.go') diff --git a/internal/persistence/txn.go b/internal/persistence/txn.go index 1babfc5..df2b2e4 100644 --- a/internal/persistence/txn.go +++ b/internal/persistence/txn.go @@ -226,6 +226,9 @@ func (t *Transaction) Delete(path string) error { // Move relocates a file (rename semantics). If newPath already exists its // before-image is captured in a separate move-target record for rollback. func (t *Transaction) Move(oldPath, newPath string) error { + if oldPath == newPath { + return fmt.Errorf("move source and destination are the same: %s", oldPath) + } src, err := t.getOp(oldPath) if err != nil { return err @@ -240,6 +243,12 @@ func (t *Transaction) Move(oldPath, newPath string) error { if src.Kind == kindReplace || src.Kind == kindDelete { return fmt.Errorf("conflicting operation on %s: already replaced or deleted", oldPath) } + // Moving a path that already received a Replace as a move destination + // would drop that after-image: the kindMove branch of apply never writes + // op.New. Refuse rather than silently lose the write. + if src.Kind == kindMoveTarget && src.NewExists { + return fmt.Errorf("conflicting operation on %s: already replaced as a move destination", oldPath) + } if dst.Kind != kindMoveTarget && dst.Kind != "" { return fmt.Errorf("conflicting operation on %s", newPath) } @@ -366,6 +375,8 @@ func (t *Transaction) apply() error { return fmt.Errorf("write %s: %w", op.Path, err) } } + default: + return fmt.Errorf("unknown op kind %q for %s", op.Kind, op.Path) } } return nil @@ -449,9 +460,16 @@ func Recover(journalDir string) error { if entry.Version != 0 && entry.Version != journalVersion { return fmt.Errorf("journal %s: unsupported version %d", path, entry.Version) } + root := filepath.Dir(journalDir) t := &Transaction{ops: make(map[string]*fileOp), journalDir: journalDir} for i := range entry.Ops { op := entry.Ops[i] + if !withinDir(root, op.Path) { + return fmt.Errorf("recover %s: op path %q escapes data root %s", path, op.Path, root) + } + if op.MoveTo != "" && !withinDir(root, op.MoveTo) { + return fmt.Errorf("recover %s: move target %q escapes data root %s", path, op.MoveTo, root) + } t.ops[op.Path] = &op t.order = append(t.order, op.Path) } @@ -475,6 +493,22 @@ func hasPrefix(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix } +// withinDir reports whether path is lexically contained in root. Journal +// recovery replays before-images at the paths recorded in the journal, so a +// journal whose ops escape the data root (hand-edited on disk, or written by +// a future/attacker version) must be refused instead of allowed to write or +// delete arbitrary files as the service user. +func withinDir(root, path string) bool { + if path == "" { + return false + } + rel, err := filepath.Rel(root, path) + if err != nil || filepath.IsAbs(rel) { + return false + } + return rel != ".." && !hasPrefix(rel, ".."+string(filepath.Separator)) +} + // ListJournalFiles is a debugging helper: returns sorted journal file names. func ListJournalFiles(journalDir string) ([]string, error) { entries, err := os.ReadDir(journalDir) -- cgit v1.2.3