@@ -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).
77127func 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.
168242func 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