Skip to content

Commit 2b749c4

Browse files
author
merge-queue-bot
committed
Merge PR #221: Preserve file permissions and fix option injection in merge operations
2 parents b0ecfec + 041dc8f commit 2b749c4

6 files changed

Lines changed: 1089 additions & 34 deletions

File tree

cmd/mdsmith/mergedriver.go

Lines changed: 137 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,76 @@ func runMergeDriver(args []string) int {
7272
}
7373
}
7474

75+
// mergeFileMode returns the low 9 permission bits (Mode().Perm()) of the named
76+
// file, or defaultMode if the file cannot be stat'd for any reason (including
77+
// ENOENT and permission errors). Uses Lstat so symlinks are not followed.
78+
func mergeFileMode(name string, defaultMode os.FileMode) os.FileMode {
79+
if info, err := os.Lstat(name); err == nil {
80+
return info.Mode().Perm()
81+
}
82+
return defaultMode
83+
}
84+
85+
// lstatFn is a variable so tests can substitute a failing implementation
86+
// to exercise non-ENOENT Lstat error paths in guardRegularFile.
87+
var lstatFn = os.Lstat
88+
89+
// guardRegularFile returns an error if:
90+
// - path exists and is not a regular file (symlink, directory, …), or
91+
// - os.Lstat fails for any reason other than ENOENT.
92+
//
93+
// A missing file (ENOENT) is allowed; the caller decides whether that
94+
// is an error at a higher level.
95+
func guardRegularFile(path string) error {
96+
info, err := lstatFn(path)
97+
if err != nil {
98+
if os.IsNotExist(err) {
99+
return nil
100+
}
101+
return fmt.Errorf("%s: lstat: %w", path, err)
102+
}
103+
if !info.Mode().IsRegular() {
104+
return fmt.Errorf("%s: not a regular file", path)
105+
}
106+
return nil
107+
}
108+
109+
// guardFn is a variable so tests can substitute a failing implementation
110+
// to exercise guardRegularFile error paths without creating real symlinks.
111+
var guardFn = guardRegularFile
112+
113+
// osWriteFile is a variable so tests can substitute a failing implementation
114+
// to exercise error paths without needing OS tricks.
115+
var osWriteFile = os.WriteFile
116+
117+
// readFileLimited is a variable so tests can substitute a failing
118+
// implementation to exercise the read-fixed-file error path in readAndRestore.
119+
var readFileLimited = lint.ReadFileLimited
120+
121+
// fixFileInPlaceFn is a variable so tests can substitute a failing
122+
// implementation to exercise the fix-failed error path in fixAtRealPath.
123+
var fixFileInPlaceFn = fixFileInPlace
124+
75125
// mergeAndClean performs the 3-way merge and strips conflict markers.
76126
// Returns the cleaned content and an exit code (0 on success).
77127
func mergeAndClean(base, ours, theirs string, maxBytes int64) ([]byte, int) {
128+
// Validate all three inputs before letting git read or write them,
129+
// so symlinks cannot pull in data from outside the worktree.
130+
for _, path := range []string{ours, base, theirs} {
131+
if err := guardFn(path); err != nil {
132+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
133+
return nil, 2
134+
}
135+
}
136+
// Capture the permissions of git's temp file. os.WriteFile preserves
137+
// the existing mode on truncating writes, so this is only the fallback
138+
// creation mode for files that do not yet exist.
139+
oursMode := mergeFileMode(ours, 0o644)
140+
78141
// Step 1: standard 3-way merge into ours.
79-
mergeCmd := exec.Command("git", "merge-file", ours, base, theirs)
142+
// Use "--" to prevent file paths starting with "-" from being
143+
// interpreted as git options (option injection).
144+
mergeCmd := exec.Command("git", "merge-file", "--", ours, base, theirs)
80145
mergeCmd.Stderr = os.Stderr
81146
mergeErr := mergeCmd.Run()
82147

@@ -90,14 +155,23 @@ func mergeAndClean(base, ours, theirs string, maxBytes int64) ([]byte, int) {
90155
}
91156

92157
// Step 2: strip conflict markers inside regenerable sections.
158+
// Re-check before reading to guard against a symlink swap after the merge.
159+
if err := guardFn(ours); err != nil {
160+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
161+
return nil, 2
162+
}
93163
content, err := lint.ReadFileLimited(ours, maxBytes)
94164
if err != nil {
95165
fmt.Fprintf(os.Stderr, "mdsmith: reading merge result: %v\n", err)
96166
return nil, 2
97167
}
98-
99168
cleaned := stripSectionConflicts(content)
100-
if err := os.WriteFile(ours, cleaned, 0644); err != nil {
169+
// Re-check immediately before writing to narrow the TOCTOU window.
170+
if err := guardFn(ours); err != nil {
171+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
172+
return nil, 2
173+
}
174+
if err := osWriteFile(ours, cleaned, oursMode); err != nil {
101175
fmt.Fprintf(os.Stderr, "mdsmith: writing cleaned merge: %v\n", err)
102176
return nil, 2
103177
}
@@ -166,55 +240,91 @@ func runMergeDriverRun(args []string) int {
166240
// fixAtRealPath writes cleaned content to pathname, runs mdsmith
167241
// fix, copies the result to ours, and restores pathname.
168242
func fixAtRealPath(cleaned []byte, ours, pathname string, maxBytes int64) ([]byte, int) {
169-
// Capture the original file mode so we can preserve permissions.
170-
fileMode := os.FileMode(0644)
171-
if info, err := os.Stat(pathname); err == nil {
172-
fileMode = info.Mode()
173-
}
243+
pathnameMode := mergeFileMode(pathname, 0o644)
244+
oursMode := mergeFileMode(ours, 0o644)
174245

246+
if err := guardFn(pathname); err != nil {
247+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
248+
return nil, 2
249+
}
250+
if err := guardFn(ours); err != nil {
251+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
252+
return nil, 2
253+
}
175254
backup, backupErr := lint.ReadFileLimited(pathname, maxBytes)
176255
if backupErr != nil && !os.IsNotExist(backupErr) {
177256
fmt.Fprintf(os.Stderr, "mdsmith: reading %s for backup: %v\n", pathname, backupErr)
178257
return nil, 2
179258
}
180-
if err := os.WriteFile(pathname, cleaned, fileMode); err != nil {
259+
// Re-check immediately before writing to narrow the TOCTOU window.
260+
if err := guardFn(pathname); err != nil {
261+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
262+
return nil, 2
263+
}
264+
if err := osWriteFile(pathname, cleaned, pathnameMode); err != nil {
181265
fmt.Fprintf(os.Stderr, "mdsmith: writing to %s: %v\n", pathname, err)
182266
return nil, 2
183267
}
184268

185-
fixErr := fixFileInPlace(pathname, maxBytes)
269+
fixErr := fixFileInPlaceFn(pathname, maxBytes)
270+
271+
fixed, code := readAndRestore(pathname, backup, backupErr, pathnameMode, maxBytes)
272+
if code != 0 {
273+
return fixed, code
274+
}
275+
276+
if fixErr != nil {
277+
fmt.Fprintf(os.Stderr, "mdsmith: fix failed: %v\n", fixErr)
278+
return fixed, 2
279+
}
186280

187-
// Restore the original working tree file before checking
188-
// fixErr, so the working tree is always left clean.
189-
fixed, err := lint.ReadFileLimited(pathname, maxBytes)
281+
// Re-check ours immediately before writing the final merge result.
282+
if err := guardFn(ours); err != nil {
283+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
284+
return nil, 2
285+
}
286+
if err := osWriteFile(ours, fixed, oursMode); err != nil {
287+
fmt.Fprintf(os.Stderr, "mdsmith: writing merge output: %v\n", err)
288+
return nil, 2
289+
}
290+
return fixed, 0
291+
}
292+
293+
// readAndRestore reads the fixed content from pathname, restores its original
294+
// content (or removes it if it did not previously exist), and returns the fixed
295+
// bytes. A non-zero exit code means the caller should propagate the error.
296+
func readAndRestore(pathname string, backup []byte, backupErr error, mode os.FileMode, maxBytes int64) ([]byte, int) {
297+
// Re-check before reading the fixed result to guard against a symlink swap.
298+
if err := guardFn(pathname); err != nil {
299+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
300+
return nil, 2
301+
}
302+
fixed, err := readFileLimited(pathname, maxBytes)
190303
if err != nil {
191304
fmt.Fprintf(os.Stderr, "mdsmith: reading fixed file: %v\n", err)
192305
return nil, 2
193306
}
194307

195308
var restoreErr error
196309
if backupErr == nil {
197-
restoreErr = os.WriteFile(pathname, backup, fileMode)
310+
// Re-check before restore to narrow TOCTOU window.
311+
if err := guardFn(pathname); err != nil {
312+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
313+
return fixed, 2
314+
}
315+
restoreErr = osWriteFile(pathname, backup, mode)
198316
} else if os.IsNotExist(backupErr) {
317+
// Re-check before removal to avoid removing a swapped-in directory.
318+
if err := guardFn(pathname); err != nil {
319+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
320+
return fixed, 2
321+
}
199322
restoreErr = os.Remove(pathname)
200323
}
201324
if restoreErr != nil {
202325
fmt.Fprintf(os.Stderr, "mdsmith: restoring %s: %v\n", pathname, restoreErr)
203326
return fixed, 2
204327
}
205-
206-
// Check fixErr before writing to ours so broken content is
207-
// not used as the merge result.
208-
if fixErr != nil {
209-
fmt.Fprintf(os.Stderr, "mdsmith: fix failed: %v\n", fixErr)
210-
return fixed, 2
211-
}
212-
213-
if err := os.WriteFile(ours, fixed, 0644); err != nil {
214-
fmt.Fprintf(os.Stderr, "mdsmith: writing merge output: %v\n", err)
215-
return nil, 2
216-
}
217-
218328
return fixed, 0
219329
}
220330

0 commit comments

Comments
 (0)