Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ linters:
- fmt.Fprintln
- io.WriteString
- os.Setenv
- os.Unsetenv
- syscall.Close
issues:
max-issues-per-linter: 0 # no limit
Expand Down
13 changes: 5 additions & 8 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 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)
}
Expand Down Expand Up @@ -568,7 +562,10 @@ func (app *app) runCmdSync(cmd *exec.Cmd, pauseAfter bool) {
// ! 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()
// 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()
Expand Down
17 changes: 6 additions & 11 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"log"
"net"
"os"
"slices"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -96,7 +95,8 @@ 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) {
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
}
Expand All @@ -107,7 +107,8 @@ func printPath(label, path string, stdoutIsTerminal bool) {
}

func writeLastDir(filename, lastDir string) {
if strings.ContainsAny(lastDir, "\n\r") {
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
}
Expand All @@ -132,14 +133,8 @@ 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") {
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)
}
Expand Down
5 changes: 5 additions & 0 deletions complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<f-1>`) 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
Expand Down Expand Up @@ -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 `:`)

Expand Down
75 changes: 59 additions & 16 deletions eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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))
Expand All @@ -1190,31 +1208,43 @@ 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 skipped := app.nav.invert(); skipped > 0 {
app.ui.echomsg(skipMsg("invert", skipped))
}
case "unselect":
app.nav.unselect()
case "glob-select":
if len(e.args) != 1 {
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)
Expand Down Expand Up @@ -1292,6 +1322,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()
Expand All @@ -1305,12 +1342,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
}
Expand Down Expand Up @@ -1688,11 +1719,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 _, ok := app.nav.selections[path]; !ok {
app.nav.selections[path] = app.nav.selectionInd
app.nav.selectionInd++
if _, ok := app.nav.selections[path]; ok {
continue
}
// toggleSelection only refuses newline paths
if err := app.nav.toggleSelection(path); err != nil {
skipped++
}
}
if skipped > 0 {
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.
Expand Down Expand Up @@ -1864,6 +1902,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 err := checkRenameTarget(oldPath, newPath); err != nil {
app.ui.echoerrf("rename: %s", err)
return
}
if oldPath == newPath {
return
}
Expand Down
7 changes: 6 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ func exportEnvVars() {
if err != nil {
fmt.Fprintf(os.Stderr, "getting current directory: %s\n", err)
}
os.Setenv("OLDPWD", dir)
if containsNewline(dir) {
// a newline in $OLDPWD would reach shell commands
os.Unsetenv("OLDPWD")
} else {
os.Setenv("OLDPWD", dir)
}

level, err := strconv.Atoi(envLevel)
if err != nil {
Expand Down
19 changes: 19 additions & 0 deletions misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"cmp"
"errors"
"fmt"
"io"
"io/fs"
Expand Down Expand Up @@ -592,6 +593,24 @@ 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)
}

// 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
Expand Down
Loading