diff --git a/core/commands/ls.go b/core/commands/ls.go index 327b159a1..1f54753f1 100644 --- a/core/commands/ls.go +++ b/core/commands/ls.go @@ -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: - + + +With the --long (-l) option, display optional file mode (permissions) and +modification time in a format similar to Unix 'ls -l': + + + +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() diff --git a/core/commands/ls_test.go b/core/commands/ls_test.go new file mode 100644 index 000000000..243d72e5d --- /dev/null +++ b/core/commands/ls_test.go @@ -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") + }) +} diff --git a/docs/changelogs/v0.40.md b/docs/changelogs/v0.40.md index ff7b77fc1..524852a30 100644 --- a/docs/changelogs/v0.40.md +++ b/docs/changelogs/v0.40.md @@ -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) diff --git a/test/cli/ls_test.go b/test/cli/ls_test.go new file mode 100644 index 000000000..d1bd98677 --- /dev/null +++ b/test/cli/ls_test.go @@ -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) "-" (mtime) + 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: ModeHashSizeModTimeName + 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]) + }) +}