From e66fa0dbc47a931361eb7cd68fb0ef8f5816f29f Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Fri, 16 Jan 2026 01:12:15 +0100 Subject: [PATCH] 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 --- core/commands/ls_test.go | 190 ++++++++++++++++++++++++++++ test/cli/ls_test.go | 260 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 core/commands/ls_test.go create mode 100644 test/cli/ls_test.go diff --git a/core/commands/ls_test.go b/core/commands/ls_test.go new file mode 100644 index 000000000..28895891c --- /dev/null +++ b/core/commands/ls_test.go @@ -0,0 +1,190 @@ +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 a fixed time to avoid flakiness) + // We test the format length by checking a definitely-recent time + recentTime := time.Now().Add(-1 * time.Hour) + recentResult := formatModTime(recentTime) + assert.Len(t, recentResult, 12, "recent time format should be 12 chars") + }) +} diff --git a/test/cli/ls_test.go b/test/cli/ls_test.go new file mode 100644 index 000000000..c08d850dc --- /dev/null +++ b/test/cli/ls_test.go @@ -0,0 +1,260 @@ +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 for preserved metadata", 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") + + // Explicit full output check to catch any formatting regressions + expectedOutput := `-rwxr-xr-x QmNmu36VhUL46L1ebS7pjGNBhBWNhL6kERHiswK6fg2HJz 17 Jun 15 2020 executable.sh +-rw-r--r-- QmUHndqmVyXbCS4FkEAQRZ4FeoBZMn8NBsy86bAVR8JhYQ 5 Jun 15 2020 readable.txt` + assert.Equal(t, expectedOutput, strings.TrimSpace(output), "output format must remain stable") + }) + + t.Run("long format shows dash for files without preserved metadata", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init().StartDaemon() + defer node.StopDaemon() + + // Create and add a file without preserving metadata + 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 metadata should show default mode and dash for mtime + assert.Contains(t, output, "----------", "file without preserved mode should show no permissions") + // The tabwriter converts tabs to spaces, so check for space-dash-space pattern + assert.Regexp(t, `----------\s+\S+\s+\d+\s+-\s+`, output, "file without preserved mtime should 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]) + }) +}