aboutsummaryrefslogtreecommitdiff
path: root/internal/persistence/txn.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/persistence/txn.go')
-rw-r--r--internal/persistence/txn.go34
1 files changed, 34 insertions, 0 deletions
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)