diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index f83c2b8e..4da1f32b 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -407,6 +407,22 @@ func (s *Sandbox) Lstat(path string, cwd string) (fs.FileInfo, error) { return info, nil } +// Readlink returns the destination of a symbolic link within the sandbox. +func (s *Sandbox) Readlink(path string, cwd string) (string, error) { + absPath := toAbs(path, cwd) + + root, relPath, ok := s.resolve(absPath) + if !ok { + return "", &os.PathError{Op: "readlink", Path: path, Err: os.ErrPermission} + } + + target, err := root.Readlink(relPath) + if err != nil { + return "", PortablePathError(err) + } + return target, nil +} + // Close releases all os.Root file descriptors. It is safe to call multiple times. func (s *Sandbox) Close() error { if s == nil { diff --git a/builtins/builtins.go b/builtins/builtins.go index 3fec7afa..6d78301b 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -115,6 +115,10 @@ type CallContext struct { // LstatFile returns file info within the shell's path restrictions (does not follow symlinks). LstatFile func(ctx context.Context, path string) (fs.FileInfo, error) + // ReadlinkFile returns the destination of a symbolic link within the + // shell's path restrictions. + ReadlinkFile func(ctx context.Context, path string) (string, error) + // AccessFile checks whether the file at path is accessible with the given mode // within the shell's path restrictions. Mode: 0x04=read, 0x02=write, 0x01=execute. AccessFile func(ctx context.Context, path string, mode uint32) error diff --git a/builtins/ls/ls.go b/builtins/ls/ls.go index 5d8d1cd0..1b0bdf67 100644 --- a/builtins/ls/ls.go +++ b/builtins/ls/ls.go @@ -233,7 +233,17 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { } } if !isDir || opts.dirOnly { - files = append(files, pathArg{name: p, info: info}) + pa := pathArg{name: p, info: info} + if opts.longFmt && info.Mode()&iofs.ModeSymlink != 0 { + if t, err := callCtx.ReadlinkFile(ctx, p); err == nil { + pa.linkTarget = t + } + // Stat the target for -F/-p indicators (nil for dangling links). + if ti, err := callCtx.StatFile(ctx, p); err == nil { + pa.linkTargetInfo = ti + } + } + files = append(files, pa) } else { dirs = append(dirs, pathArg{name: p, info: info}) } @@ -251,7 +261,7 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { cw = computeColWidths(files, func(a pathArg) iofs.FileInfo { return a.info }, func(a pathArg) (string, string, string) { return a.owner, a.group, a.nlink }, opts) } for _, f := range files { - printEntry(callCtx, f.name, f.info, f.owner, f.group, f.nlink, opts, now, cw) + printEntry(callCtx, f.name, f.info, f.owner, f.group, f.nlink, f.linkTarget, f.linkTargetInfo, opts, now, cw) } } @@ -297,11 +307,13 @@ type options struct { } type pathArg struct { - name string - info iofs.FileInfo - owner string // cached by fileOwner (populated when longFmt) - group string - nlink string + name string + info iofs.FileInfo + owner string // cached by fileOwner (populated when longFmt) + group string + nlink string + linkTarget string // symlink destination (populated when longFmt + symlink) + linkTargetInfo iofs.FileInfo // target's FileInfo for indicator (may be nil for dangling links) } func listDir(ctx context.Context, callCtx *builtins.CallContext, dir string, opts *options, depth int, now time.Time) error { @@ -347,12 +359,14 @@ func listDir(ctx context.Context, callCtx *builtins.CallContext, dir string, opt // Get FileInfo for sorting (if needed) and for long format. type entryInfo struct { - name string - info iofs.FileInfo - isSymlink bool - owner string // cached by fileOwner (populated when longFmt) - group string - nlink string + name string + info iofs.FileInfo + isSymlink bool + owner string // cached by fileOwner (populated when longFmt) + group string + nlink string + linkTarget string // symlink destination (populated when longFmt + symlink) + linkTargetInfo iofs.FileInfo // target's FileInfo for indicator (may be nil for dangling links) } failed := false @@ -372,11 +386,24 @@ func listDir(ctx context.Context, callCtx *builtins.CallContext, dir string, opt failed = true continue } - infoEntries = append(infoEntries, entryInfo{ - name: name, - info: info, - isSymlink: e.Type()&iofs.ModeSymlink != 0, - }) + ei := entryInfo{ + name: name, + info: info, + // Check both Type() and info.Mode() — Type() can be 0 on + // filesystems that report DT_UNKNOWN. + isSymlink: e.Type()&iofs.ModeSymlink != 0 || info.Mode()&iofs.ModeSymlink != 0, + } + if opts.longFmt && ei.isSymlink { + fullPath := joinPath(dir, name) + if t, err := callCtx.ReadlinkFile(ctx, fullPath); err == nil { + ei.linkTarget = t + } + // Stat the target for -F/-p indicators (nil for dangling links). + if ti, err := callCtx.StatFile(ctx, fullPath); err == nil { + ei.linkTargetInfo = ti + } + } + infoEntries = append(infoEntries, ei) } // Synthesize . and .. for -a (os.ReadDir never includes them). @@ -432,7 +459,7 @@ func listDir(ctx context.Context, callCtx *builtins.CallContext, dir string, opt if ctx.Err() != nil { break } - printEntry(callCtx, ei.name, ei.info, ei.owner, ei.group, ei.nlink, opts, now, cw) + printEntry(callCtx, ei.name, ei.info, ei.owner, ei.group, ei.nlink, ei.linkTarget, ei.linkTargetInfo, opts, now, cw) } // Only warn on implicit truncation (no explicit --offset/--limit). @@ -525,7 +552,7 @@ func computeColWidths[T any](entries []T, getInfo func(T) iofs.FileInfo, getOwne return w } -func printEntry(callCtx *builtins.CallContext, name string, info iofs.FileInfo, owner, group, nlink string, opts *options, now time.Time, cw colWidths) { +func printEntry(callCtx *builtins.CallContext, name string, info iofs.FileInfo, owner, group, nlink, linkTarget string, linkTargetInfo iofs.FileInfo, opts *options, now time.Time, cw colWidths) { if opts.longFmt { mode := formatMode(info) size := info.Size() @@ -538,12 +565,24 @@ func printEntry(callCtx *builtins.CallContext, name string, info iofs.FileInfo, sizeStr = fmt.Sprintf("%d", size) } + suffix := indicator(info, opts) + if linkTarget != "" { + targetIndicator := "" + // GNU ls only applies -F (classify) indicators to symlink + // targets, not -p (append-slash-only). For example, + // ls -lF shows "link -> dir/" but ls -lp shows "link -> dir". + if linkTargetInfo != nil && opts.classify { + targetIndicator = indicator(linkTargetInfo, opts) + } + suffix = " -> " + linkTarget + targetIndicator + } + timeStr := formatTime(modTime, now) callCtx.Outf("%s %*s %-*s %-*s %*s %s %s%s\n", mode, cw.nlink, nlink, cw.owner, owner, cw.group, group, cw.size, sizeStr, timeStr, - name, indicator(info, opts)) + name, suffix) } else { callCtx.Outf("%s%s\n", name, indicator(info, opts)) } diff --git a/interp/runner_exec.go b/interp/runner_exec.go index dfb9868e..62734ee4 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -304,6 +304,9 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { LstatFile: func(ctx context.Context, path string) (fs.FileInfo, error) { return r.sandbox.Lstat(path, dir) }, + ReadlinkFile: func(ctx context.Context, path string) (string, error) { + return r.sandbox.Readlink(path, dir) + }, AccessFile: func(ctx context.Context, path string, mode uint32) error { return r.sandbox.Access(path, dir, mode) }, @@ -363,6 +366,9 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { LstatFile: func(ctx context.Context, path string) (fs.FileInfo, error) { return r.sandbox.Lstat(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) }, + ReadlinkFile: func(ctx context.Context, path string) (string, error) { + return r.sandbox.Readlink(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) + }, AccessFile: func(ctx context.Context, path string, mode uint32) error { return r.sandbox.Access(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir, mode) }, diff --git a/tests/scenarios/cmd/ls/long_format/symlink_append_slash.yaml b/tests/scenarios/cmd/ls/long_format/symlink_append_slash.yaml new file mode 100644 index 00000000..19dc875d --- /dev/null +++ b/tests/scenarios/cmd/ls/long_format/symlink_append_slash.yaml @@ -0,0 +1,16 @@ +description: ls -lp does not append slash to symlink targets (only -F does) +setup: + files: + - path: dir/subdir/.keep + content: "" + - path: dir/dirlink + symlink: subdir +input: + allowed_paths: ["$DIR"] + script: |+ + ls -lp dir/ +expect: + stdout_contains: + - "dirlink -> subdir" + - "subdir/" + exit_code: 0 diff --git a/tests/scenarios/cmd/ls/long_format/symlink_classify.yaml b/tests/scenarios/cmd/ls/long_format/symlink_classify.yaml new file mode 100644 index 00000000..3e09d049 --- /dev/null +++ b/tests/scenarios/cmd/ls/long_format/symlink_classify.yaml @@ -0,0 +1,22 @@ +description: ls -lF shows classification indicators on symlink targets +setup: + files: + - path: dir/file.txt + content: "hello" + chmod: 0644 + - path: dir/subdir/.keep + content: "" + - path: dir/dirlink + symlink: subdir + - path: dir/filelink + symlink: file.txt +input: + allowed_paths: ["$DIR"] + script: |+ + ls -lF dir/ +expect: + stdout_contains: + - "dirlink -> subdir/" + - "filelink -> file.txt" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/ls/long_format/symlink_target.yaml b/tests/scenarios/cmd/ls/long_format/symlink_target.yaml new file mode 100644 index 00000000..a0dec9f5 --- /dev/null +++ b/tests/scenarios/cmd/ls/long_format/symlink_target.yaml @@ -0,0 +1,17 @@ +description: ls -l shows symlink target with -> arrow +setup: + files: + - path: file.txt + content: "hello" + chmod: 0644 + - path: link.txt + symlink: file.txt +input: + allowed_paths: ["$DIR"] + script: |+ + ls -l link.txt +expect: + stdout_contains: + - "link.txt -> file.txt" + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/ls/long_format/symlink_target_in_dir.yaml b/tests/scenarios/cmd/ls/long_format/symlink_target_in_dir.yaml new file mode 100644 index 00000000..65950a1e --- /dev/null +++ b/tests/scenarios/cmd/ls/long_format/symlink_target_in_dir.yaml @@ -0,0 +1,18 @@ +description: ls -l in a directory shows symlink targets with -> arrow +setup: + files: + - path: dir/file.txt + content: "hello" + chmod: 0644 + - path: dir/link.txt + symlink: file.txt +input: + allowed_paths: ["$DIR"] + script: |+ + ls -l dir/ +expect: + stdout_contains: + - "link.txt -> file.txt" + - "file.txt" + stderr: "" + exit_code: 0