From 58fc6908a82501bc7672adb3a97c32f1bf2713ae Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Sun, 16 Aug 2026 12:15:16 +0200 Subject: [PATCH 1/2] fix(conflicts): preserve ResolveConflict temp files (#143) ResolveConflict previously reused a predictable .vaultsync-tmp path. If that path already existed, WriteFile truncated unrelated bytes and Rename consumed the entry while replacing the original. CreateTemp now exclusively creates a randomized same-directory entry, and all writes use that opened descriptor. A pre-existing candidate is never adopted, and the resolver performs no pathname-based temp cleanup after ownership could be lost. Pre-commit failures retain the original and conflict; post-commit cleanup failures retain the conflict duplicate. What could go wrong and why this is safe: Syncthing may remove a reserved temp before rename, which produces a reported pre-commit failure while both user files remain. Focused tests cover legacy nodes, permissions, short writes, I/O boundaries, traversal, source-removal failure, and reuse of the freed temp pathname. Rebuild the XCFramework before the next archive. --- CHANGELOG.md | 4 + go/bridge/conflicts.go | 124 +++++-- go/bridge/conflicts_test.go | 637 ++++++++++++++++++++++++++++++++++++ 3 files changed, 746 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7360518..af81ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to VaultSync are documented here. ## [Unreleased] +### Fixed + +- **Manual conflict resolution preserves unrelated temporary files** ([#143](https://github.com/psimaker/vaultsync/issues/143)) — choosing the conflicting version no longer reuses or overwrites a pre-existing temporary file next to the note. + ### Security - **Updated the Go runtime used by the sync engine and self-hosted helper** — Go 1.26.5 → 1.26.6 incorporates the latest standard-library security fixes. Future iOS archives and helper releases are built with the patched runtime. diff --git a/go/bridge/conflicts.go b/go/bridge/conflicts.go index ed572b8..2dc26e8 100644 --- a/go/bridge/conflicts.go +++ b/go/bridge/conflicts.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "regexp" @@ -33,6 +34,40 @@ var conflictPattern = regexp.MustCompile(`^(.+)\.sync-conflict-(\d{8}-\d{6})-([A // to prevent excessive I/O on very large vaults. const maxConflictScan = 10000 +// Syncthing's fs.IsTemporary recognizes the .syncthing. prefix, so the scanner +// ignores these short-lived files instead of publishing them as vault content. +// Keep the pattern independent of the user filename to stay below conservative +// filesystem filename limits; os.CreateTemp appends the exclusive random suffix. +const conflictResolveTempPattern = ".syncthing.vaultsync-resolve-*" + +type conflictTempFile interface { + Name() string + Write([]byte) (int, error) + Chmod(os.FileMode) error + Sync() error + Close() error +} + +type conflictFileOperations struct { + readFile func(string) ([]byte, error) + stat func(string) (os.FileInfo, error) + createTemp func(string, string) (conflictTempFile, error) + rename func(string, string) error + remove func(string) error +} + +func systemConflictFileOperations() conflictFileOperations { + return conflictFileOperations{ + readFile: os.ReadFile, + stat: os.Stat, + createTemp: func(dir, pattern string) (conflictTempFile, error) { + return os.CreateTemp(dir, pattern) + }, + rename: os.Rename, + remove: os.Remove, + } +} + // GetConflictFilesJSON scans the folder's directory for .sync-conflict-* files. // Returns a JSON array of ConflictFile objects. Stops after scanning maxConflictScan files. func GetConflictFilesJSON(folderID string) string { @@ -215,26 +250,10 @@ func ResolveConflict(folderID, conflictFileName string, keepConflict bool) strin originalName := matches[1] + matches[4] originalPath := filepath.Join(filepath.Dir(conflictPath), originalName) - data, err := os.ReadFile(conflictPath) - if err != nil { - return fmt.Sprintf("read conflict file: %v", err) - } - - // Preserve original file permissions, default to 0644. - perm := os.FileMode(0o644) - if info, err := os.Stat(originalPath); err == nil { - perm = info.Mode() - } - - // Atomic write: write to temp file then rename to avoid partial writes. - tmpPath := originalPath + ".vaultsync-tmp" - if err := os.WriteFile(tmpPath, data, perm); err != nil { - return fmt.Sprintf("write temp file: %v", err) - } - if err := os.Rename(tmpPath, originalPath); err != nil { - os.Remove(tmpPath) - return fmt.Sprintf("replace original file: %v", err) + if err := replaceConflictAndRemoveSource(conflictPath, originalPath, systemConflictFileOperations()); err != nil { + return err.Error() } + return "" } if err := os.Remove(conflictPath); err != nil { @@ -244,6 +263,73 @@ func ResolveConflict(folderID, conflictFileName string, keepConflict bool) strin return "" } +func replaceConflictAndRemoveSource(conflictPath, originalPath string, ops conflictFileOperations) error { + if err := replaceConflictOriginal(conflictPath, originalPath, ops); err != nil { + return err + } + if err := ops.remove(conflictPath); err != nil { + return fmt.Errorf("delete conflict file: %w", err) + } + return nil +} + +func replaceConflictOriginal(conflictPath, originalPath string, ops conflictFileOperations) error { + data, err := ops.readFile(conflictPath) + if err != nil { + return fmt.Errorf("read conflict file: %w", err) + } + + // Preserve the current original's mode. A missing original is a supported + // promotion case and keeps the historical 0644 default; every other stat + // error is ambiguous and must fail before creating or changing anything. + perm := os.FileMode(0o644) + if info, statErr := ops.stat(originalPath); statErr == nil { + perm = info.Mode() + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("stat original file: %w", statErr) + } + + tempFile, err := ops.createTemp(filepath.Dir(originalPath), conflictResolveTempPattern) + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tempPath := tempFile.Name() + + if n, writeErr := tempFile.Write(data); writeErr != nil || n != len(data) { + if writeErr == nil { + writeErr = io.ErrShortWrite + } + return closeConflictTempAfterError(tempFile, "write temp file", writeErr) + } + if err := tempFile.Chmod(perm); err != nil { + return closeConflictTempAfterError(tempFile, "set temp permissions", err) + } + if err := tempFile.Sync(); err != nil { + return closeConflictTempAfterError(tempFile, "sync temp file", err) + } + if err := tempFile.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + + // The successful same-directory rename is the commit point. Do not defer a + // path-based Remove: after this rename the old temp path is free and may be + // reused by another process, so that pathname no longer proves ownership. + if err := ops.rename(tempPath, originalPath); err != nil { + // Leave our reserved temp entry in place on failure. Syncthing ignores the + // prefix and its normal stale-temp policy can remove abandoned entries. + return fmt.Errorf("replace original file: %w", err) + } + + return nil +} + +func closeConflictTempAfterError(tempFile conflictTempFile, operation string, operationErr error) error { + if closeErr := tempFile.Close(); closeErr != nil { + return fmt.Errorf("%s: %v; close temp file: %v", operation, operationErr, closeErr) + } + return fmt.Errorf("%s: %w", operation, operationErr) +} + // RemoveConflictFilesForOriginal removes every sync-conflict copy of the file // at originalPath inside the given folder. The original file is NOT touched. // diff --git a/go/bridge/conflicts_test.go b/go/bridge/conflicts_test.go index afe15bf..da22db9 100644 --- a/go/bridge/conflicts_test.go +++ b/go/bridge/conflicts_test.go @@ -1,10 +1,12 @@ package bridge import ( + "bytes" "encoding/json" "os" "path/filepath" "strings" + "syscall" "testing" "time" ) @@ -205,6 +207,641 @@ func TestResolveConflictKeepConflict(t *testing.T) { } } +func TestIssue143ResolveConflictPreservesExistingTemporaryFile(t *testing.T) { + configDir := testConfigDir(t) + + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + defer StopSyncthing() + + const folderID = "issue143temp" + folderPath := filepath.Join(configDir, folderID) + if errMsg := AddFolder(folderID, "Issue 143 Temp Collision", folderPath); errMsg != "" { + t.Fatalf("AddFolder failed: %s", errMsg) + } + + const conflictName = "doc.sync-conflict-20260406-100000-DEF5678.md" + originalPath := filepath.Join(folderPath, "doc.md") + conflictPath := filepath.Join(folderPath, conflictName) + tempPath := originalPath + ".vaultsync-tmp" + unrelatedPath := filepath.Join(folderPath, "unrelated-sentinel.md") + + originalSentinel := []byte("issue-143-original-sentinel") + conflictSentinel := []byte("issue-143-conflict-sentinel") + tempSentinel := []byte("issue-143-existing-temp-sentinel") + unrelatedSentinel := []byte("issue-143-unrelated-sentinel") + fixtures := []struct { + name string + path string + data []byte + }{ + {name: "original", path: originalPath, data: originalSentinel}, + {name: "conflict", path: conflictPath, data: conflictSentinel}, + {name: "pre-existing temp", path: tempPath, data: tempSentinel}, + {name: "unrelated", path: unrelatedPath, data: unrelatedSentinel}, + } + for _, fixture := range fixtures { + if err := os.WriteFile(fixture.path, fixture.data, 0o644); err != nil { + t.Fatalf("write %s fixture: %v", fixture.name, err) + } + got, err := os.ReadFile(fixture.path) + if err != nil { + t.Fatalf("read back %s fixture: %v", fixture.name, err) + } + if !bytes.Equal(got, fixture.data) { + t.Fatalf("%s fixture bytes = %q, want %q", fixture.name, got, fixture.data) + } + } + + if errMsg := ResolveConflict(folderID, conflictName, true); errMsg != "" { + t.Fatalf("ResolveConflict(keepConflict=true) failed: %s", errMsg) + } + + tempAfter, err := os.ReadFile(tempPath) + if err != nil { + t.Fatalf("pre-existing temp file was not preserved: %v", err) + } + if !bytes.Equal(tempAfter, tempSentinel) { + t.Errorf("pre-existing temp bytes = %q, want %q", tempAfter, tempSentinel) + } + + originalAfter, err := os.ReadFile(originalPath) + if err != nil { + t.Fatalf("read resolved original: %v", err) + } + if !bytes.Equal(originalAfter, conflictSentinel) { + t.Errorf("resolved original bytes = %q, want conflict bytes %q", originalAfter, conflictSentinel) + } + if _, err := os.Stat(conflictPath); !os.IsNotExist(err) { + t.Errorf("resolved conflict path still exists or stat failed: %v", err) + } + unrelatedAfter, err := os.ReadFile(unrelatedPath) + if err != nil { + t.Fatalf("read unrelated file: %v", err) + } + if !bytes.Equal(unrelatedAfter, unrelatedSentinel) { + t.Errorf("unrelated bytes = %q, want %q", unrelatedAfter, unrelatedSentinel) + } + ownedTemps, err := filepath.Glob(filepath.Join(folderPath, ".syncthing.vaultsync-resolve-*")) + if err != nil { + t.Fatalf("glob VaultSync temporary files: %v", err) + } + if len(ownedTemps) != 0 { + t.Errorf("successful resolution left VaultSync temporary files: %v", ownedTemps) + } +} + +func TestIssue143ResolveConflictRejectsPathTraversal(t *testing.T) { + configDir := testConfigDir(t) + + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + defer StopSyncthing() + + const folderID = "issue143traversal" + folderPath := filepath.Join(configDir, folderID) + if errMsg := AddFolder(folderID, "Issue 143 Traversal", folderPath); errMsg != "" { + t.Fatalf("AddFolder failed: %s", errMsg) + } + + const conflictName = "outside.sync-conflict-20260406-100000-DEF5678.md" + originalPath := filepath.Join(configDir, "outside.md") + conflictPath := filepath.Join(configDir, conflictName) + legacyPath := originalPath + ".vaultsync-tmp" + unrelatedPath := filepath.Join(configDir, "outside-unrelated-sentinel.md") + originalBytes := []byte("issue-143-traversal-original") + conflictBytes := []byte("issue-143-traversal-conflict") + legacyBytes := []byte("issue-143-traversal-legacy-temp") + unrelatedBytes := []byte("issue-143-traversal-unrelated") + + fixtures := []struct { + name string + path string + data []byte + }{ + {name: "outside original", path: originalPath, data: originalBytes}, + {name: "outside conflict", path: conflictPath, data: conflictBytes}, + {name: "outside legacy temp", path: legacyPath, data: legacyBytes}, + {name: "outside unrelated", path: unrelatedPath, data: unrelatedBytes}, + } + for _, fixture := range fixtures { + if err := os.WriteFile(fixture.path, fixture.data, 0o600); err != nil { + t.Fatalf("write %s: %v", fixture.name, err) + } + issue143AssertFileBytes(t, fixture.path, fixture.data) + } + + errMsg := ResolveConflict(folderID, filepath.Join("..", conflictName), true) + if errMsg != "invalid path: outside folder root" { + t.Fatalf("ResolveConflict traversal error = %q, want invalid path error", errMsg) + } + + for _, fixture := range fixtures { + issue143AssertFileBytes(t, fixture.path, fixture.data) + } + issue143AssertNoOperationTemps(t, configDir) +} + +func TestIssue143ResolveConflictPreservesModeAndSupportsMissingOriginal(t *testing.T) { + tests := []struct { + name string + createOriginal bool + wantMode os.FileMode + }{ + {name: "existing original", createOriginal: true, wantMode: 0o640}, + {name: "missing original", createOriginal: false, wantMode: 0o644}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + originalPath := filepath.Join(dir, "doc.md") + conflictPath := filepath.Join(dir, "doc.sync-conflict-20260406-100000-DEF5678.md") + conflictBytes := []byte("issue-143-selected-conflict") + + if tt.createOriginal { + if err := os.WriteFile(originalPath, []byte("issue-143-original"), 0o600); err != nil { + t.Fatalf("write original: %v", err) + } + if err := os.Chmod(originalPath, tt.wantMode); err != nil { + t.Fatalf("set original permissions: %v", err) + } + } + if err := os.WriteFile(conflictPath, conflictBytes, 0o600); err != nil { + t.Fatalf("write conflict: %v", err) + } + + if err := replaceConflictAndRemoveSource(conflictPath, originalPath, systemConflictFileOperations()); err != nil { + t.Fatalf("replaceConflictAndRemoveSource() failed: %v", err) + } + + issue143AssertFileBytes(t, originalPath, conflictBytes) + if _, err := os.Stat(conflictPath); !os.IsNotExist(err) { + t.Fatalf("conflict path still exists or stat failed: %v", err) + } + info, err := os.Stat(originalPath) + if err != nil { + t.Fatalf("stat resolved original: %v", err) + } + if got := info.Mode().Perm(); got != tt.wantMode { + t.Errorf("resolved original permissions = %04o, want %04o", got, tt.wantMode) + } + issue143AssertNoOperationTemps(t, dir) + }) + } +} + +func TestIssue143ResolveConflictDoesNotTouchLegacyTempNodes(t *testing.T) { + tests := []struct { + name string + setup func(*testing.T, string) func(*testing.T) + }{ + { + name: "regular file", + setup: func(t *testing.T, legacyPath string) func(*testing.T) { + t.Helper() + want := []byte("issue-143-legacy-file-sentinel") + if err := os.WriteFile(legacyPath, want, 0o600); err != nil { + t.Fatalf("write legacy regular file: %v", err) + } + return func(t *testing.T) { + t.Helper() + issue143AssertFileBytes(t, legacyPath, want) + } + }, + }, + { + name: "symlink", + setup: func(t *testing.T, legacyPath string) func(*testing.T) { + t.Helper() + targetPath := filepath.Join(filepath.Dir(legacyPath), "legacy-symlink-target") + want := []byte("issue-143-symlink-target-sentinel") + if err := os.WriteFile(targetPath, want, 0o600); err != nil { + t.Fatalf("write symlink target: %v", err) + } + if err := os.Symlink(targetPath, legacyPath); err != nil { + t.Fatalf("create legacy symlink: %v", err) + } + return func(t *testing.T) { + t.Helper() + info, err := os.Lstat(legacyPath) + if err != nil { + t.Fatalf("lstat legacy symlink: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("legacy path mode = %v, want symlink", info.Mode()) + } + gotTarget, err := os.Readlink(legacyPath) + if err != nil { + t.Fatalf("read legacy symlink: %v", err) + } + if gotTarget != targetPath { + t.Errorf("legacy symlink target = %q, want %q", gotTarget, targetPath) + } + issue143AssertFileBytes(t, targetPath, want) + } + }, + }, + { + name: "directory", + setup: func(t *testing.T, legacyPath string) func(*testing.T) { + t.Helper() + if err := os.Mkdir(legacyPath, 0o700); err != nil { + t.Fatalf("create legacy directory: %v", err) + } + childPath := filepath.Join(legacyPath, "sentinel") + want := []byte("issue-143-legacy-directory-sentinel") + if err := os.WriteFile(childPath, want, 0o600); err != nil { + t.Fatalf("write legacy directory sentinel: %v", err) + } + return func(t *testing.T) { + t.Helper() + info, err := os.Lstat(legacyPath) + if err != nil { + t.Fatalf("lstat legacy directory: %v", err) + } + if !info.IsDir() { + t.Fatalf("legacy path mode = %v, want directory", info.Mode()) + } + issue143AssertFileBytes(t, childPath, want) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + originalPath := filepath.Join(dir, "doc.md") + conflictPath := filepath.Join(dir, "doc.sync-conflict-20260406-100000-DEF5678.md") + legacyPath := originalPath + ".vaultsync-tmp" + conflictBytes := []byte("issue-143-selected-conflict") + + if err := os.WriteFile(originalPath, []byte("issue-143-original"), 0o600); err != nil { + t.Fatalf("write original: %v", err) + } + if err := os.WriteFile(conflictPath, conflictBytes, 0o600); err != nil { + t.Fatalf("write conflict: %v", err) + } + verifyLegacy := tt.setup(t, legacyPath) + + if err := replaceConflictAndRemoveSource(conflictPath, originalPath, systemConflictFileOperations()); err != nil { + t.Fatalf("replaceConflictAndRemoveSource() failed: %v", err) + } + + issue143AssertFileBytes(t, originalPath, conflictBytes) + if _, err := os.Stat(conflictPath); !os.IsNotExist(err) { + t.Fatalf("conflict path still exists or stat failed: %v", err) + } + verifyLegacy(t) + issue143AssertNoOperationTemps(t, dir) + }) + } +} + +func TestIssue143ResolveConflictPreCommitFailuresPreserveUserFiles(t *testing.T) { + tests := []struct { + name string + wantErrPrefix string + wantTempCount int + inject func(*conflictFileOperations) + }{ + { + name: "read", + wantErrPrefix: "read conflict file:", + wantTempCount: 0, + inject: func(ops *conflictFileOperations) { + ops.readFile = func(string) ([]byte, error) { return nil, syscall.EIO } + }, + }, + { + name: "stat", + wantErrPrefix: "stat original file:", + wantTempCount: 0, + inject: func(ops *conflictFileOperations) { + ops.stat = func(string) (os.FileInfo, error) { return nil, syscall.EACCES } + }, + }, + { + name: "create disk full", + wantErrPrefix: "create temp file:", + wantTempCount: 0, + inject: func(ops *conflictFileOperations) { + ops.createTemp = func(string, string) (conflictTempFile, error) { + return nil, syscall.ENOSPC + } + }, + }, + { + name: "partial write disk full", + wantErrPrefix: "write temp file:", + wantTempCount: 1, + inject: func(ops *conflictFileOperations) { + issue143InjectTempFault(ops, func(file *issue143FaultingTempFile) { + file.write = func(data []byte) (int, error) { + n, err := file.conflictTempFile.Write(data[:len(data)/2]) + if err != nil { + return n, err + } + return n, syscall.ENOSPC + } + }) + }, + }, + { + name: "short write", + wantErrPrefix: "write temp file:", + wantTempCount: 1, + inject: func(ops *conflictFileOperations) { + issue143InjectTempFault(ops, func(file *issue143FaultingTempFile) { + file.write = func(data []byte) (int, error) { + return file.conflictTempFile.Write(data[:len(data)/2]) + } + }) + }, + }, + { + name: "chmod", + wantErrPrefix: "set temp permissions:", + wantTempCount: 1, + inject: func(ops *conflictFileOperations) { + issue143InjectTempFault(ops, func(file *issue143FaultingTempFile) { + file.chmod = func(os.FileMode) error { return syscall.EPERM } + }) + }, + }, + { + name: "sync", + wantErrPrefix: "sync temp file:", + wantTempCount: 1, + inject: func(ops *conflictFileOperations) { + issue143InjectTempFault(ops, func(file *issue143FaultingTempFile) { + file.sync = func() error { return syscall.EIO } + }) + }, + }, + { + name: "close", + wantErrPrefix: "close temp file:", + wantTempCount: 1, + inject: func(ops *conflictFileOperations) { + issue143InjectTempFault(ops, func(file *issue143FaultingTempFile) { + file.close = func() error { + if err := file.conflictTempFile.Close(); err != nil { + return err + } + return syscall.EIO + } + }) + }, + }, + { + name: "rename", + wantErrPrefix: "replace original file:", + wantTempCount: 1, + inject: func(ops *conflictFileOperations) { + ops.rename = func(string, string) error { return syscall.EIO } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fixture := issue143NewFileFixture(t) + ops := systemConflictFileOperations() + tt.inject(&ops) + + err := replaceConflictAndRemoveSource(fixture.conflictPath, fixture.originalPath, ops) + if err == nil { + t.Fatalf("replaceConflictAndRemoveSource() succeeded, want %q error", tt.wantErrPrefix) + } + if !strings.HasPrefix(err.Error(), tt.wantErrPrefix) { + t.Errorf("error = %q, want prefix %q", err, tt.wantErrPrefix) + } + + issue143AssertPreCommitFixture(t, fixture) + temps := issue143OperationTemps(t, fixture.dir) + if len(temps) != tt.wantTempCount { + t.Errorf("temporary file count = %d, want %d: %v", len(temps), tt.wantTempCount, temps) + } + for _, tempPath := range temps { + info, statErr := os.Lstat(tempPath) + if statErr != nil { + t.Fatalf("lstat operation temp: %v", statErr) + } + if !info.Mode().IsRegular() { + t.Errorf("operation temp mode = %v, want regular file", info.Mode()) + } + } + }) + } +} + +func TestIssue143ResolveConflictPostCommitCleanupFailurePreservesDuplicate(t *testing.T) { + fixture := issue143NewFileFixture(t) + ops := systemConflictFileOperations() + realRemove := ops.remove + ops.remove = func(path string) error { + if path == fixture.conflictPath { + return syscall.EIO + } + return realRemove(path) + } + + err := replaceConflictAndRemoveSource(fixture.conflictPath, fixture.originalPath, ops) + if err == nil { + t.Fatal("replaceConflictAndRemoveSource() succeeded, want cleanup error") + } + if !strings.HasPrefix(err.Error(), "delete conflict file:") { + t.Errorf("error = %q, want delete conflict file prefix", err) + } + + issue143AssertFileBytes(t, fixture.originalPath, fixture.conflictBytes) + issue143AssertFileBytes(t, fixture.conflictPath, fixture.conflictBytes) + issue143AssertFileBytes(t, fixture.legacyPath, fixture.legacyBytes) + issue143AssertFileBytes(t, fixture.unrelatedPath, fixture.unrelatedBytes) + issue143AssertNoOperationTemps(t, fixture.dir) +} + +func TestIssue143ResolveConflictDoesNotRemoveReusedTempPathAfterCommit(t *testing.T) { + fixture := issue143NewFileFixture(t) + ops := systemConflictFileOperations() + createTemp := ops.createTemp + realRename := ops.rename + var tempPath string + ops.createTemp = func(dir, pattern string) (conflictTempFile, error) { + created, err := createTemp(dir, pattern) + if err == nil { + tempPath = created.Name() + } + return created, err + } + foreignBytes := []byte("issue-143-post-rename-foreign-sentinel") + ops.rename = func(oldPath, newPath string) error { + if err := realRename(oldPath, newPath); err != nil { + return err + } + file, err := os.OpenFile(oldPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if _, err := file.Write(foreignBytes); err != nil { + file.Close() + return err + } + return file.Close() + } + + if err := replaceConflictAndRemoveSource(fixture.conflictPath, fixture.originalPath, ops); err != nil { + t.Fatalf("replaceConflictAndRemoveSource() failed: %v", err) + } + if tempPath == "" { + t.Fatal("operation did not report its temporary path") + } + + issue143AssertFileBytes(t, fixture.originalPath, fixture.conflictBytes) + if _, err := os.Stat(fixture.conflictPath); !os.IsNotExist(err) { + t.Fatalf("conflict path still exists or stat failed: %v", err) + } + issue143AssertFileBytes(t, tempPath, foreignBytes) + issue143AssertFileBytes(t, fixture.legacyPath, fixture.legacyBytes) + issue143AssertFileBytes(t, fixture.unrelatedPath, fixture.unrelatedBytes) +} + +type issue143FaultingTempFile struct { + conflictTempFile + write func([]byte) (int, error) + chmod func(os.FileMode) error + sync func() error + close func() error +} + +func (file *issue143FaultingTempFile) Write(data []byte) (int, error) { + if file.write != nil { + return file.write(data) + } + return file.conflictTempFile.Write(data) +} + +func (file *issue143FaultingTempFile) Chmod(mode os.FileMode) error { + if file.chmod != nil { + return file.chmod(mode) + } + return file.conflictTempFile.Chmod(mode) +} + +func (file *issue143FaultingTempFile) Sync() error { + if file.sync != nil { + return file.sync() + } + return file.conflictTempFile.Sync() +} + +func (file *issue143FaultingTempFile) Close() error { + if file.close != nil { + return file.close() + } + return file.conflictTempFile.Close() +} + +func issue143InjectTempFault(ops *conflictFileOperations, configure func(*issue143FaultingTempFile)) { + createTemp := ops.createTemp + ops.createTemp = func(dir, pattern string) (conflictTempFile, error) { + created, err := createTemp(dir, pattern) + if err != nil { + return nil, err + } + faulting := &issue143FaultingTempFile{conflictTempFile: created} + configure(faulting) + return faulting, nil + } +} + +type issue143FileFixture struct { + dir string + originalPath string + conflictPath string + legacyPath string + unrelatedPath string + originalBytes []byte + conflictBytes []byte + legacyBytes []byte + unrelatedBytes []byte +} + +func issue143NewFileFixture(t *testing.T) issue143FileFixture { + t.Helper() + dir := t.TempDir() + fixture := issue143FileFixture{ + dir: dir, + originalPath: filepath.Join(dir, "doc.md"), + conflictPath: filepath.Join(dir, "doc.sync-conflict-20260406-100000-DEF5678.md"), + legacyPath: filepath.Join(dir, "doc.md.vaultsync-tmp"), + unrelatedPath: filepath.Join(dir, "unrelated-sentinel.md"), + originalBytes: []byte("issue-143-original-sentinel"), + conflictBytes: []byte("issue-143-conflict-sentinel"), + legacyBytes: []byte("issue-143-legacy-temp-sentinel"), + unrelatedBytes: []byte("issue-143-unrelated-sentinel"), + } + + files := []struct { + name string + path string + data []byte + }{ + {name: "original", path: fixture.originalPath, data: fixture.originalBytes}, + {name: "conflict", path: fixture.conflictPath, data: fixture.conflictBytes}, + {name: "legacy temp", path: fixture.legacyPath, data: fixture.legacyBytes}, + {name: "unrelated", path: fixture.unrelatedPath, data: fixture.unrelatedBytes}, + } + for _, file := range files { + if err := os.WriteFile(file.path, file.data, 0o600); err != nil { + t.Fatalf("write %s: %v", file.name, err) + } + issue143AssertFileBytes(t, file.path, file.data) + } + + return fixture +} + +func issue143AssertPreCommitFixture(t *testing.T, fixture issue143FileFixture) { + t.Helper() + issue143AssertFileBytes(t, fixture.originalPath, fixture.originalBytes) + issue143AssertFileBytes(t, fixture.conflictPath, fixture.conflictBytes) + issue143AssertFileBytes(t, fixture.legacyPath, fixture.legacyBytes) + issue143AssertFileBytes(t, fixture.unrelatedPath, fixture.unrelatedBytes) +} + +func issue143AssertFileBytes(t *testing.T, path string, want []byte) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %q: %v", filepath.Base(path), err) + } + if !bytes.Equal(got, want) { + t.Errorf("%q bytes = %q, want %q", filepath.Base(path), got, want) + } +} + +func issue143AssertNoOperationTemps(t *testing.T, dir string) { + t.Helper() + temps := issue143OperationTemps(t, dir) + if len(temps) != 0 { + t.Errorf("successful resolution left VaultSync temporary files: %v", temps) + } +} + +func issue143OperationTemps(t *testing.T, dir string) []string { + t.Helper() + temps, err := filepath.Glob(filepath.Join(dir, conflictResolveTempPattern)) + if err != nil { + t.Fatalf("glob VaultSync temporary files: %v", err) + } + return temps +} + func TestResolveConflictErrors(t *testing.T) { configDir := testConfigDir(t) From 87e0cbb49f090147f24adce87d2bb92e0483dc0d Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Sun, 16 Aug 2026 12:18:16 +0200 Subject: [PATCH 2/2] docs(repo): reference AGENTS.md The local operating manual was renamed from CLAUDE.md to AGENTS.md. Update the localization-lint comment so it no longer points at the retired filename. This is a comment-only change with no lint behavior change. --- ios/scripts/strings-key-parity.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/scripts/strings-key-parity.sh b/ios/scripts/strings-key-parity.sh index 43341ea..bac90a8 100755 --- a/ios/scripts/strings-key-parity.sh +++ b/ios/scripts/strings-key-parity.sh @@ -12,7 +12,7 @@ # # Keys are extracted with an escape-aware pattern (backslash escapes like \" are # part of the key), so keys containing escaped quotes do not false-positive the -# way the naive `grep -o '^"[^"]*"'` check documented in CLAUDE.md does. +# way the naive `grep -o '^"[^"]*"'` check documented in AGENTS.md does. # # Usage: ios/scripts/strings-key-parity.sh (exit 0 = parity, 1 = violations)