Skip to content

Commit 134da1d

Browse files
buffer: fix sudo TTY race on privileged file save
When saving a protected file, micro spawned two sudo dd subprocesses concurrently: a truncation dd and a write dd. Both competed for the TTY to prompt for the sudo password, racing on TCGETS2/TCSETS2 ioctls and deadlocking stdin routing. This strictly fails with sudo-rs, which enforces auditable escalation boundaries. Fix by removing notrunc and the separate truncation subprocess entirely. A single dd subprocess now handles truncation and write in one shot, making the race impossible by construction. The conv=fsync guarantee is preserved on platforms that support it. Move writeBackup() before openFile() in safeWrite() for the sudo path so the backup always exists before dd truncates the file. If openFile() fails after the backup is created, return OverwriteError so the user knows their data is safe in the backup. The non-sudo path is unchanged. Link: #4050 Signed-off-by: Nilton Perim Neto <niltonperimneto@gmail.com>
1 parent d976b3f commit 134da1d

1 file changed

Lines changed: 39 additions & 30 deletions

File tree

internal/buffer/save.go

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import (
2525
const LargeFileThreshold = 50000
2626

2727
type wrappedFile struct {
28-
name string
2928
writeCloser io.WriteCloser
3029
withSudo bool
3130
screenb bool
@@ -82,13 +81,13 @@ func openFile(name string, withSudo bool) (wrappedFile, error) {
8281
var sigChan chan os.Signal
8382

8483
if withSudo {
85-
conv := "notrunc"
84+
args := []string{"dd", "bs=4k", "of=" + name}
8685
// TODO: both platforms do not support dd with conv=fsync yet
8786
if !(runtime.GOOS == "illumos" || runtime.GOOS == "netbsd") {
88-
conv += ",fsync"
87+
args = append(args, "conv=fsync")
8988
}
9089

91-
cmd = exec.Command(config.GlobalSettings["sucmd"].(string), "dd", "bs=4k", "conv="+conv, "of="+name)
90+
cmd = exec.Command(config.GlobalSettings["sucmd"].(string), args...)
9291
writeCloser, err = cmd.StdinPipe()
9392
if err != nil {
9493
return wrappedFile{}, err
@@ -99,16 +98,16 @@ func openFile(name string, withSudo bool) (wrappedFile, error) {
9998
signal.Notify(sigChan, os.Interrupt)
10099

101100
screenb = screen.TempFini()
101+
102102
// need to start the process now, otherwise when we flush the file
103103
// contents to its stdin it might hang because the kernel's pipe size
104104
// is too small to handle the full file contents all at once
105105
err = cmd.Start()
106106
if err != nil {
107+
writeCloser.Close()
107108
screen.TempStart(screenb)
108-
109109
signal.Notify(util.Sigterm, os.Interrupt)
110110
signal.Stop(sigChan)
111-
112111
return wrappedFile{}, err
113112
}
114113
} else {
@@ -118,18 +117,7 @@ func openFile(name string, withSudo bool) (wrappedFile, error) {
118117
}
119118
}
120119

121-
return wrappedFile{name, writeCloser, withSudo, screenb, cmd, sigChan}, nil
122-
}
123-
124-
func (wf wrappedFile) Truncate() error {
125-
if wf.withSudo {
126-
// we don't need to stop the screen here, since it is still stopped
127-
// by openFile()
128-
// truncate might not be available on every platfom, so use dd instead
129-
cmd := exec.Command(config.GlobalSettings["sucmd"].(string), "dd", "count=0", "of="+wf.name)
130-
return cmd.Run()
131-
}
132-
return wf.writeCloser.(*os.File).Truncate(0)
120+
return wrappedFile{writeCloser, withSudo, screenb, cmd, sigChan}, nil
133121
}
134122

135123
func (wf wrappedFile) Write(b *SharedBuffer) (int, error) {
@@ -150,9 +138,12 @@ func (wf wrappedFile) Write(b *SharedBuffer) (int, error) {
150138
eol = []byte{'\n'}
151139
}
152140

153-
err := wf.Truncate()
154-
if err != nil {
155-
return 0, err
141+
if !wf.withSudo {
142+
f := wf.writeCloser.(*os.File)
143+
err := f.Truncate(0)
144+
if err != nil {
145+
return 0, err
146+
}
156147
}
157148

158149
// write lines
@@ -354,26 +345,44 @@ func (b *Buffer) saveToFile(filename string, withSudo bool, autoSave bool) error
354345
// This means that the file is not overwritten directly but by writing to the
355346
// backup file first.
356347
func (b *SharedBuffer) safeWrite(path string, withSudo bool, newFile bool) (int, error) {
357-
file, err := openFile(path, withSudo)
358-
if err != nil {
359-
return 0, err
360-
}
348+
var err error
361349

362350
defer func() {
363351
if newFile && err != nil {
364352
os.Remove(path)
365353
}
366354
}()
367355

368-
// Try to backup first before writing
369-
backupName, resolveName, err := b.writeBackup(path)
356+
// When using sudo, create the backup before opening the file because
357+
// openFile() truncates the target immediately. For non-sudo saves,
358+
// openFile() does not truncate on open, i.e. open is non-destructive,
359+
// so only create the backup after open succeeds.
360+
var backupName, resolveName string
361+
if withSudo {
362+
backupName, resolveName, err = b.writeBackup(path)
363+
if err != nil {
364+
return 0, err
365+
}
366+
delete(requestedBackups, b)
367+
}
368+
369+
file, err := openFile(path, withSudo)
370370
if err != nil {
371-
file.Close()
371+
if withSudo {
372+
return 0, util.OverwriteError{err, backupName}
373+
}
372374
return 0, err
373375
}
374376

375-
// Backup saved, so cancel pending periodic backup, if any
376-
delete(requestedBackups, b)
377+
if !withSudo {
378+
backupName, resolveName, err = b.writeBackup(path)
379+
if err != nil {
380+
file.Close()
381+
return 0, err
382+
}
383+
// Backup saved, so cancel pending periodic backup, if any
384+
delete(requestedBackups, b)
385+
}
377386

378387
b.forceKeepBackup = true
379388
size := 0

0 commit comments

Comments
 (0)