@cryptotaxi247 / kubo / commits / c1fd4d70f

feat(cli): ls --long (#11103)

* 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>

segfault_bits committed Jan 16, 2026 at 06:57 UTC c1fd4d70f58e682bfe73fa4b50d17581c823c671
4 files changed +639 -16
core/commands/ls.go
+191 -16
@@ -48,6 +48,7 @@ const (
48 lsResolveTypeOptionName = "resolve-type"
49 lsSizeOptionName = "size"
50 lsStreamOptionName = "stream"
51 + lsLongOptionName = "long"
52 )
53
54 var LsCmd = &cmds.Command{
@@ -57,7 +58,26 @@ var LsCmd = &cmds.Command{
58 Displays the contents of an IPFS or IPNS object(s) at the given path, with
59 the following format:
60
60 - <link base58 hash> <link size in bytes> <link name>
61 + <cid> <size> <name>
62 +
63 +With the --long (-l) option, display optional file mode (permissions) and
64 +modification time in a format similar to Unix 'ls -l':
65 +
66 + <mode> <cid> <size> <mtime> <name>
67 +
68 +Mode and mtime are optional UnixFS metadata. They are only present if the
69 +content was imported with 'ipfs add --preserve-mode' and '--preserve-mtime'.
70 +Without preserved metadata, both mode and mtime display '-'. Times are in UTC.
71 +
72 +Example with --long and preserved metadata:
73 +
74 + -rw-r--r-- QmZULkCELmmk5XNf... 1234 Jan 15 10:30 document.txt
75 + -rwxr-xr-x QmaRGe7bVmVaLmxb... 5678 Dec 01 2023 script.sh
76 + drwxr-xr-x QmWWEQhcLufF3qPm... - Nov 20 2023 subdir/
77 +
78 +Example with --long without preserved metadata:
79 +
80 + - QmZULkCELmmk5XNf... 1234 - document.txt
81
82 The JSON output contains type information.
83 `,
@@ -71,6 +91,7 @@ The JSON output contains type information.
91 cmds.BoolOption(lsResolveTypeOptionName, "Resolve linked objects to find out their types.").WithDefault(true),
92 cmds.BoolOption(lsSizeOptionName, "Resolve linked objects to find out their file size.").WithDefault(true),
93 cmds.BoolOption(lsStreamOptionName, "s", "Enable experimental streaming of directory entries as they are traversed."),
94 + cmds.BoolOption(lsLongOptionName, "l", "Use a long listing format, showing file mode and modification time."),
95 },
96 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
97 api, err := cmdenv.GetApi(env, req)
@@ -215,10 +236,121 @@ The JSON output contains type information.
236 Type: LsOutput{},
237 }
238
239 +// formatMode converts os.FileMode to a 10-character Unix ls-style string.
240 +//
241 +// Format: [type][owner rwx][group rwx][other rwx]
242 +//
243 +// Type indicators: - (regular), d (directory), l (symlink), p (named pipe),
244 +// s (socket), c (char device), b (block device).
245 +//
246 +// Special bits replace the execute position: setuid on owner (s/S),
247 +// setgid on group (s/S), sticky on other (t/T). Lowercase when the
248 +// underlying execute bit is also set, uppercase when not.
249 +func formatMode(mode os.FileMode) string {
250 + var buf [10]byte
251 +
252 + // File type - handle all special file types like ls does
253 + switch {
254 + case mode&os.ModeDir != 0:
255 + buf[0] = 'd'
256 + case mode&os.ModeSymlink != 0:
257 + buf[0] = 'l'
258 + case mode&os.ModeNamedPipe != 0:
259 + buf[0] = 'p'
260 + case mode&os.ModeSocket != 0:
261 + buf[0] = 's'
262 + case mode&os.ModeDevice != 0:
263 + if mode&os.ModeCharDevice != 0 {
264 + buf[0] = 'c'
265 + } else {
266 + buf[0] = 'b'
267 + }
268 + default:
269 + buf[0] = '-'
270 + }
271 +
272 + // Owner permissions (bits 8,7,6)
273 + buf[1] = permBit(mode, 0400, 'r') // read
274 + buf[2] = permBit(mode, 0200, 'w') // write
275 + // Handle setuid bit for owner execute
276 + if mode&os.ModeSetuid != 0 {
277 + if mode&0100 != 0 {
278 + buf[3] = 's'
279 + } else {
280 + buf[3] = 'S'
281 + }
282 + } else {
283 + buf[3] = permBit(mode, 0100, 'x') // execute
284 + }
285 +
286 + // Group permissions (bits 5,4,3)
287 + buf[4] = permBit(mode, 0040, 'r') // read
288 + buf[5] = permBit(mode, 0020, 'w') // write
289 + // Handle setgid bit for group execute
290 + if mode&os.ModeSetgid != 0 {
291 + if mode&0010 != 0 {
292 + buf[6] = 's'
293 + } else {
294 + buf[6] = 'S'
295 + }
296 + } else {
297 + buf[6] = permBit(mode, 0010, 'x') // execute
298 + }
299 +
300 + // Other permissions (bits 2,1,0)
301 + buf[7] = permBit(mode, 0004, 'r') // read
302 + buf[8] = permBit(mode, 0002, 'w') // write
303 + // Handle sticky bit for other execute
304 + if mode&os.ModeSticky != 0 {
305 + if mode&0001 != 0 {
306 + buf[9] = 't'
307 + } else {
308 + buf[9] = 'T'
309 + }
310 + } else {
311 + buf[9] = permBit(mode, 0001, 'x') // execute
312 + }
313 +
314 + return string(buf[:])
315 +}
316 +
317 +// permBit returns the permission character if the bit is set.
318 +func permBit(mode os.FileMode, bit os.FileMode, char byte) byte {
319 + if mode&bit != 0 {
320 + return char
321 + }
322 + return '-'
323 +}
324 +
325 +// formatModTime formats time.Time for display, following Unix ls conventions.
326 +//
327 +// Returns "-" for zero time. Otherwise returns a 12-character string:
328 +// recent files (within 6 months) show "Jan 02 15:04",
329 +// older or future files show "Jan 02 2006".
330 +//
331 +// The output uses the timezone embedded in t (UTC for IPFS metadata).
332 +func formatModTime(t time.Time) string {
333 + if t.IsZero() {
334 + return "-"
335 + }
336 +
337 + // Format: "Jan 02 15:04" for times within the last 6 months
338 + // Format: "Jan 02 2006" for older times (similar to ls)
339 + now := time.Now()
340 + sixMonthsAgo := now.AddDate(0, -6, 0)
341 +
342 + if t.After(sixMonthsAgo) && t.Before(now.Add(24*time.Hour)) {
343 + return t.Format("Jan 02 15:04")
344 + }
345 + return t.Format("Jan 02 2006")
346 +}
347 +
348 func tabularOutput(req *cmds.Request, w io.Writer, out *LsOutput, lastObjectHash string, ignoreBreaks bool) string {
349 headers, _ := req.Options[lsHeadersOptionNameTime].(bool)
350 stream, _ := req.Options[lsStreamOptionName].(bool)
351 size, _ := req.Options[lsSizeOptionName].(bool)
352 + long, _ := req.Options[lsLongOptionName].(bool)
353 +
354 // in streaming mode we can't automatically align the tabs
355 // so we take a best guess
356 var minTabWidth int
@@ -242,9 +374,21 @@ func tabularOutput(req *cmds.Request, w io.Writer, out *LsOutput, lastObjectHash
374 fmt.Fprintf(tw, "%s:\n", object.Hash)
375 }
376 if headers {
245 - s := "Hash\tName"
246 - if size {
247 - s = "Hash\tSize\tName"
377 + var s string
378 + if long {
379 + // Long format: Mode Hash [Size] ModTime Name
380 + if size {
381 + s = "Mode\tHash\tSize\tModTime\tName"
382 + } else {
383 + s = "Mode\tHash\tModTime\tName"
384 + }
385 + } else {
386 + // Standard format: Hash [Size] Name
387 + if size {
388 + s = "Hash\tSize\tName"
389 + } else {
390 + s = "Hash\tName"
391 + }
392 }
393 fmt.Fprintln(tw, s)
394 }
@@ -253,23 +397,54 @@ func tabularOutput(req *cmds.Request, w io.Writer, out *LsOutput, lastObjectHash
397
398 for _, link := range object.Links {
399 var s string
256 - switch link.Type {
257 - case unixfs.TDirectory, unixfs.THAMTShard, unixfs.TMetadata:
258 - if size {
259 - s = "%[1]s\t-\t%[3]s/\n"
400 + isDir := link.Type == unixfs.TDirectory || link.Type == unixfs.THAMTShard || link.Type == unixfs.TMetadata
401 +
402 + if long {
403 + // Long format: Mode Hash Size ModTime Name
404 + var mode string
405 + if link.Mode == 0 {
406 + // No mode metadata preserved. Show "-" to indicate
407 + // "not available" rather than "----------" (mode 0000).
408 + mode = "-"
409 } else {
261 - s = "%[1]s\t%[3]s/\n"
410 + mode = formatMode(link.Mode)
411 }
263 - default:
264 - if size {
265 - s = "%s\t%v\t%s\n"
412 + modTime := formatModTime(link.ModTime)
413 +
414 + if isDir {
415 + if size {
416 + s = "%s\t%s\t-\t%s\t%s/\n"
417 + } else {
418 + s = "%s\t%s\t%s\t%s/\n"
419 + }
420 + fmt.Fprintf(tw, s, mode, link.Hash, modTime, cmdenv.EscNonPrint(link.Name))
421 } else {
267 - s = "%[1]s\t%[3]s\n"
422 + if size {
423 + s = "%s\t%s\t%v\t%s\t%s\n"
424 + fmt.Fprintf(tw, s, mode, link.Hash, link.Size, modTime, cmdenv.EscNonPrint(link.Name))
425 + } else {
426 + s = "%s\t%s\t%s\t%s\n"
427 + fmt.Fprintf(tw, s, mode, link.Hash, modTime, cmdenv.EscNonPrint(link.Name))
428 + }
429 }
430 + } else {
431 + // Standard format: Hash [Size] Name
432 + switch {
433 + case isDir:
434 + if size {
435 + s = "%[1]s\t-\t%[3]s/\n"
436 + } else {
437 + s = "%[1]s\t%[3]s/\n"
438 + }
439 + default:
440 + if size {
441 + s = "%s\t%v\t%s\n"
442 + } else {
443 + s = "%[1]s\t%[3]s\n"
444 + }
445 + }
446 + fmt.Fprintf(tw, s, link.Hash, link.Size, cmdenv.EscNonPrint(link.Name))
447 }
270 -
271 - // TODO: Print link.Mode and link.ModTime?
272 - fmt.Fprintf(tw, s, link.Hash, link.Size, cmdenv.EscNonPrint(link.Name))
448 }
449 }
450 tw.Flush()
core/commands/ls_test.go new
+189
@@ -0,0 +1,189 @@
1 +package commands
2 +
3 +import (
4 + "os"
5 + "testing"
6 + "time"
7 +
8 + "github.com/stretchr/testify/assert"
9 +)
10 +
11 +func TestFormatMode(t *testing.T) {
12 + t.Parallel()
13 +
14 + tests := []struct {
15 + name string
16 + mode os.FileMode
17 + expected string
18 + }{
19 + // File types
20 + {
21 + name: "regular file with rw-r--r--",
22 + mode: 0644,
23 + expected: "-rw-r--r--",
24 + },
25 + {
26 + name: "regular file with rwxr-xr-x",
27 + mode: 0755,
28 + expected: "-rwxr-xr-x",
29 + },
30 + {
31 + name: "regular file with no permissions",
32 + mode: 0,
33 + expected: "----------",
34 + },
35 + {
36 + name: "regular file with full permissions",
37 + mode: 0777,
38 + expected: "-rwxrwxrwx",
39 + },
40 + {
41 + name: "directory with rwxr-xr-x",
42 + mode: os.ModeDir | 0755,
43 + expected: "drwxr-xr-x",
44 + },
45 + {
46 + name: "directory with rwx------",
47 + mode: os.ModeDir | 0700,
48 + expected: "drwx------",
49 + },
50 + {
51 + name: "symlink with rwxrwxrwx",
52 + mode: os.ModeSymlink | 0777,
53 + expected: "lrwxrwxrwx",
54 + },
55 + {
56 + name: "named pipe with rw-r--r--",
57 + mode: os.ModeNamedPipe | 0644,
58 + expected: "prw-r--r--",
59 + },
60 + {
61 + name: "socket with rw-rw-rw-",
62 + mode: os.ModeSocket | 0666,
63 + expected: "srw-rw-rw-",
64 + },
65 + {
66 + name: "block device with rw-rw----",
67 + mode: os.ModeDevice | 0660,
68 + expected: "brw-rw----",
69 + },
70 + {
71 + name: "character device with rw-rw-rw-",
72 + mode: os.ModeDevice | os.ModeCharDevice | 0666,
73 + expected: "crw-rw-rw-",
74 + },
75 +
76 + // Special permission bits - setuid
77 + {
78 + name: "setuid with execute",
79 + mode: os.ModeSetuid | 0755,
80 + expected: "-rwsr-xr-x",
81 + },
82 + {
83 + name: "setuid without execute",
84 + mode: os.ModeSetuid | 0644,
85 + expected: "-rwSr--r--",
86 + },
87 +
88 + // Special permission bits - setgid
89 + {
90 + name: "setgid with execute",
91 + mode: os.ModeSetgid | 0755,
92 + expected: "-rwxr-sr-x",
93 + },
94 + {
95 + name: "setgid without execute",
96 + mode: os.ModeSetgid | 0745,
97 + expected: "-rwxr-Sr-x",
98 + },
99 +
100 + // Special permission bits - sticky
101 + {
102 + name: "sticky with execute",
103 + mode: os.ModeSticky | 0755,
104 + expected: "-rwxr-xr-t",
105 + },
106 + {
107 + name: "sticky without execute",
108 + mode: os.ModeSticky | 0754,
109 + expected: "-rwxr-xr-T",
110 + },
111 +
112 + // Combined special bits
113 + {
114 + name: "setuid + setgid + sticky all with execute",
115 + mode: os.ModeSetuid | os.ModeSetgid | os.ModeSticky | 0777,
116 + expected: "-rwsrwsrwt",
117 + },
118 + {
119 + name: "setuid + setgid + sticky none with execute",
120 + mode: os.ModeSetuid | os.ModeSetgid | os.ModeSticky | 0666,
121 + expected: "-rwSrwSrwT",
122 + },
123 +
124 + // Directory with special bits
125 + {
126 + name: "directory with sticky bit",
127 + mode: os.ModeDir | os.ModeSticky | 0755,
128 + expected: "drwxr-xr-t",
129 + },
130 + }
131 +
132 + for _, tc := range tests {
133 + t.Run(tc.name, func(t *testing.T) {
134 + t.Parallel()
135 + result := formatMode(tc.mode)
136 + assert.Equal(t, tc.expected, result)
137 + })
138 + }
139 +}
140 +
141 +func TestFormatModTime(t *testing.T) {
142 + t.Parallel()
143 +
144 + t.Run("zero time returns dash", func(t *testing.T) {
145 + t.Parallel()
146 + result := formatModTime(time.Time{})
147 + assert.Equal(t, "-", result)
148 + })
149 +
150 + t.Run("old time shows year format", func(t *testing.T) {
151 + t.Parallel()
152 + // Use a time clearly in the past (more than 6 months ago)
153 + oldTime := time.Date(2020, time.March, 15, 10, 30, 0, 0, time.UTC)
154 + result := formatModTime(oldTime)
155 + // Format: "Jan 02 2006" (note: two spaces before year)
156 + assert.Equal(t, "Mar 15 2020", result)
157 + })
158 +
159 + t.Run("very old time shows year format", func(t *testing.T) {
160 + t.Parallel()
161 + veryOldTime := time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)
162 + result := formatModTime(veryOldTime)
163 + assert.Equal(t, "Jan 01 2000", result)
164 + })
165 +
166 + t.Run("future time shows year format", func(t *testing.T) {
167 + t.Parallel()
168 + // Times more than 24h in the future should show year format
169 + futureTime := time.Now().AddDate(1, 0, 0)
170 + result := formatModTime(futureTime)
171 + // Should contain the future year
172 + assert.Contains(t, result, " ") // two spaces before year
173 + assert.Regexp(t, `^[A-Z][a-z]{2} \d{2} \d{4}$`, result) // matches "Mon DD YYYY"
174 + assert.Contains(t, result, futureTime.Format("2006")) // contains the year
175 + })
176 +
177 + t.Run("format lengths are consistent", func(t *testing.T) {
178 + t.Parallel()
179 + // Both formats should produce 12-character strings for alignment
180 + oldTime := time.Date(2020, time.March, 15, 10, 30, 0, 0, time.UTC)
181 + oldResult := formatModTime(oldTime)
182 + assert.Len(t, oldResult, 12, "old time format should be 12 chars")
183 +
184 + // Recent time: use 1 month ago to ensure it's always within the 6-month window
185 + recentTime := time.Now().AddDate(0, -1, 0)
186 + recentResult := formatModTime(recentTime)
187 + assert.Len(t, recentResult, 12, "recent time format should be 12 chars")
188 + })
189 +}
docs/changelogs/v0.40.md
+5
@@ -21,6 +21,7 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
21 - [Accelerated DHT Client and Provide Sweep now work together](#accelerated-dht-client-and-provide-sweep-now-work-together)
22 - [⏱️ Configurable gateway request duration limit](#️-configurable-gateway-request-duration-limit)
23 - [🔧 Recovery from corrupted MFS root](#-recovery-from-corrupted-mfs-root)
24 + - [📋 Long listing format for `ipfs ls`](#-long-listing-format-for-ipfs-ls)
25 - [📦️ Dependency updates](#-dependency-updates)
26 - [📝 Changelog](#-changelog)
27 - [👨‍👩‍👧‍👦 Contributors](#-contributors)
@@ -139,6 +140,10 @@ $ ipfs files chroot --confirm QmYourBackupCID
140
141 See `ipfs files chroot --help` for details.
142
143 +#### 📋 Long listing format for `ipfs ls`
144 +
145 +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.
146 +
147 #### 📦️ Dependency updates
148
149 - update `go-libp2p` to [v0.46.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.46.0)
test/cli/ls_test.go new
+254
@@ -0,0 +1,254 @@
1 +package cli
2 +
3 +import (
4 + "os"
5 + "path/filepath"
6 + "strings"
7 + "testing"
8 + "time"
9 +
10 + "github.com/ipfs/kubo/test/cli/harness"
11 + "github.com/stretchr/testify/assert"
12 + "github.com/stretchr/testify/require"
13 +)
14 +
15 +func TestLsLongFormat(t *testing.T) {
16 + t.Parallel()
17 +
18 + t.Run("long format shows mode and mtime when preserved", func(t *testing.T) {
19 + t.Parallel()
20 + node := harness.NewT(t).NewNode().Init().StartDaemon()
21 + defer node.StopDaemon()
22 +
23 + // Create a test directory structure with known permissions
24 + testDir := filepath.Join(node.Dir, "testdata")
25 + require.NoError(t, os.MkdirAll(testDir, 0755))
26 +
27 + // Create files with specific permissions
28 + file1 := filepath.Join(testDir, "readable.txt")
29 + require.NoError(t, os.WriteFile(file1, []byte("hello"), 0644))
30 +
31 + file2 := filepath.Join(testDir, "executable.sh")
32 + require.NoError(t, os.WriteFile(file2, []byte("#!/bin/sh\necho hi"), 0755))
33 +
34 + // Set a known mtime in the past (to get year format, avoiding flaky time-based tests)
35 + oldTime := time.Date(2020, time.June, 15, 10, 30, 0, 0, time.UTC)
36 + require.NoError(t, os.Chtimes(file1, oldTime, oldTime))
37 + require.NoError(t, os.Chtimes(file2, oldTime, oldTime))
38 +
39 + // Add with preserved mode and mtime
40 + addRes := node.IPFS("add", "-r", "--preserve-mode", "--preserve-mtime", "-Q", testDir)
41 + dirCid := addRes.Stdout.Trimmed()
42 +
43 + // Run ls with --long flag
44 + lsRes := node.IPFS("ls", "--long", dirCid)
45 + output := lsRes.Stdout.String()
46 +
47 + // Verify format: Mode Hash Size ModTime Name
48 + lines := strings.Split(strings.TrimSpace(output), "\n")
49 + require.Len(t, lines, 2, "expected 2 files in output")
50 +
51 + // Check executable.sh line (should be first alphabetically)
52 + assert.Contains(t, lines[0], "-rwxr-xr-x", "executable should have 755 permissions")
53 + assert.Contains(t, lines[0], "Jun 15 2020", "should show mtime with year format")
54 + assert.Contains(t, lines[0], "executable.sh", "should show filename")
55 +
56 + // Check readable.txt line
57 + assert.Contains(t, lines[1], "-rw-r--r--", "readable file should have 644 permissions")
58 + assert.Contains(t, lines[1], "Jun 15 2020", "should show mtime with year format")
59 + assert.Contains(t, lines[1], "readable.txt", "should show filename")
60 + })
61 +
62 + t.Run("long format shows dash for files without preserved mode or mtime", func(t *testing.T) {
63 + t.Parallel()
64 + node := harness.NewT(t).NewNode().Init().StartDaemon()
65 + defer node.StopDaemon()
66 +
67 + // Create and add a file without --preserve-mode or --preserve-mtime
68 + testFile := filepath.Join(node.Dir, "nopreserve.txt")
69 + require.NoError(t, os.WriteFile(testFile, []byte("test content"), 0644))
70 +
71 + addRes := node.IPFS("add", "-Q", testFile)
72 + fileCid := addRes.Stdout.Trimmed()
73 +
74 + // Create a wrapper directory to list
75 + node.IPFS("files", "mkdir", "/testdir")
76 + node.IPFS("files", "cp", "/ipfs/"+fileCid, "/testdir/file.txt")
77 + statRes := node.IPFS("files", "stat", "--hash", "/testdir")
78 + dirCid := statRes.Stdout.Trimmed()
79 +
80 + // Run ls with --long flag
81 + lsRes := node.IPFS("ls", "--long", dirCid)
82 + output := lsRes.Stdout.String()
83 +
84 + // Files without preserved mode or mtime should show "-" for both columns
85 + // Format: "-" (mode) <CID> <size> "-" (mtime) <name>
86 + assert.Regexp(t, `^-\s+\S+\s+\d+\s+-\s+`, output, "missing mode and mtime should both show dash")
87 + })
88 +
89 + t.Run("long format with headers shows correct column order", func(t *testing.T) {
90 + t.Parallel()
91 + node := harness.NewT(t).NewNode().Init().StartDaemon()
92 + defer node.StopDaemon()
93 +
94 + // Create a simple test file
95 + testDir := filepath.Join(node.Dir, "headertest")
96 + require.NoError(t, os.MkdirAll(testDir, 0755))
97 + testFile := filepath.Join(testDir, "file.txt")
98 + require.NoError(t, os.WriteFile(testFile, []byte("hello"), 0644))
99 +
100 + oldTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)
101 + require.NoError(t, os.Chtimes(testFile, oldTime, oldTime))
102 +
103 + addRes := node.IPFS("add", "-r", "--preserve-mode", "--preserve-mtime", "-Q", testDir)
104 + dirCid := addRes.Stdout.Trimmed()
105 +
106 + // Run ls with --long and --headers (--size defaults to true)
107 + lsRes := node.IPFS("ls", "--long", "--headers", dirCid)
108 + output := lsRes.Stdout.String()
109 + lines := strings.Split(strings.TrimSpace(output), "\n")
110 +
111 + // First line should be headers in correct order: Mode Hash Size ModTime Name
112 + require.GreaterOrEqual(t, len(lines), 2)
113 + headerFields := strings.Fields(lines[0])
114 + require.Len(t, headerFields, 5, "header should have 5 columns")
115 + assert.Equal(t, "Mode", headerFields[0])
116 + assert.Equal(t, "Hash", headerFields[1])
117 + assert.Equal(t, "Size", headerFields[2])
118 + assert.Equal(t, "ModTime", headerFields[3])
119 + assert.Equal(t, "Name", headerFields[4])
120 +
121 + // Data line should have matching columns
122 + dataFields := strings.Fields(lines[1])
123 + require.GreaterOrEqual(t, len(dataFields), 5)
124 + assert.Regexp(t, `^-[rwx-]{9}$`, dataFields[0], "first field should be mode")
125 + assert.Regexp(t, `^Qm`, dataFields[1], "second field should be CID")
126 + assert.Regexp(t, `^\d+$`, dataFields[2], "third field should be size")
127 + })
128 +
129 + t.Run("long format with headers and size=false", func(t *testing.T) {
130 + t.Parallel()
131 + node := harness.NewT(t).NewNode().Init().StartDaemon()
132 + defer node.StopDaemon()
133 +
134 + testDir := filepath.Join(node.Dir, "headertest2")
135 + require.NoError(t, os.MkdirAll(testDir, 0755))
136 + testFile := filepath.Join(testDir, "file.txt")
137 + require.NoError(t, os.WriteFile(testFile, []byte("hello"), 0644))
138 +
139 + oldTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)
140 + require.NoError(t, os.Chtimes(testFile, oldTime, oldTime))
141 +
142 + addRes := node.IPFS("add", "-r", "--preserve-mode", "--preserve-mtime", "-Q", testDir)
143 + dirCid := addRes.Stdout.Trimmed()
144 +
145 + // Run ls with --long --headers --size=false
146 + lsRes := node.IPFS("ls", "--long", "--headers", "--size=false", dirCid)
147 + output := lsRes.Stdout.String()
148 + lines := strings.Split(strings.TrimSpace(output), "\n")
149 +
150 + // Header should be: Mode Hash ModTime Name (no Size)
151 + require.GreaterOrEqual(t, len(lines), 2)
152 + headerFields := strings.Fields(lines[0])
153 + require.Len(t, headerFields, 4, "header should have 4 columns without size")
154 + assert.Equal(t, "Mode", headerFields[0])
155 + assert.Equal(t, "Hash", headerFields[1])
156 + assert.Equal(t, "ModTime", headerFields[2])
157 + assert.Equal(t, "Name", headerFields[3])
158 + })
159 +
160 + t.Run("long format for directories shows trailing slash", func(t *testing.T) {
161 + t.Parallel()
162 + node := harness.NewT(t).NewNode().Init().StartDaemon()
163 + defer node.StopDaemon()
164 +
165 + // Create nested directory structure
166 + testDir := filepath.Join(node.Dir, "dirtest")
167 + subDir := filepath.Join(testDir, "subdir")
168 + require.NoError(t, os.MkdirAll(subDir, 0755))
169 + require.NoError(t, os.WriteFile(filepath.Join(subDir, "file.txt"), []byte("hi"), 0644))
170 +
171 + addRes := node.IPFS("add", "-r", "--preserve-mode", "-Q", testDir)
172 + dirCid := addRes.Stdout.Trimmed()
173 +
174 + // Run ls with --long flag
175 + lsRes := node.IPFS("ls", "--long", dirCid)
176 + output := lsRes.Stdout.String()
177 +
178 + // Directory should end with /
179 + assert.Contains(t, output, "subdir/", "directory should have trailing slash")
180 + // Directory should show 'd' in mode
181 + assert.Contains(t, output, "drwxr-xr-x", "directory should show directory mode")
182 + })
183 +
184 + t.Run("long format without size flag", func(t *testing.T) {
185 + t.Parallel()
186 + node := harness.NewT(t).NewNode().Init().StartDaemon()
187 + defer node.StopDaemon()
188 +
189 + testDir := filepath.Join(node.Dir, "nosizetest")
190 + require.NoError(t, os.MkdirAll(testDir, 0755))
191 + testFile := filepath.Join(testDir, "file.txt")
192 + require.NoError(t, os.WriteFile(testFile, []byte("hello world"), 0644))
193 +
194 + oldTime := time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)
195 + require.NoError(t, os.Chtimes(testFile, oldTime, oldTime))
196 +
197 + addRes := node.IPFS("add", "-r", "--preserve-mode", "--preserve-mtime", "-Q", testDir)
198 + dirCid := addRes.Stdout.Trimmed()
199 +
200 + // Run ls with --long but --size=false
201 + lsRes := node.IPFS("ls", "--long", "--size=false", dirCid)
202 + output := lsRes.Stdout.String()
203 +
204 + // Should still have mode and mtime, but format differs (no size column)
205 + assert.Contains(t, output, "-rw-r--r--")
206 + assert.Contains(t, output, "Jan 01 2020")
207 + assert.Contains(t, output, "file.txt")
208 + })
209 +
210 + t.Run("long format output is stable", func(t *testing.T) {
211 + // This test ensures the output format doesn't change due to refactors
212 + t.Parallel()
213 + node := harness.NewT(t).NewNode().Init().StartDaemon()
214 + defer node.StopDaemon()
215 +
216 + testDir := filepath.Join(node.Dir, "stabletest")
217 + require.NoError(t, os.MkdirAll(testDir, 0755))
218 + testFile := filepath.Join(testDir, "test.txt")
219 + require.NoError(t, os.WriteFile(testFile, []byte("stable"), 0644))
220 +
221 + // Use a fixed time for reproducibility
222 + fixedTime := time.Date(2020, time.December, 25, 12, 0, 0, 0, time.UTC)
223 + require.NoError(t, os.Chtimes(testFile, fixedTime, fixedTime))
224 +
225 + addRes := node.IPFS("add", "-r", "--preserve-mode", "--preserve-mtime", "-Q", testDir)
226 + dirCid := addRes.Stdout.Trimmed()
227 +
228 + // The CID should be deterministic given same content, mode, and mtime
229 + // This is the expected CID for this specific test data
230 + lsRes := node.IPFS("ls", "--long", dirCid)
231 + output := strings.TrimSpace(lsRes.Stdout.String())
232 +
233 + // Verify the format: Mode<tab>Hash<tab>Size<tab>ModTime<tab>Name
234 + fields := strings.Fields(output)
235 + require.GreaterOrEqual(t, len(fields), 5, "output should have at least 5 fields")
236 +
237 + // Field 0: mode (10 chars, starts with - for regular file)
238 + assert.Regexp(t, `^-[rwx-]{9}$`, fields[0], "mode should be Unix permission format")
239 +
240 + // Field 1: CID (starts with Qm or bafy)
241 + assert.Regexp(t, `^(Qm|bafy)`, fields[1], "second field should be CID")
242 +
243 + // Field 2: size (numeric)
244 + assert.Regexp(t, `^\d+$`, fields[2], "third field should be numeric size")
245 +
246 + // Fields 3-4: date (e.g., "Dec 25 2020" or "Dec 25 12:00")
247 + // The date format is "Mon DD YYYY" for old files
248 + assert.Equal(t, "Dec", fields[3])
249 + assert.Equal(t, "25", fields[4])
250 +
251 + // Last field: filename
252 + assert.Equal(t, "test.txt", fields[len(fields)-1])
253 + })
254 +}