From c61421143d2a28811092d9dd0d406824867db282 Mon Sep 17 00:00:00 2001 From: valoq Date: Wed, 1 Jul 2026 09:10:21 +0200 Subject: [PATCH 01/11] improve newline handling --- app.go | 8 +------- client.go | 6 +++--- eval.go | 5 ++++- nav.go | 39 +++++++++++++++++++++++++++++++++------ ui.go | 2 +- 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/app.go b/app.go index 725962b09..269cc31c5 100644 --- a/app.go +++ b/app.go @@ -160,13 +160,7 @@ func loadFiles() (clipboard clipboard, err error) { } func saveFiles(clipboard clipboard) error { - for _, path := range clipboard.paths { - // the clipboard file stores one path per line so a newline cannot be saved - if strings.ContainsAny(path, "\n\r") { - return fmt.Errorf("cannot copy %s because the name contains a newline", path) - } - } - + // clipboard.paths is gated by currFileOrSelectionsValid, so no newline check is needed here if err := os.MkdirAll(filepath.Dir(gFilesPath), 0o700); err != nil { return fmt.Errorf("creating data directory: %w", err) } diff --git a/client.go b/client.go index cae82ea4e..ce4e09fe0 100644 --- a/client.go +++ b/client.go @@ -96,7 +96,7 @@ func run() { // rejected unconditionally (frame integrity for line-oriented consumers); // control bytes are stripped only when stdout is a terminal. func printPath(label, path string, stdoutIsTerminal bool) { - if strings.ContainsAny(path, "\n\r") { + if containsNewline(path) { log.Printf("%s: skipping path with newline: %q", label, path) return } @@ -107,7 +107,7 @@ func printPath(label, path string, stdoutIsTerminal bool) { } func writeLastDir(filename, lastDir string) { - if strings.ContainsAny(lastDir, "\n\r") { + if containsNewline(lastDir) { log.Printf("last-dir: path contains newline: %q", lastDir) return } @@ -133,7 +133,7 @@ func writeSelection(filename string, selection []string) { defer f.Close() filtered := slices.DeleteFunc(slices.Clone(selection), func(s string) bool { - if strings.ContainsAny(s, "\n\r") { + if containsNewline(s) { log.Printf("selection: skipping path with newline: %q", s) return true } diff --git a/eval.go b/eval.go index 07b157098..f5572dc31 100644 --- a/eval.go +++ b/eval.go @@ -1113,7 +1113,7 @@ func (e *callExpr) eval(app *app, _ []string) { onChdir(app) } else { if gSelectionPath != "" || gPrintSelection { - app.selectionOut, _ = app.nav.currFileOrSelections() + app.selectionOut, _ = app.nav.currFileOrSelectionsValid() app.quitChan <- struct{}{} return } @@ -1689,6 +1689,9 @@ func (e *callExpr) eval(app *app, _ []string) { case "visual-accept": dir := app.nav.currDir() for _, path := range dir.visualSelections() { + if containsNewline(path) { + continue // quarantine: a newline-containing path is never selected + } if _, ok := app.nav.selections[path]; !ok { app.nav.selections[path] = app.nav.selectionInd app.nav.selectionInd++ diff --git a/nav.go b/nav.go index 397aab358..3b126571d 100644 --- a/nav.go +++ b/nav.go @@ -131,6 +131,11 @@ func (file *file) isPreviewable() bool { return !file.IsDir() || gOpts.dirpreviews } +// containsNewline reports whether a name or path contains a newline or carriage return. +func containsNewline(s string) bool { + return strings.ContainsAny(s, "\n\r") +} + type fakeStat struct { name string } @@ -713,7 +718,7 @@ func (nav *nav) position() { func (nav *nav) exportFiles() { var currFile string - if curr := nav.currFile(); curr != nil { + if curr := nav.currFile(); curr != nil && !containsNewline(curr.path) { currFile = quoteString(curr.path) } @@ -1257,6 +1262,9 @@ func (nav *nav) toggleSelection(path string) { nav.selectionInd = 0 } } else { + if containsNewline(path) { + return // quarantine: a newline-containing path is never selected + } nav.selections[path] = nav.selectionInd nav.selectionInd++ } @@ -1277,7 +1285,7 @@ func (nav *nav) tagToggleSelection(path, tag string) { } func (nav *nav) tagToggle(tag string) error { - list, err := nav.currFileOrSelections() + list, err := nav.currFileOrSelectionsValid() if err != nil { return err } @@ -1294,7 +1302,7 @@ func (nav *nav) tagToggle(tag string) error { } func (nav *nav) tag(tag string) error { - list, err := nav.currFileOrSelections() + list, err := nav.currFileOrSelectionsValid() if err != nil { return err } @@ -1322,7 +1330,7 @@ func (nav *nav) unselect() { } func (nav *nav) save(mode clipboardMode) error { - list, err := nav.currFileOrSelections() + list, err := nav.currFileOrSelectionsValid() if err != nil { return err } @@ -1567,6 +1575,11 @@ func (nav *nav) rename() error { oldPath := nav.renameOldPath newPath := nav.renameNewPath + // refuse to create a name containing a newline (POSIX.1-2024 / Austin Group #251) + if containsNewline(filepath.Base(newPath)) { + return fmt.Errorf("invalid name: %q contains a newline", filepath.Base(newPath)) + } + if err := os.Rename(oldPath, newPath); err != nil { return err } @@ -1881,7 +1894,7 @@ func (nav *nav) writeMarks() error { if strings.Contains(gOpts.tempmarks, k) { continue } - if strings.ContainsAny(nav.marks[k], "\n\r") { + if containsNewline(nav.marks[k]) { log.Printf("marks: skipping mark '%s' with newline in path: %q", k, nav.marks[k]) continue } @@ -1940,7 +1953,7 @@ func (nav *nav) writeTags() error { defer f.Close() for _, k := range slices.Sorted(maps.Keys(nav.tags)) { - if strings.ContainsAny(k, "\n\r") { + if containsNewline(k) { log.Printf("tags: skipping tag with newline in path: %q", k) continue } @@ -2008,6 +2021,20 @@ func (nav *nav) currFileOrSelections() ([]string, error) { return nil, errors.New("no file selected") } +// currFileOrSelectionsValid is currFileOrSelections but errors if any path contains a newline. +func (nav *nav) currFileOrSelectionsValid() ([]string, error) { + list, err := nav.currFileOrSelections() + if err != nil { + return nil, err + } + for _, path := range list { + if containsNewline(path) { + return nil, fmt.Errorf("%q contains a newline", filepath.Base(path)) + } + } + return list, nil +} + func (nav *nav) calcDirSize() error { calc := func(f *file) error { if f.IsDir() { diff --git a/ui.go b/ui.go index d55a06664..afe3c832f 100644 --- a/ui.go +++ b/ui.go @@ -1354,7 +1354,7 @@ func listFilesInCurrDir(nav *nav) string { b := new(strings.Builder) for _, file := range dir.files { - if strings.ContainsAny(file.path, "\n\r") { + if containsNewline(file.path) { continue } fmt.Fprintln(b, file.path) From bdecd6a9c1a5a3750bb71f900324b026b4a4b43e Mon Sep 17 00:00:00 2001 From: valoq Date: Thu, 2 Jul 2026 00:19:07 +0200 Subject: [PATCH 02/11] also cover fv --- nav.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nav.go b/nav.go index 3b126571d..029bdbb73 100644 --- a/nav.go +++ b/nav.go @@ -730,6 +730,9 @@ func (nav *nav) exportFiles() { var vSelections []string for _, selection := range nav.currDir().visualSelections() { + if containsNewline(selection) { + continue // $fv is filesep-joined, so refuse newline paths like $f above + } vSelections = append(vSelections, quoteString(selection)) } currVSelections := strings.Join(vSelections, gOpts.filesep) From 94f12464469747f47a71801a7bccf3897eb22e76 Mon Sep 17 00:00:00 2001 From: valoq Date: Thu, 2 Jul 2026 00:50:23 +0200 Subject: [PATCH 03/11] handle PWD --- main.go | 3 +++ nav.go | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 7d9887945..fc3014b74 100644 --- a/main.go +++ b/main.go @@ -78,6 +78,9 @@ func exportEnvVars() { if err != nil { fmt.Fprintf(os.Stderr, "getting current directory: %s\n", err) } + if containsNewline(dir) { + dir = "" // refuse newline in $OLDPWD; it reaches shell commands + } os.Setenv("OLDPWD", dir) level, err := strconv.Atoi(envLevel) diff --git a/nav.go b/nav.go index 029bdbb73..85e5430ac 100644 --- a/nav.go +++ b/nav.go @@ -740,7 +740,11 @@ func (nav *nav) exportFiles() { os.Setenv("f", currFile) os.Setenv("fs", currSelections) os.Setenv("fv", currVSelections) - os.Setenv("PWD", quoteString(nav.currDir().path)) + pwd := nav.currDir().path + if containsNewline(pwd) { + pwd = "" // refuse newline in $PWD; it reaches shell commands like $f + } + os.Setenv("PWD", quoteString(pwd)) if len(selections) == 0 { os.Setenv("fx", currFile) From 345ecfb51e046f9ac8a4c7195e01f11624e9b504 Mon Sep 17 00:00:00 2001 From: valoq Date: Thu, 2 Jul 2026 01:35:16 +0200 Subject: [PATCH 04/11] fix rename check --- nav.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nav.go b/nav.go index 85e5430ac..65a736a58 100644 --- a/nav.go +++ b/nav.go @@ -1582,8 +1582,8 @@ func (nav *nav) rename() error { oldPath := nav.renameOldPath newPath := nav.renameNewPath - // refuse to create a name containing a newline (POSIX.1-2024 / Austin Group #251) - if containsNewline(filepath.Base(newPath)) { + // refuse a newline anywhere in the target (POSIX.1-2024 / Austin Group #251) + if containsNewline(newPath) { return fmt.Errorf("invalid name: %q contains a newline", filepath.Base(newPath)) } From e57df104bd90c11777f624159c330f02cb2df605 Mon Sep 17 00:00:00 2001 From: valoq Date: Thu, 2 Jul 2026 01:50:21 +0200 Subject: [PATCH 05/11] improve check --- eval.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/eval.go b/eval.go index f5572dc31..a36f8deb4 100644 --- a/eval.go +++ b/eval.go @@ -1867,6 +1867,11 @@ func (e *callExpr) eval(app *app, _ []string) { if !filepath.IsAbs(newPath) { newPath = filepath.Join(wd, newPath) } + // reject before the create-parent prompt so no newline dir is made + if containsNewline(newPath) { + app.ui.echoerrf("rename: %q contains a newline", filepath.Base(newPath)) + return + } if oldPath == newPath { return } From 4c846759d98e3ff7ec0fc010418b77f5213a9988 Mon Sep 17 00:00:00 2001 From: valoq Date: Sat, 4 Jul 2026 04:46:17 +0200 Subject: [PATCH 06/11] centralize path checks --- app.go | 2 +- client.go | 11 ++------- eval.go | 2 +- nav.go | 71 ++++++++++++++++++++++++++++++++----------------------- 4 files changed, 46 insertions(+), 40 deletions(-) diff --git a/app.go b/app.go index 269cc31c5..e705a69da 100644 --- a/app.go +++ b/app.go @@ -160,7 +160,7 @@ func loadFiles() (clipboard clipboard, err error) { } func saveFiles(clipboard clipboard) error { - // clipboard.paths is gated by currFileOrSelectionsValid, so no newline check is needed here + // clipboard.paths is already checked by currFileOrSelections, so no newline check is needed here if err := os.MkdirAll(filepath.Dir(gFilesPath), 0o700); err != nil { return fmt.Errorf("creating data directory: %w", err) } diff --git a/client.go b/client.go index ce4e09fe0..27697228e 100644 --- a/client.go +++ b/client.go @@ -7,7 +7,6 @@ import ( "log" "net" "os" - "slices" "strings" "sync" "time" @@ -132,14 +131,8 @@ func writeSelection(filename string, selection []string) { } defer f.Close() - filtered := slices.DeleteFunc(slices.Clone(selection), func(s string) bool { - if containsNewline(s) { - log.Printf("selection: skipping path with newline: %q", s) - return true - } - return false - }) - _, err = f.WriteString(strings.Join(filtered, "\n")) + // the selection is already checked by currFileOrSelections before quitting + _, err = f.WriteString(strings.Join(selection, "\n")) if err != nil { log.Printf("writing selection file: %s", err) } diff --git a/eval.go b/eval.go index a36f8deb4..302b4f0fb 100644 --- a/eval.go +++ b/eval.go @@ -1113,7 +1113,7 @@ func (e *callExpr) eval(app *app, _ []string) { onChdir(app) } else { if gSelectionPath != "" || gPrintSelection { - app.selectionOut, _ = app.nav.currFileOrSelectionsValid() + app.selectionOut, _ = app.nav.currFileOrSelections() app.quitChan <- struct{}{} return } diff --git a/nav.go b/nav.go index 65a736a58..db2e2e2b2 100644 --- a/nav.go +++ b/nav.go @@ -136,6 +136,11 @@ func containsNewline(s string) bool { return strings.ContainsAny(s, "\n\r") } +// errNewlinePath is the error shown when a path contains a newline. +func errNewlinePath(path string) error { + return fmt.Errorf("%q contains a newline; use rename to fix the name", path) +} + type fakeStat struct { name string } @@ -1262,25 +1267,28 @@ func (nav *nav) move(index int) bool { } } -func (nav *nav) toggleSelection(path string) { +func (nav *nav) toggleSelection(path string) error { if _, ok := nav.selections[path]; ok { delete(nav.selections, path) if len(nav.selections) == 0 { nav.selectionInd = 0 } - } else { - if containsNewline(path) { - return // quarantine: a newline-containing path is never selected - } - nav.selections[path] = nav.selectionInd - nav.selectionInd++ + return nil + } + if containsNewline(path) { + // a newline path must never enter the selections because they are written line by line + return errNewlinePath(path) } + nav.selections[path] = nav.selectionInd + nav.selectionInd++ + return nil } -func (nav *nav) toggle() { +func (nav *nav) toggle() error { if curr := nav.currFile(); curr != nil { - nav.toggleSelection(curr.path) + return nav.toggleSelection(curr.path) } + return nil } func (nav *nav) tagToggleSelection(path, tag string) { @@ -1292,7 +1300,7 @@ func (nav *nav) tagToggleSelection(path, tag string) { } func (nav *nav) tagToggle(tag string) error { - list, err := nav.currFileOrSelectionsValid() + list, err := nav.currFileOrSelections() if err != nil { return err } @@ -1309,7 +1317,7 @@ func (nav *nav) tagToggle(tag string) error { } func (nav *nav) tag(tag string) error { - list, err := nav.currFileOrSelectionsValid() + list, err := nav.currFileOrSelections() if err != nil { return err } @@ -1325,10 +1333,17 @@ func (nav *nav) tag(tag string) error { return nil } -func (nav *nav) invert() { +func (nav *nav) invert() error { + skipped := 0 for _, file := range nav.currDir().files { - nav.toggleSelection(file.path) + if err := nav.toggleSelection(file.path); err != nil { + skipped++ + } } + if skipped > 0 { + return fmt.Errorf("skipped %d name(s) containing a newline; use rename to fix", skipped) + } + return nil } func (nav *nav) unselect() { @@ -1337,7 +1352,7 @@ func (nav *nav) unselect() { } func (nav *nav) save(mode clipboardMode) error { - list, err := nav.currFileOrSelectionsValid() + list, err := nav.currFileOrSelections() if err != nil { return err } @@ -1661,6 +1676,7 @@ func (nav *nav) globSel(pattern string, invert bool) error { dir := nav.currDir() anyMatched := false + skipped := 0 for i := range dir.files { matched, err := filepath.Match(pattern, dir.files[i].Name()) if err != nil { @@ -1670,7 +1686,9 @@ func (nav *nav) globSel(pattern string, invert bool) error { anyMatched = true fpath := filepath.Join(dir.path, dir.files[i].Name()) if _, ok := nav.selections[fpath]; ok == invert { - nav.toggleSelection(fpath) + if err := nav.toggleSelection(fpath); err != nil { + skipped++ + } } } } @@ -1679,6 +1697,10 @@ func (nav *nav) globSel(pattern string, invert bool) error { return fmt.Errorf("glob-select: pattern not found: %s", pattern) } + if skipped > 0 { + return fmt.Errorf("glob-select: skipped %d name(s) containing a newline; use rename to fix", skipped) + } + return nil } @@ -2016,32 +2038,23 @@ func (nav *nav) currSelections() []string { return paths } +// currFileOrSelections returns the files a command works on and refuses paths with a newline. func (nav *nav) currFileOrSelections() ([]string, error) { if sel := nav.currSelections(); len(sel) > 0 { return sel, nil } if curr := nav.currFile(); curr != nil { + // selections are checked when they are added, so only the current file needs a check + if containsNewline(curr.path) { + return nil, errNewlinePath(curr.path) + } return []string{curr.path}, nil } return nil, errors.New("no file selected") } -// currFileOrSelectionsValid is currFileOrSelections but errors if any path contains a newline. -func (nav *nav) currFileOrSelectionsValid() ([]string, error) { - list, err := nav.currFileOrSelections() - if err != nil { - return nil, err - } - for _, path := range list { - if containsNewline(path) { - return nil, fmt.Errorf("%q contains a newline", filepath.Base(path)) - } - } - return list, nil -} - func (nav *nav) calcDirSize() error { calc := func(f *file) error { if f.IsDir() { From 5cb4e8b4f36a1ae8f27ce4c188d010cfb6a2c8a7 Mon Sep 17 00:00:00 2001 From: valoq Date: Sat, 4 Jul 2026 04:46:48 +0200 Subject: [PATCH 07/11] improve error messages --- eval.go | 58 +++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/eval.go b/eval.go index 302b4f0fb..d21c79460 100644 --- a/eval.go +++ b/eval.go @@ -926,7 +926,13 @@ func insert(app *app, arg string) { case app.ui.cmdPrefix == "mark-save: ": normal(app) - app.nav.marks[arg] = app.nav.currDir().path + path := app.nav.currDir().path + if containsNewline(path) { + // refuse instead of storing a mark that writeMarks would silently drop + app.ui.echoerrf("mark-save: %s", errNewlinePath(path)) + return + } + app.nav.marks[arg] = path if err := app.nav.writeMarks(); err != nil { app.ui.echoerrf("mark-save: %s", err) return @@ -1113,11 +1119,21 @@ func (e *callExpr) eval(app *app, _ []string) { onChdir(app) } else { if gSelectionPath != "" || gPrintSelection { - app.selectionOut, _ = app.nav.currFileOrSelections() + list, err := app.nav.currFileOrSelections() + if err != nil { + app.ui.echoerrf("open: %s", err) + return + } + app.selectionOut = list app.quitChan <- struct{}{} return } + // refuse a newline name here because a custom open or the default opener would run with an empty $f + if containsNewline(curr.path) { + app.ui.echoerrf("open: %s", errNewlinePath(curr.path)) + return + } if cmd, ok := gOpts.cmds["open"]; ok { cmd.eval(app, e.args) } @@ -1176,7 +1192,9 @@ func (e *callExpr) eval(app *app, _ []string) { } case "toggle": if len(e.args) == 0 { - app.nav.toggle() + if err := app.nav.toggle(); err != nil { + app.ui.echoerrf("toggle: %s", err) + } } else { for _, path := range e.args { path, err := filepath.Abs(replaceTilde(path)) @@ -1190,11 +1208,15 @@ func (e *callExpr) eval(app *app, _ []string) { continue } - app.nav.toggleSelection(path) + if err := app.nav.toggleSelection(path); err != nil { + app.ui.echoerrf("toggle: %s", err) + } } } case "invert": - app.nav.invert() + if err := app.nav.invert(); err != nil { + app.ui.echoerrf("invert: %s", err) + } case "unselect": app.nav.unselect() case "glob-select": @@ -1292,6 +1314,13 @@ func (e *callExpr) eval(app *app, _ []string) { app.nav.reload() app.ui.loadFile(app, true) case "delete": + // check the files before dispatch so a custom delete command is refused like the builtin + list, err := app.nav.currFileOrSelections() + if err != nil { + app.ui.echoerrf("delete: %s", err) + return + } + if cmd, ok := gOpts.cmds["delete"]; ok { cmd.eval(app, e.args) app.nav.unselect() @@ -1305,12 +1334,6 @@ func (e *callExpr) eval(app *app, _ []string) { } } } else { - list, err := app.nav.currFileOrSelections() - if err != nil { - app.ui.echoerrf("delete: %s", err) - return - } - if app.ui.cmdPrefix == ">" { return } @@ -1688,15 +1711,18 @@ func (e *callExpr) eval(app *app, _ []string) { dir.visualWrap = 0 case "visual-accept": dir := app.nav.currDir() + skipped := 0 for _, path := range dir.visualSelections() { - if containsNewline(path) { - continue // quarantine: a newline-containing path is never selected + if _, ok := app.nav.selections[path]; ok { + continue } - if _, ok := app.nav.selections[path]; !ok { - app.nav.selections[path] = app.nav.selectionInd - app.nav.selectionInd++ + if err := app.nav.toggleSelection(path); err != nil { + skipped++ } } + if skipped > 0 { + app.ui.echoerrf("visual-accept: skipped %d name(s) containing a newline; use rename to fix", skipped) + } // resetting Visual mode here instead of inside `normal()` // allows us to use Visual mode inside search, find etc. dir.visualAnchor = -1 From 5a8a8d96d0cc1d8c2503b6388d67030cd59c27cb Mon Sep 17 00:00:00 2001 From: valoq Date: Sat, 4 Jul 2026 04:47:21 +0200 Subject: [PATCH 08/11] minor improvements --- complete.go | 5 +++++ eval.go | 2 +- main.go | 6 ++++-- misc.go | 10 ++++++++++ nav.go | 21 ++++++--------------- ui.go | 3 ++- 6 files changed, 28 insertions(+), 19 deletions(-) diff --git a/complete.go b/complete.go index dd404362e..603c3f923 100644 --- a/complete.go +++ b/complete.go @@ -273,6 +273,11 @@ func matchFile(s string, dirOnly bool, escape, unescape func(string) string) (ma continue } + // skip newline names because the escaped form is a shell line continuation + if containsNewline(f.Name()) { + continue + } + name := f.Name() if isDir { name += string(filepath.Separator) diff --git a/eval.go b/eval.go index d21c79460..afaf0aebb 100644 --- a/eval.go +++ b/eval.go @@ -1895,7 +1895,7 @@ func (e *callExpr) eval(app *app, _ []string) { } // reject before the create-parent prompt so no newline dir is made if containsNewline(newPath) { - app.ui.echoerrf("rename: %q contains a newline", filepath.Base(newPath)) + app.ui.echoerrf("rename: %q contains a newline", newPath) return } if oldPath == newPath { diff --git a/main.go b/main.go index fc3014b74..0f2448314 100644 --- a/main.go +++ b/main.go @@ -79,9 +79,11 @@ func exportEnvVars() { fmt.Fprintf(os.Stderr, "getting current directory: %s\n", err) } if containsNewline(dir) { - dir = "" // refuse newline in $OLDPWD; it reaches shell commands + // a newline in $OLDPWD would reach shell commands + os.Unsetenv("OLDPWD") + } else { + os.Setenv("OLDPWD", dir) } - os.Setenv("OLDPWD", dir) level, err := strconv.Atoi(envLevel) if err != nil { diff --git a/misc.go b/misc.go index 2fe153104..82764f55a 100644 --- a/misc.go +++ b/misc.go @@ -601,3 +601,13 @@ func getWidths(wtot int, ratios []int, drawbox bool, borderstyle borderStyle) [] // All in all you're just another brick in the code // // -- Pink Trolled -- + +// containsNewline reports whether a name or path contains a newline or carriage return. +func containsNewline(s string) bool { + return strings.ContainsAny(s, "\n\r") +} + +// errNewlinePath is the error shown when a path contains a newline. +func errNewlinePath(path string) error { + return fmt.Errorf("%q contains a newline; use rename to fix the name", path) +} diff --git a/nav.go b/nav.go index db2e2e2b2..d0ccdcfdd 100644 --- a/nav.go +++ b/nav.go @@ -131,16 +131,6 @@ func (file *file) isPreviewable() bool { return !file.IsDir() || gOpts.dirpreviews } -// containsNewline reports whether a name or path contains a newline or carriage return. -func containsNewline(s string) bool { - return strings.ContainsAny(s, "\n\r") -} - -// errNewlinePath is the error shown when a path contains a newline. -func errNewlinePath(path string) error { - return fmt.Errorf("%q contains a newline; use rename to fix the name", path) -} - type fakeStat struct { name string } @@ -745,11 +735,12 @@ func (nav *nav) exportFiles() { os.Setenv("f", currFile) os.Setenv("fs", currSelections) os.Setenv("fv", currVSelections) - pwd := nav.currDir().path - if containsNewline(pwd) { - pwd = "" // refuse newline in $PWD; it reaches shell commands like $f + if pwd := nav.currDir().path; containsNewline(pwd) { + // a newline in $PWD would reach shell commands + os.Unsetenv("PWD") + } else { + os.Setenv("PWD", quoteString(pwd)) } - os.Setenv("PWD", quoteString(pwd)) if len(selections) == 0 { os.Setenv("fx", currFile) @@ -1599,7 +1590,7 @@ func (nav *nav) rename() error { // refuse a newline anywhere in the target (POSIX.1-2024 / Austin Group #251) if containsNewline(newPath) { - return fmt.Errorf("invalid name: %q contains a newline", filepath.Base(newPath)) + return fmt.Errorf("invalid name: %q contains a newline", newPath) } if err := os.Rename(oldPath, newPath); err != nil { diff --git a/ui.go b/ui.go index afe3c832f..afb388106 100644 --- a/ui.go +++ b/ui.go @@ -1300,7 +1300,8 @@ func listJumps(jumps []string, ind int) string { fmt.Fprintln(t, " jump\tpath") // print jumps in order of most recent, Vim uses the opposite order for i := len(jumps) - 1; i >= 0; i-- { - path := jumps[i] + // sanitize the path so a raw newline cannot break the query response + path := sanitizeName(jumps[i]) switch { case i < ind: fmt.Fprintf(t, " %*d\t%s\n", maxlength, ind-i, path) From 74f7eaf9d4c016985dbcdbd357de08f3395d0df0 Mon Sep 17 00:00:00 2001 From: valoq Date: Sun, 5 Jul 2026 01:35:03 +0200 Subject: [PATCH 09/11] improve patch --- app.go | 20 ++++++++++++++++++++ client.go | 2 ++ doc.md | 4 ++++ eval.go | 6 ++++-- misc.go | 24 ++++++++++++++---------- nav.go | 12 ++++++++---- newline_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 newline_test.go diff --git a/app.go b/app.go index e705a69da..d3ec9edb0 100644 --- a/app.go +++ b/app.go @@ -554,6 +554,25 @@ func (app *app) runCmdSync(cmd *exec.Cmd, pauseAfter bool) { app.nav.renew() } +// warnNewlineEnv reports names that exportFiles dropped so shell commands do not fail silently. +func (app *app) warnNewlineEnv() { + if curr := app.nav.currFile(); curr != nil && containsNewline(curr.path) { + app.ui.echoerrf("shell: left $f empty: %s", errNewlinePath(curr.path)) + } + if pwd := app.nav.currDir().path; containsNewline(pwd) { + app.ui.echoerrf("shell: unset $PWD: %q: %s", pwd, errNewline) + } + skipped := 0 + for _, path := range app.nav.currDir().visualSelections() { + if containsNewline(path) { + skipped++ + } + } + if skipped > 0 { + app.ui.echoerrf("shell: dropped %d name(s) with a newline from $fv", skipped) + } +} + // runShell is used to run a shell command. Modes are as follows: // // Prefix Wait Async Stdin Stdout Stderr UI action @@ -563,6 +582,7 @@ func (app *app) runCmdSync(cmd *exec.Cmd, pauseAfter bool) { // & No Yes No No No Do nothing func (app *app) runShell(s string, args []string, prefix string) { app.nav.exportFiles() + app.warnNewlineEnv() app.ui.exportSizes() app.exportMode() exportLfPath() diff --git a/client.go b/client.go index 27697228e..c957c238c 100644 --- a/client.go +++ b/client.go @@ -96,6 +96,7 @@ func run() { // control bytes are stripped only when stdout is a terminal. func printPath(label, path string, stdoutIsTerminal bool) { if containsNewline(path) { + fmt.Fprintf(os.Stderr, "lf: %s: skipping path with a newline: %q\n", label, path) log.Printf("%s: skipping path with newline: %q", label, path) return } @@ -107,6 +108,7 @@ func printPath(label, path string, stdoutIsTerminal bool) { func writeLastDir(filename, lastDir string) { if containsNewline(lastDir) { + fmt.Fprintf(os.Stderr, "lf: last-dir: skipping path with a newline: %q\n", lastDir) log.Printf("last-dir: path contains newline: %q", lastDir) return } diff --git a/doc.md b/doc.md index aed366bb8..96fc28ddd 100644 --- a/doc.md +++ b/doc.md @@ -32,6 +32,9 @@ This documentation can either be read from the terminal using `lf -doc` or onlin You can also use the `help` command (default ``) inside lf to view the documentation in a pager. A man page with the same content is also available in the repository at https://github.com/gokcehan/lf/blob/master/lf.1 +Names containing a newline or carriage return are displayed using replacement characters and can be navigated and previewed, but following the POSIX.1-2024 recommendation, operations on them (e.g. `copy`, `cut`, `delete`, `open`, `toggle`, `tag`, `mark-save`) are refused with an error, and such names are excluded from `$f`, `$fs`, `$fx` and `$fv` (with `$PWD` and `$OLDPWD` unset when affected) with a warning before shell commands run. +Use `rename` to remove the newline from the name. + # OPTIONS ## POSITIONAL ARGUMENTS @@ -602,6 +605,7 @@ A custom `delete` command can be defined to override this default. Rename the current file using the built-in method. A custom `rename` command can be defined to override this default. +Renaming a file to a name containing a newline is refused, but a name containing a newline can be edited to remove it. ## read (modal) (default `:`) diff --git a/eval.go b/eval.go index afaf0aebb..3344dd9b2 100644 --- a/eval.go +++ b/eval.go @@ -1716,8 +1716,10 @@ func (e *callExpr) eval(app *app, _ []string) { if _, ok := app.nav.selections[path]; ok { continue } - if err := app.nav.toggleSelection(path); err != nil { + if err := app.nav.toggleSelection(path); errors.Is(err, errNewline) { skipped++ + } else if err != nil { + app.ui.echoerrf("visual-accept: %s", err) } } if skipped > 0 { @@ -1895,7 +1897,7 @@ func (e *callExpr) eval(app *app, _ []string) { } // reject before the create-parent prompt so no newline dir is made if containsNewline(newPath) { - app.ui.echoerrf("rename: %q contains a newline", newPath) + app.ui.echoerrf("rename: %q: %s", newPath, errNewline) return } if oldPath == newPath { diff --git a/misc.go b/misc.go index 82764f55a..41803e987 100644 --- a/misc.go +++ b/misc.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "cmp" + "errors" "fmt" "io" "io/fs" @@ -592,6 +593,19 @@ func getWidths(wtot int, ratios []int, drawbox bool, borderstyle borderStyle) [] return widths } +// errNewline is the sentinel error for names refused due to a newline or carriage return. +var errNewline = errors.New("name contains a newline") + +// containsNewline reports whether a name or path contains a newline or carriage return. +func containsNewline(s string) bool { + return strings.ContainsAny(s, "\n\r") +} + +// errNewlinePath wraps errNewline with the offending path and a hint to fix it. +func errNewlinePath(path string) error { + return fmt.Errorf("%q: %w; use rename to fix the name", path, errNewline) +} + // We don't need no generic code // We don't need no type control // No dark templates in compiler @@ -601,13 +615,3 @@ func getWidths(wtot int, ratios []int, drawbox bool, borderstyle borderStyle) [] // All in all you're just another brick in the code // // -- Pink Trolled -- - -// containsNewline reports whether a name or path contains a newline or carriage return. -func containsNewline(s string) bool { - return strings.ContainsAny(s, "\n\r") -} - -// errNewlinePath is the error shown when a path contains a newline. -func errNewlinePath(path string) error { - return fmt.Errorf("%q contains a newline; use rename to fix the name", path) -} diff --git a/nav.go b/nav.go index d0ccdcfdd..97632e58a 100644 --- a/nav.go +++ b/nav.go @@ -462,7 +462,7 @@ type nav struct { marks map[string]string renameOldPath string renameNewPath string - selections map[string]int + selections map[string]int // never contains a path with a newline, enforced in toggleSelection tags map[string]string selectionInd int height int @@ -1327,8 +1327,10 @@ func (nav *nav) tag(tag string) error { func (nav *nav) invert() error { skipped := 0 for _, file := range nav.currDir().files { - if err := nav.toggleSelection(file.path); err != nil { + if err := nav.toggleSelection(file.path); errors.Is(err, errNewline) { skipped++ + } else if err != nil { + return err } } if skipped > 0 { @@ -1590,7 +1592,7 @@ func (nav *nav) rename() error { // refuse a newline anywhere in the target (POSIX.1-2024 / Austin Group #251) if containsNewline(newPath) { - return fmt.Errorf("invalid name: %q contains a newline", newPath) + return fmt.Errorf("%q: %w", newPath, errNewline) } if err := os.Rename(oldPath, newPath); err != nil { @@ -1677,8 +1679,10 @@ func (nav *nav) globSel(pattern string, invert bool) error { anyMatched = true fpath := filepath.Join(dir.path, dir.files[i].Name()) if _, ok := nav.selections[fpath]; ok == invert { - if err := nav.toggleSelection(fpath); err != nil { + if err := nav.toggleSelection(fpath); errors.Is(err, errNewline) { skipped++ + } else if err != nil { + return err } } } diff --git a/newline_test.go b/newline_test.go new file mode 100644 index 000000000..48ae2dc2a --- /dev/null +++ b/newline_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "errors" + "testing" +) + +// selections must never contain a newline path, saveFiles and writeSelection rely on this invariant +func TestToggleSelectionRefusesNewline(t *testing.T) { + nav := &nav{selections: map[string]int{}} + + if err := nav.toggleSelection("/tmp/a\nb"); !errors.Is(err, errNewline) { + t.Errorf("toggleSelection(newline path) = %v, want errNewline", err) + } + if len(nav.selections) != 0 { + t.Error("newline path entered selections") + } + + if err := nav.toggleSelection("/tmp/ok"); err != nil { + t.Errorf("toggleSelection(clean path) = %v, want nil", err) + } + + // unselecting a stale newline entry must still work so users can recover + nav.selections["/tmp/x\ry"] = 5 + if err := nav.toggleSelection("/tmp/x\ry"); err != nil { + t.Errorf("unselecting stale newline entry = %v, want nil", err) + } + if _, ok := nav.selections["/tmp/x\ry"]; ok { + t.Error("stale newline entry not removed") + } +} + +// rename must refuse a newline target before touching the filesystem +func TestRenameRefusesNewlineTarget(t *testing.T) { + nav := &nav{renameOldPath: "/tmp/a", renameNewPath: "/tmp/a\nb"} + if err := nav.rename(); !errors.Is(err, errNewline) { + t.Errorf("rename to newline target = %v, want errNewline", err) + } +} From d8c7193c7f082fae1caab5a0e1c7161dc518f04f Mon Sep 17 00:00:00 2001 From: valoq Date: Sun, 5 Jul 2026 01:46:07 +0200 Subject: [PATCH 10/11] fix ci --- .golangci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.golangci.yaml b/.golangci.yaml index fcf9fd96c..82c187742 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -17,6 +17,7 @@ linters: - fmt.Fprintln - io.WriteString - os.Setenv + - os.Unsetenv - syscall.Close issues: max-issues-per-linter: 0 # no limit From f71fd22d5fcfed366aea1032befef533f8c91b74 Mon Sep 17 00:00:00 2001 From: valoq Date: Tue, 7 Jul 2026 08:49:32 +0200 Subject: [PATCH 11/11] improve patch --- app.go | 25 +++--------------- eval.go | 27 ++++++++++++------- misc.go | 5 ++++ nav.go | 82 +++++++++++++++++++++++++++++++++++++++------------------ 4 files changed, 82 insertions(+), 57 deletions(-) diff --git a/app.go b/app.go index d3ec9edb0..46b517267 100644 --- a/app.go +++ b/app.go @@ -554,25 +554,6 @@ func (app *app) runCmdSync(cmd *exec.Cmd, pauseAfter bool) { app.nav.renew() } -// warnNewlineEnv reports names that exportFiles dropped so shell commands do not fail silently. -func (app *app) warnNewlineEnv() { - if curr := app.nav.currFile(); curr != nil && containsNewline(curr.path) { - app.ui.echoerrf("shell: left $f empty: %s", errNewlinePath(curr.path)) - } - if pwd := app.nav.currDir().path; containsNewline(pwd) { - app.ui.echoerrf("shell: unset $PWD: %q: %s", pwd, errNewline) - } - skipped := 0 - for _, path := range app.nav.currDir().visualSelections() { - if containsNewline(path) { - skipped++ - } - } - if skipped > 0 { - app.ui.echoerrf("shell: dropped %d name(s) with a newline from $fv", skipped) - } -} - // runShell is used to run a shell command. Modes are as follows: // // Prefix Wait Async Stdin Stdout Stderr UI action @@ -581,8 +562,10 @@ func (app *app) warnNewlineEnv() { // ! Yes No Yes Yes Yes Pause and then resume // & No Yes No No No Do nothing func (app *app) runShell(s string, args []string, prefix string) { - app.nav.exportFiles() - app.warnNewlineEnv() + // report names that exportFiles dropped so shell commands do not fail silently + for _, msg := range app.nav.exportFiles() { + app.ui.echoerr(msg) + } app.ui.exportSizes() app.exportMode() exportLfPath() diff --git a/eval.go b/eval.go index 3344dd9b2..6797da8a2 100644 --- a/eval.go +++ b/eval.go @@ -1214,8 +1214,8 @@ func (e *callExpr) eval(app *app, _ []string) { } } case "invert": - if err := app.nav.invert(); err != nil { - app.ui.echoerrf("invert: %s", err) + if skipped := app.nav.invert(); skipped > 0 { + app.ui.echomsg(skipMsg("invert", skipped)) } case "unselect": app.nav.unselect() @@ -1224,19 +1224,27 @@ func (e *callExpr) eval(app *app, _ []string) { app.ui.echoerr("glob-select: requires a pattern to match") return } - if err := app.nav.globSel(e.args[0], false); err != nil { + skipped, err := app.nav.globSel(e.args[0], false) + if err != nil { app.ui.echoerrf("%s", err) return } + if skipped > 0 { + app.ui.echomsg(skipMsg("glob-select", skipped)) + } case "glob-unselect": if len(e.args) != 1 { app.ui.echoerr("glob-unselect: requires a pattern to match") return } - if err := app.nav.globSel(e.args[0], true); err != nil { + skipped, err := app.nav.globSel(e.args[0], true) + if err != nil { app.ui.echoerrf("%s", err) return } + if skipped > 0 { + app.ui.echomsg(skipMsg("glob-unselect", skipped)) + } case "copy": if err := app.nav.save(clipboardCopy); err != nil { app.ui.echoerrf("copy: %s", err) @@ -1716,14 +1724,13 @@ func (e *callExpr) eval(app *app, _ []string) { if _, ok := app.nav.selections[path]; ok { continue } - if err := app.nav.toggleSelection(path); errors.Is(err, errNewline) { + // toggleSelection only refuses newline paths + if err := app.nav.toggleSelection(path); err != nil { skipped++ - } else if err != nil { - app.ui.echoerrf("visual-accept: %s", err) } } if skipped > 0 { - app.ui.echoerrf("visual-accept: skipped %d name(s) containing a newline; use rename to fix", skipped) + app.ui.echomsg(skipMsg("visual-accept", skipped)) } // resetting Visual mode here instead of inside `normal()` // allows us to use Visual mode inside search, find etc. @@ -1896,8 +1903,8 @@ func (e *callExpr) eval(app *app, _ []string) { newPath = filepath.Join(wd, newPath) } // reject before the create-parent prompt so no newline dir is made - if containsNewline(newPath) { - app.ui.echoerrf("rename: %q: %s", newPath, errNewline) + if err := checkRenameTarget(oldPath, newPath); err != nil { + app.ui.echoerrf("rename: %s", err) return } if oldPath == newPath { diff --git a/misc.go b/misc.go index 41803e987..6d4b5222f 100644 --- a/misc.go +++ b/misc.go @@ -606,6 +606,11 @@ func errNewlinePath(path string) error { return fmt.Errorf("%q: %w; use rename to fix the name", path, errNewline) } +// skipMsg formats the notice for names skipped because they contain a newline. +func skipMsg(cmd string, skipped int) string { + return fmt.Sprintf("%s: skipped %d name(s) containing a newline; use rename to fix", cmd, skipped) +} + // We don't need no generic code // We don't need no type control // No dark templates in compiler diff --git a/nav.go b/nav.go index 97632e58a..67a89dd90 100644 --- a/nav.go +++ b/nav.go @@ -711,25 +711,47 @@ func (nav *nav) position() { } } -func (nav *nav) exportFiles() { +// exportFiles sets $f, $fs, $fv, $fx and $PWD, returning warnings for names dropped due to a newline. +func (nav *nav) exportFiles() []string { + var warnings []string + var currFile string - if curr := nav.currFile(); curr != nil && !containsNewline(curr.path) { - currFile = quoteString(curr.path) + if curr := nav.currFile(); curr != nil { + if containsNewline(curr.path) { + warnings = append(warnings, fmt.Sprintf("shell: left $f empty: %s", errNewlinePath(curr.path))) + } else { + currFile = quoteString(curr.path) + } } var selections []string + dropped := 0 for _, selection := range nav.currSelections() { + // filter stale entries so a regression in toggleSelection cannot reach $fs and $fx + if containsNewline(selection) { + dropped++ + continue + } selections = append(selections, quoteString(selection)) } + if dropped > 0 { + warnings = append(warnings, fmt.Sprintf("shell: dropped %d name(s) with a newline from $fs and $fx", dropped)) + } currSelections := strings.Join(selections, gOpts.filesep) var vSelections []string + dropped = 0 for _, selection := range nav.currDir().visualSelections() { + // $fv is filesep-joined, so refuse newline paths like $f above if containsNewline(selection) { - continue // $fv is filesep-joined, so refuse newline paths like $f above + dropped++ + continue } vSelections = append(vSelections, quoteString(selection)) } + if dropped > 0 { + warnings = append(warnings, fmt.Sprintf("shell: dropped %d name(s) with a newline from $fv", dropped)) + } currVSelections := strings.Join(vSelections, gOpts.filesep) os.Setenv("f", currFile) @@ -737,6 +759,7 @@ func (nav *nav) exportFiles() { os.Setenv("fv", currVSelections) if pwd := nav.currDir().path; containsNewline(pwd) { // a newline in $PWD would reach shell commands + warnings = append(warnings, fmt.Sprintf("shell: unset $PWD: %q: %s", pwd, errNewline)) os.Unsetenv("PWD") } else { os.Setenv("PWD", quoteString(pwd)) @@ -747,6 +770,8 @@ func (nav *nav) exportFiles() { } else { os.Setenv("fx", currSelections) } + + return warnings } func (nav *nav) preloadLoop(ui *ui) { @@ -1324,19 +1349,16 @@ func (nav *nav) tag(tag string) error { return nil } -func (nav *nav) invert() error { +// invert toggles all files in the current directory, returning the number of newline names skipped. +func (nav *nav) invert() int { skipped := 0 for _, file := range nav.currDir().files { - if err := nav.toggleSelection(file.path); errors.Is(err, errNewline) { + // toggleSelection only refuses newline paths + if err := nav.toggleSelection(file.path); err != nil { skipped++ - } else if err != nil { - return err } } - if skipped > 0 { - return fmt.Errorf("skipped %d name(s) containing a newline; use rename to fix", skipped) - } - return nil + return skipped } func (nav *nav) unselect() { @@ -1586,13 +1608,25 @@ func (nav *nav) del(app *app) error { return nil } +// checkRenameTarget refuses a newline in the rename target, exempting components inherited from the directory of oldPath. +func checkRenameTarget(oldPath, newPath string) error { + rel, ok := strings.CutPrefix(newPath, filepath.Dir(oldPath)+string(filepath.Separator)) + if !ok { + rel = newPath + } + if containsNewline(rel) { + return fmt.Errorf("%q: %w", rel, errNewline) + } + return nil +} + func (nav *nav) rename() error { oldPath := nav.renameOldPath newPath := nav.renameNewPath - // refuse a newline anywhere in the target (POSIX.1-2024 / Austin Group #251) - if containsNewline(newPath) { - return fmt.Errorf("%q: %w", newPath, errNewline) + // refuse creating a newline name (POSIX.1-2024 / Austin Group #251) + if err := checkRenameTarget(oldPath, newPath); err != nil { + return err } if err := os.Rename(oldPath, newPath); err != nil { @@ -1665,7 +1699,8 @@ func (nav *nav) cd(path string) error { return nil } -func (nav *nav) globSel(pattern string, invert bool) error { +// globSel toggles files matching pattern, returning the number of newline names skipped. +func (nav *nav) globSel(pattern string, invert bool) (int, error) { dir := nav.currDir() anyMatched := false @@ -1673,30 +1708,25 @@ func (nav *nav) globSel(pattern string, invert bool) error { for i := range dir.files { matched, err := filepath.Match(pattern, dir.files[i].Name()) if err != nil { - return fmt.Errorf("glob-select: %w", err) + return skipped, fmt.Errorf("glob-select: %w", err) } if matched { anyMatched = true fpath := filepath.Join(dir.path, dir.files[i].Name()) if _, ok := nav.selections[fpath]; ok == invert { - if err := nav.toggleSelection(fpath); errors.Is(err, errNewline) { + // toggleSelection only refuses newline paths + if err := nav.toggleSelection(fpath); err != nil { skipped++ - } else if err != nil { - return err } } } } if !anyMatched { - return fmt.Errorf("glob-select: pattern not found: %s", pattern) + return skipped, fmt.Errorf("glob-select: pattern not found: %s", pattern) } - if skipped > 0 { - return fmt.Errorf("glob-select: skipped %d name(s) containing a newline; use rename to fix", skipped) - } - - return nil + return skipped, nil } func findMatch(name, pattern string) bool {