feat(cli): ls --long (#11103)
Some checks failed
CodeQL / codeql (push) Has been cancelled
Docker Check / lint (push) Has been cancelled
Docker Check / build (push) Has been cancelled
Gateway Conformance / gateway-conformance (push) Has been cancelled
Gateway Conformance / gateway-conformance-libp2p-experiment (push) Has been cancelled
Go Build / go-build (push) Has been cancelled
Go Check / go-check (push) Has been cancelled
Go Lint / go-lint (push) Has been cancelled
Go Test / unit-tests (push) Has been cancelled
Go Test / cli-tests (push) Has been cancelled
Go Test / example-tests (push) Has been cancelled
Interop / interop-prep (push) Has been cancelled
Sharness / sharness-test (push) Has been cancelled
Spell Check / spellcheck (push) Has been cancelled
Interop / helia-interop (push) Has been cancelled
Interop / ipfs-webui (push) Has been cancelled

* Implements the -l/--long flag for the ipfs ls command to display Unix-style
file permissions and modification times, similar to the traditional ls -l.

When the --long flag is used, the output includes:
- File mode/permissions in Unix format (e.g., -rw-r--r--, drwxr-xr-x)
- File hash (CID)
- File size (when --size is also specified)
- Modification time in human-readable format
- File name

The permission string implementation handles all file types and special bits:
- File types: regular (-), directory (d), symlink (l), named pipe (p),
  socket (s), character device (c), block device (b)
- Special permission bits: setuid (s/S), setgid (s/S), sticky (t/T)
  - Lowercase when execute bit is set, uppercase when not set

The timestamp format follows Unix ls conventions:
- Recent files (within 6 months): "Jan 02 15:04"
- Older files: "Jan 02  2006"

Signed-off-by: sneax <paladesh600@gmail.com>

* fix(ls): correct --long flag header order and help text

- fix header column order: was "Mode Hash Size Name ModTime" but data
  outputs "Mode Hash Size ModTime Name", now headers match data order
- remove redundant if/else branch in directory output that had
  identical code in both branches
- add example output to help text showing format with mode, hash,
  size, mtime, and name columns
- document that files without preserved metadata show '----------'
  for mode and '-' for mtime
- add changelog entry for v0.40

* test(ls): add format stability tests for --long flag

add tests to prevent formatting regressions in ipfs ls --long output:

unit tests (core/commands/ls_test.go):
- TestFormatMode: 20 cases covering all file types (regular, dir,
  symlink, pipe, socket, block/char devices) and special permission
  bits (setuid, setgid, sticky with/without execute)
- TestFormatModTime: zero time, old time (year format), future time,
  format length consistency

integration tests (test/cli/ls_test.go):
- explicit full output comparison with deterministic CIDs to catch
  any formatting changes
- header column order verification for --long with --size=true/false
- files without preserved metadata (---------- and - placeholders)
- directory output (trailing slash, d prefix in mode)

requested in: https://github.com/ipfs/kubo/pull/11103#issuecomment-3745043561

* fix(ls): improve --long flag docs and fix minor issues

- improved godocs for formatMode and formatModTime functions
- fixed permBit signature: char rune → char byte (avoids unnecessary cast)
- clarified help text: mode/mtime are optional UnixFS metadata
- documented that times are displayed in UTC
- fixed flaky time test by using 1 month ago instead of 1 hour
- removed hardcoded CID assertion that would break on DAG changes

* fix(ls): show "-" for missing mode in --long output

display "-" instead of "----------" when mode metadata is not preserved.
this avoids ambiguity with Unix mode 0000 and matches how missing mtime
is already displayed. follows common Unix tool conventions (ps, netstat)
where "-" indicates "not available".

---------

Signed-off-by: sneax <paladesh600@gmail.com>
Co-authored-by: Marcin Rataj <lidel@lidel.org>
This commit is contained in:
segfault_bits 2026-01-16 06:57:32 +05:30 committed by GitHub
parent 824a47ae11
commit c1fd4d70f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 643 additions and 20 deletions

View File

@ -48,6 +48,7 @@ const (
lsResolveTypeOptionName = "resolve-type"
lsSizeOptionName = "size"
lsStreamOptionName = "stream"
lsLongOptionName = "long"
)
var LsCmd = &cmds.Command{
@ -57,7 +58,26 @@ var LsCmd = &cmds.Command{
Displays the contents of an IPFS or IPNS object(s) at the given path, with
the following format:
<link base58 hash> <link size in bytes> <link name>
<cid> <size> <name>
With the --long (-l) option, display optional file mode (permissions) and
modification time in a format similar to Unix 'ls -l':
<mode> <cid> <size> <mtime> <name>
Mode and mtime are optional UnixFS metadata. They are only present if the
content was imported with 'ipfs add --preserve-mode' and '--preserve-mtime'.
Without preserved metadata, both mode and mtime display '-'. Times are in UTC.
Example with --long and preserved metadata:
-rw-r--r-- QmZULkCELmmk5XNf... 1234 Jan 15 10:30 document.txt
-rwxr-xr-x QmaRGe7bVmVaLmxb... 5678 Dec 01 2023 script.sh
drwxr-xr-x QmWWEQhcLufF3qPm... - Nov 20 2023 subdir/
Example with --long without preserved metadata:
- QmZULkCELmmk5XNf... 1234 - document.txt
The JSON output contains type information.
`,
@ -71,6 +91,7 @@ The JSON output contains type information.
cmds.BoolOption(lsResolveTypeOptionName, "Resolve linked objects to find out their types.").WithDefault(true),
cmds.BoolOption(lsSizeOptionName, "Resolve linked objects to find out their file size.").WithDefault(true),
cmds.BoolOption(lsStreamOptionName, "s", "Enable experimental streaming of directory entries as they are traversed."),
cmds.BoolOption(lsLongOptionName, "l", "Use a long listing format, showing file mode and modification time."),
},
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
api, err := cmdenv.GetApi(env, req)
@ -215,10 +236,121 @@ The JSON output contains type information.
Type: LsOutput{},
}
// formatMode converts os.FileMode to a 10-character Unix ls-style string.
//
// Format: [type][owner rwx][group rwx][other rwx]
//
// Type indicators: - (regular), d (directory), l (symlink), p (named pipe),
// s (socket), c (char device), b (block device).
//
// Special bits replace the execute position: setuid on owner (s/S),
// setgid on group (s/S), sticky on other (t/T). Lowercase when the
// underlying execute bit is also set, uppercase when not.
func formatMode(mode os.FileMode) string {
var buf [10]byte
// File type - handle all special file types like ls does
switch {
case mode&os.ModeDir != 0:
buf[0] = 'd'
case mode&os.ModeSymlink != 0:
buf[0] = 'l'
case mode&os.ModeNamedPipe != 0:
buf[0] = 'p'
case mode&os.ModeSocket != 0:
buf[0] = 's'
case mode&os.ModeDevice != 0:
if mode&os.ModeCharDevice != 0 {
buf[0] = 'c'
} else {
buf[0] = 'b'
}
default:
buf[0] = '-'
}
// Owner permissions (bits 8,7,6)
buf[1] = permBit(mode, 0400, 'r') // read
buf[2] = permBit(mode, 0200, 'w') // write
// Handle setuid bit for owner execute
if mode&os.ModeSetuid != 0 {
if mode&0100 != 0 {
buf[3] = 's'
} else {
buf[3] = 'S'
}
} else {
buf[3] = permBit(mode, 0100, 'x') // execute
}
// Group permissions (bits 5,4,3)
buf[4] = permBit(mode, 0040, 'r') // read
buf[5] = permBit(mode, 0020, 'w') // write
// Handle setgid bit for group execute
if mode&os.ModeSetgid != 0 {
if mode&0010 != 0 {
buf[6] = 's'
} else {
buf[6] = 'S'
}
} else {
buf[6] = permBit(mode, 0010, 'x') // execute
}
// Other permissions (bits 2,1,0)
buf[7] = permBit(mode, 0004, 'r') // read
buf[8] = permBit(mode, 0002, 'w') // write
// Handle sticky bit for other execute
if mode&os.ModeSticky != 0 {
if mode&0001 != 0 {
buf[9] = 't'
} else {
buf[9] = 'T'
}
} else {
buf[9] = permBit(mode, 0001, 'x') // execute
}
return string(buf[:])
}
// permBit returns the permission character if the bit is set.
func permBit(mode os.FileMode, bit os.FileMode, char byte) byte {
if mode&bit != 0 {
return char
}
return '-'
}
// formatModTime formats time.Time for display, following Unix ls conventions.
//
// Returns "-" for zero time. Otherwise returns a 12-character string:
// recent files (within 6 months) show "Jan 02 15:04",
// older or future files show "Jan 02 2006".
//
// The output uses the timezone embedded in t (UTC for IPFS metadata).
func formatModTime(t time.Time) string {
if t.IsZero() {
return "-"
}
// Format: "Jan 02 15:04" for times within the last 6 months
// Format: "Jan 02 2006" for older times (similar to ls)
now := time.Now()
sixMonthsAgo := now.AddDate(0, -6, 0)
if t.After(sixMonthsAgo) && t.Before(now.Add(24*time.Hour)) {
return t.Format("Jan 02 15:04")
}
return t.Format("Jan 02 2006")
}
func tabularOutput(req *cmds.Request, w io.Writer, out *LsOutput, lastObjectHash string, ignoreBreaks bool) string {
headers, _ := req.Options[lsHeadersOptionNameTime].(bool)
stream, _ := req.Options[lsStreamOptionName].(bool)
size, _ := req.Options[lsSizeOptionName].(bool)
long, _ := req.Options[lsLongOptionName].(bool)
// in streaming mode we can't automatically align the tabs
// so we take a best guess
var minTabWidth int
@ -242,9 +374,21 @@ func tabularOutput(req *cmds.Request, w io.Writer, out *LsOutput, lastObjectHash
fmt.Fprintf(tw, "%s:\n", object.Hash)
}
if headers {
s := "Hash\tName"
if size {
s = "Hash\tSize\tName"
var s string
if long {
// Long format: Mode Hash [Size] ModTime Name
if size {
s = "Mode\tHash\tSize\tModTime\tName"
} else {
s = "Mode\tHash\tModTime\tName"
}
} else {
// Standard format: Hash [Size] Name
if size {
s = "Hash\tSize\tName"
} else {
s = "Hash\tName"
}
}
fmt.Fprintln(tw, s)
}
@ -253,23 +397,54 @@ func tabularOutput(req *cmds.Request, w io.Writer, out *LsOutput, lastObjectHash
for _, link := range object.Links {
var s string
switch link.Type {
case unixfs.TDirectory, unixfs.THAMTShard, unixfs.TMetadata:
if size {
s = "%[1]s\t-\t%[3]s/\n"
} else {
s = "%[1]s\t%[3]s/\n"
}
default:
if size {
s = "%s\t%v\t%s\n"
} else {
s = "%[1]s\t%[3]s\n"
}
}
isDir := link.Type == unixfs.TDirectory || link.Type == unixfs.THAMTShard || link.Type == unixfs.TMetadata
// TODO: Print link.Mode and link.ModTime?
fmt.Fprintf(tw, s, link.Hash, link.Size, cmdenv.EscNonPrint(link.Name))
if long {
// Long format: Mode Hash Size ModTime Name
var mode string
if link.Mode == 0 {
// No mode metadata preserved. Show "-" to indicate
// "not available" rather than "----------" (mode 0000).
mode = "-"
} else {
mode = formatMode(link.Mode)
}
modTime := formatModTime(link.ModTime)
if isDir {
if size {
s = "%s\t%s\t-\t%s\t%s/\n"
} else {
s = "%s\t%s\t%s\t%s/\n"
}
fmt.Fprintf(tw, s, mode, link.Hash, modTime, cmdenv.EscNonPrint(link.Name))
} else {
if size {
s = "%s\t%s\t%v\t%s\t%s\n"
fmt.Fprintf(tw, s, mode, link.Hash, link.Size, modTime, cmdenv.EscNonPrint(link.Name))
} else {
s = "%s\t%s\t%s\t%s\n"
fmt.Fprintf(tw, s, mode, link.Hash, modTime, cmdenv.EscNonPrint(link.Name))
}
}
} else {
// Standard format: Hash [Size] Name
switch {
case isDir:
if size {
s = "%[1]s\t-\t%[3]s/\n"
} else {
s = "%[1]s\t%[3]s/\n"
}
default:
if size {
s = "%s\t%v\t%s\n"
} else {
s = "%[1]s\t%[3]s\n"
}
}
fmt.Fprintf(tw, s, link.Hash, link.Size, cmdenv.EscNonPrint(link.Name))
}
}
}
tw.Flush()

189
core/commands/ls_test.go Normal file
View File

@ -0,0 +1,189 @@
package commands
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestFormatMode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mode os.FileMode
expected string
}{
// File types
{
name: "regular file with rw-r--r--",
mode: 0644,
expected: "-rw-r--r--",
},
{
name: "regular file with rwxr-xr-x",
mode: 0755,
expected: "-rwxr-xr-x",
},
{
name: "regular file with no permissions",
mode: 0,
expected: "----------",
},
{
name: "regular file with full permissions",
mode: 0777,
expected: "-rwxrwxrwx",
},
{
name: "directory with rwxr-xr-x",
mode: os.ModeDir | 0755,
expected: "drwxr-xr-x",
},
{
name: "directory with rwx------",
mode: os.ModeDir | 0700,
expected: "drwx------",
},
{
name: "symlink with rwxrwxrwx",
mode: os.ModeSymlink | 0777,
expected: "lrwxrwxrwx",
},
{
name: "named pipe with rw-r--r--",
mode: os.ModeNamedPipe | 0644,
expected: "prw-r--r--",
},
{
name: "socket with rw-rw-rw-",
mode: os.ModeSocket | 0666,
expected: "srw-rw-rw-",
},
{
name: "block device with rw-rw----",
mode: os.ModeDevice | 0660,
expected: "brw-rw----",
},
{
name: "character device with rw-rw-rw-",
mode: os.ModeDevice | os.ModeCharDevice | 0666,
expected: "crw-rw-rw-",
},
// Special permission bits - setuid
{
name: "setuid with execute",
mode: os.ModeSetuid | 0755,
expected: "-rwsr-xr-x",
},
{
name: "setuid without execute",
mode: os.ModeSetuid | 0644,
expected: "-rwSr--r--",
},
// Special permission bits - setgid
{
name: "setgid with execute",
mode: os.ModeSetgid | 0755,
expected: "-rwxr-sr-x",
},
{
name: "setgid without execute",
mode: os.ModeSetgid | 0745,
expected: "-rwxr-Sr-x",
},
// Special permission bits - sticky
{
name: "sticky with execute",
mode: os.ModeSticky | 0755,
expected: "-rwxr-xr-t",
},
{
name: "sticky without execute",
mode: os.ModeSticky | 0754,
expected: "-rwxr-xr-T",
},
// Combined special bits
{
name: "setuid + setgid + sticky all with execute",
mode: os.ModeSetuid | os.ModeSetgid | os.ModeSticky | 0777,
expected: "-rwsrwsrwt",
},
{
name: "setuid + setgid + sticky none with execute",
mode: os.ModeSetuid | os.ModeSetgid | os.ModeSticky | 0666,
expected: "-rwSrwSrwT",
},
// Directory with special bits
{
name: "directory with sticky bit",
mode: os.ModeDir | os.ModeSticky | 0755,
expected: "drwxr-xr-t",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := formatMode(tc.mode)
assert.Equal(t, tc.expected, result)
})
}
}
func TestFormatModTime(t *testing.T) {
t.Parallel()
t.Run("zero time returns dash", func(t *testing.T) {
t.Parallel()
result := formatModTime(time.Time{})
assert.Equal(t, "-", result)
})
t.Run("old time shows year format", func(t *testing.T) {
t.Parallel()
// Use a time clearly in the past (more than 6 months ago)
oldTime := time.Date(2020, time.March, 15, 10, 30, 0, 0, time.UTC)
result := formatModTime(oldTime)
// Format: "Jan 02 2006" (note: two spaces before year)
assert.Equal(t, "Mar 15 2020", result)
})
t.Run("very old time shows year format", func(t *testing.T) {
t.Parallel()
veryOldTime := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)
result := formatModTime(veryOldTime)
assert.Equal(t, "Jan 01 2000", result)
})
t.Run("future time shows year format", func(t *testing.T) {
t.Parallel()
// Times more than 24h in the future should show year format
futureTime := time.Now().AddDate(1, 0, 0)
result := formatModTime(futureTime)
// Should contain the future year
assert.Contains(t, result, " ") // two spaces before year
assert.Regexp(t, `^[A-Z][a-z]{2} \d{2} \d{4}$`, result) // matches "Mon DD YYYY"
assert.Contains(t, result, futureTime.Format("2006")) // contains the year
})
t.Run("format lengths are consistent", func(t *testing.T) {
t.Parallel()
// Both formats should produce 12-character strings for alignment
oldTime := time.Date(2020, time.March, 15, 10, 30, 0, 0, time.UTC)
oldResult := formatModTime(oldTime)
assert.Len(t, oldResult, 12, "old time format should be 12 chars")
// Recent time: use 1 month ago to ensure it's always within the 6-month window
recentTime := time.Now().AddDate(0, -1, 0)
recentResult := formatModTime(recentTime)
assert.Len(t, recentResult, 12, "recent time format should be 12 chars")
})
}

View File

@ -21,6 +21,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
- [Accelerated DHT Client and Provide Sweep now work together](#accelerated-dht-client-and-provide-sweep-now-work-together)
- [⏱️ Configurable gateway request duration limit](#-configurable-gateway-request-duration-limit)
- [🔧 Recovery from corrupted MFS root](#-recovery-from-corrupted-mfs-root)
- [📋 Long listing format for `ipfs ls`](#-long-listing-format-for-ipfs-ls)
- [📦️ Dependency updates](#-dependency-updates)
- [📝 Changelog](#-changelog)
- [👨‍👩‍👧‍👦 Contributors](#-contributors)
@ -139,6 +140,10 @@ $ ipfs files chroot --confirm QmYourBackupCID
See `ipfs files chroot --help` for details.
#### 📋 Long listing format for `ipfs ls`
The `ipfs ls` command now supports `--long` (`-l`) flag for displaying Unix-style file permissions and modification times. This works with files added using `--preserve-mode` and `--preserve-mtime`. See `ipfs ls --help` for format details and examples.
#### 📦️ Dependency updates
- update `go-libp2p` to [v0.46.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.46.0)

254
test/cli/ls_test.go Normal file
View File

@ -0,0 +1,254 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ipfs/kubo/test/cli/harness"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLsLongFormat(t *testing.T) {
t.Parallel()
t.Run("long format shows mode and mtime when preserved", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init().StartDaemon()
defer node.StopDaemon()
// Create a test directory structure with known permissions
testDir := filepath.Join(node.Dir, "testdata")
require.NoError(t, os.MkdirAll(testDir, 0755))
// Create files with specific permissions
file1 := filepath.Join(testDir, "readable.txt")
require.NoError(t, os.WriteFile(file1, []byte("hello"), 0644))
file2 := filepath.Join(testDir, "executable.sh")
require.NoError(t, os.WriteFile(file2, []byte("#!/bin/sh\necho hi"), 0755))
// Set a known mtime in the past (to get year format, avoiding flaky time-based tests)
oldTime := time.Date(2020, time.June, 15, 10, 30, 0, 0, time.UTC)
require.NoError(t, os.Chtimes(file1, oldTime, oldTime))
require.NoError(t, os.Chtimes(file2, oldTime, oldTime))
// Add with preserved mode and mtime
addRes := node.IPFS("add", "-r", "--preserve-mode", "--preserve-mtime", "-Q", testDir)
dirCid := addRes.Stdout.Trimmed()
// Run ls with --long flag
lsRes := node.IPFS("ls", "--long", dirCid)
output := lsRes.Stdout.String()
// Verify format: Mode Hash Size ModTime Name
lines := strings.Split(strings.TrimSpace(output), "\n")
require.Len(t, lines, 2, "expected 2 files in output")
// Check executable.sh line (should be first alphabetically)
assert.Contains(t, lines[0], "-rwxr-xr-x", "executable should have 755 permissions")
assert.Contains(t, lines[0], "Jun 15 2020", "should show mtime with year format")
assert.Contains(t, lines[0], "executable.sh", "should show filename")
// Check readable.txt line
assert.Contains(t, lines[1], "-rw-r--r--", "readable file should have 644 permissions")
assert.Contains(t, lines[1], "Jun 15 2020", "should show mtime with year format")
assert.Contains(t, lines[1], "readable.txt", "should show filename")
})
t.Run("long format shows dash for files without preserved mode or mtime", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init().StartDaemon()
defer node.StopDaemon()
// Create and add a file without --preserve-mode or --preserve-mtime
testFile := filepath.Join(node.Dir, "nopreserve.txt")
require.NoError(t, os.WriteFile(testFile, []byte("test content"), 0644))
addRes := node.IPFS("add", "-Q", testFile)
fileCid := addRes.Stdout.Trimmed()
// Create a wrapper directory to list
node.IPFS("files", "mkdir", "/testdir")
node.IPFS("files", "cp", "/ipfs/"+fileCid, "/testdir/file.txt")
statRes := node.IPFS("files", "stat", "--hash", "/testdir")
dirCid := statRes.Stdout.Trimmed()
// Run ls with --long flag
lsRes := node.IPFS("ls", "--long", dirCid)
output := lsRes.Stdout.String()
// Files without preserved mode or mtime should show "-" for both columns
// Format: "-" (mode) <CID> <size> "-" (mtime) <name>
assert.Regexp(t, `^-\s+\S+\s+\d+\s+-\s+`, output, "missing mode and mtime should both show dash")
})
t.Run("long format with headers shows correct column order", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init().StartDaemon()
defer node.StopDaemon()
// Create a simple test file
testDir := filepath.Join(node.Dir, "headertest")
require.NoError(t, os.MkdirAll(testDir, 0755))
testFile := filepath.Join(testDir, "file.txt")
require.NoError(t, os.WriteFile(testFile, []byte("hello"), 0644))
oldTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)
require.NoError(t, os.Chtimes(testFile, oldTime, oldTime))
addRes := node.IPFS("add", "-r", "--preserve-mode", "--preserve-mtime", "-Q", testDir)
dirCid := addRes.Stdout.Trimmed()
// Run ls with --long and --headers (--size defaults to true)
lsRes := node.IPFS("ls", "--long", "--headers", dirCid)
output := lsRes.Stdout.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
// First line should be headers in correct order: Mode Hash Size ModTime Name
require.GreaterOrEqual(t, len(lines), 2)
headerFields := strings.Fields(lines[0])
require.Len(t, headerFields, 5, "header should have 5 columns")
assert.Equal(t, "Mode", headerFields[0])
assert.Equal(t, "Hash", headerFields[1])
assert.Equal(t, "Size", headerFields[2])
assert.Equal(t, "ModTime", headerFields[3])
assert.Equal(t, "Name", headerFields[4])
// Data line should have matching columns
dataFields := strings.Fields(lines[1])
require.GreaterOrEqual(t, len(dataFields), 5)
assert.Regexp(t, `^-[rwx-]{9}$`, dataFields[0], "first field should be mode")
assert.Regexp(t, `^Qm`, dataFields[1], "second field should be CID")
assert.Regexp(t, `^\d+$`, dataFields[2], "third field should be size")
})
t.Run("long format with headers and size=false", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init().StartDaemon()
defer node.StopDaemon()
testDir := filepath.Join(node.Dir, "headertest2")
require.NoError(t, os.MkdirAll(testDir, 0755))
testFile := filepath.Join(testDir, "file.txt")
require.NoError(t, os.WriteFile(testFile, []byte("hello"), 0644))
oldTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)
require.NoError(t, os.Chtimes(testFile, oldTime, oldTime))
addRes := node.IPFS("add", "-r", "--preserve-mode", "--preserve-mtime", "-Q", testDir)
dirCid := addRes.Stdout.Trimmed()
// Run ls with --long --headers --size=false
lsRes := node.IPFS("ls", "--long", "--headers", "--size=false", dirCid)
output := lsRes.Stdout.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
// Header should be: Mode Hash ModTime Name (no Size)
require.GreaterOrEqual(t, len(lines), 2)
headerFields := strings.Fields(lines[0])
require.Len(t, headerFields, 4, "header should have 4 columns without size")
assert.Equal(t, "Mode", headerFields[0])
assert.Equal(t, "Hash", headerFields[1])
assert.Equal(t, "ModTime", headerFields[2])
assert.Equal(t, "Name", headerFields[3])
})
t.Run("long format for directories shows trailing slash", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init().StartDaemon()
defer node.StopDaemon()
// Create nested directory structure
testDir := filepath.Join(node.Dir, "dirtest")
subDir := filepath.Join(testDir, "subdir")
require.NoError(t, os.MkdirAll(subDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(subDir, "file.txt"), []byte("hi"), 0644))
addRes := node.IPFS("add", "-r", "--preserve-mode", "-Q", testDir)
dirCid := addRes.Stdout.Trimmed()
// Run ls with --long flag
lsRes := node.IPFS("ls", "--long", dirCid)
output := lsRes.Stdout.String()
// Directory should end with /
assert.Contains(t, output, "subdir/", "directory should have trailing slash")
// Directory should show 'd' in mode
assert.Contains(t, output, "drwxr-xr-x", "directory should show directory mode")
})
t.Run("long format without size flag", func(t *testing.T) {
t.Parallel()
node := harness.NewT(t).NewNode().Init().StartDaemon()
defer node.StopDaemon()
testDir := filepath.Join(node.Dir, "nosizetest")
require.NoError(t, os.MkdirAll(testDir, 0755))
testFile := filepath.Join(testDir, "file.txt")
require.NoError(t, os.WriteFile(testFile, []byte("hello world"), 0644))
oldTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)
require.NoError(t, os.Chtimes(testFile, oldTime, oldTime))
addRes := node.IPFS("add", "-r", "--preserve-mode", "--preserve-mtime", "-Q", testDir)
dirCid := addRes.Stdout.Trimmed()
// Run ls with --long but --size=false
lsRes := node.IPFS("ls", "--long", "--size=false", dirCid)
output := lsRes.Stdout.String()
// Should still have mode and mtime, but format differs (no size column)
assert.Contains(t, output, "-rw-r--r--")
assert.Contains(t, output, "Jan 01 2020")
assert.Contains(t, output, "file.txt")
})
t.Run("long format output is stable", func(t *testing.T) {
// This test ensures the output format doesn't change due to refactors
t.Parallel()
node := harness.NewT(t).NewNode().Init().StartDaemon()
defer node.StopDaemon()
testDir := filepath.Join(node.Dir, "stabletest")
require.NoError(t, os.MkdirAll(testDir, 0755))
testFile := filepath.Join(testDir, "test.txt")
require.NoError(t, os.WriteFile(testFile, []byte("stable"), 0644))
// Use a fixed time for reproducibility
fixedTime := time.Date(2020, time.December, 25, 12, 0, 0, 0, time.UTC)
require.NoError(t, os.Chtimes(testFile, fixedTime, fixedTime))
addRes := node.IPFS("add", "-r", "--preserve-mode", "--preserve-mtime", "-Q", testDir)
dirCid := addRes.Stdout.Trimmed()
// The CID should be deterministic given same content, mode, and mtime
// This is the expected CID for this specific test data
lsRes := node.IPFS("ls", "--long", dirCid)
output := strings.TrimSpace(lsRes.Stdout.String())
// Verify the format: Mode<tab>Hash<tab>Size<tab>ModTime<tab>Name
fields := strings.Fields(output)
require.GreaterOrEqual(t, len(fields), 5, "output should have at least 5 fields")
// Field 0: mode (10 chars, starts with - for regular file)
assert.Regexp(t, `^-[rwx-]{9}$`, fields[0], "mode should be Unix permission format")
// Field 1: CID (starts with Qm or bafy)
assert.Regexp(t, `^(Qm|bafy)`, fields[1], "second field should be CID")
// Field 2: size (numeric)
assert.Regexp(t, `^\d+$`, fields[2], "third field should be numeric size")
// Fields 3-4: date (e.g., "Dec 25 2020" or "Dec 25 12:00")
// The date format is "Mon DD YYYY" for old files
assert.Equal(t, "Dec", fields[3])
assert.Equal(t, "25", fields[4])
// Last field: filename
assert.Equal(t, "test.txt", fields[len(fields)-1])
})
}