kubo/core/commands/ls.go
sneax 98e7a46e1e 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>
2025-12-14 22:38:43 +05:30

414 lines
10 KiB
Go

package commands
import (
"context"
"fmt"
"io"
"os"
"slices"
"strings"
"text/tabwriter"
"time"
cmdenv "github.com/ipfs/kubo/core/commands/cmdenv"
"github.com/ipfs/kubo/core/commands/cmdutils"
unixfs "github.com/ipfs/boxo/ipld/unixfs"
unixfs_pb "github.com/ipfs/boxo/ipld/unixfs/pb"
cmds "github.com/ipfs/go-ipfs-cmds"
iface "github.com/ipfs/kubo/core/coreiface"
options "github.com/ipfs/kubo/core/coreiface/options"
)
// LsLink contains printable data for a single ipld link in ls output
type LsLink struct {
Name, Hash string
Size uint64
Type unixfs_pb.Data_DataType
Target string
Mode os.FileMode
ModTime time.Time
}
// LsObject is an element of LsOutput
// It can represent all or part of a directory
type LsObject struct {
Hash string
Links []LsLink
}
// LsOutput is a set of printable data for directories,
// it can be complete or partial
type LsOutput struct {
Objects []LsObject
}
const (
lsHeadersOptionNameTime = "headers"
lsResolveTypeOptionName = "resolve-type"
lsSizeOptionName = "size"
lsStreamOptionName = "stream"
lsLongOptionName = "long"
)
var LsCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "List directory contents for Unix filesystem objects.",
ShortDescription: `
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>
With the --long (-l) option, also display file mode (permissions) and
modification time for files that were added with --preserve-mode and
--preserve-mtime flags. Format similar to Unix 'ls -l':
<mode> <link base58 hash> <size> <mtime> <name>
The JSON output contains type information.
`,
},
Arguments: []cmds.Argument{
cmds.StringArg("ipfs-path", true, true, "The path to the IPFS object(s) to list links from.").EnableStdin(),
},
Options: []cmds.Option{
cmds.BoolOption(lsHeadersOptionNameTime, "v", "Print table headers (Hash, Size, Name)."),
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)
if err != nil {
return err
}
resolveType, _ := req.Options[lsResolveTypeOptionName].(bool)
resolveSize, _ := req.Options[lsSizeOptionName].(bool)
stream, _ := req.Options[lsStreamOptionName].(bool)
err = req.ParseBodyArgs()
if err != nil {
return err
}
paths := req.Arguments
enc, err := cmdenv.GetCidEncoder(req)
if err != nil {
return err
}
var processLink func(path string, link LsLink) error
var dirDone func(i int)
processDir := func() (func(path string, link LsLink) error, func(i int)) {
return func(path string, link LsLink) error {
output := []LsObject{{
Hash: path,
Links: []LsLink{link},
}}
return res.Emit(&LsOutput{output})
}, func(i int) {}
}
done := func() error { return nil }
if !stream {
output := make([]LsObject, len(req.Arguments))
processDir = func() (func(path string, link LsLink) error, func(i int)) {
// for each dir
outputLinks := make([]LsLink, 0)
return func(path string, link LsLink) error {
// for each link
outputLinks = append(outputLinks, link)
return nil
}, func(i int) {
// after each dir
slices.SortFunc(outputLinks, func(a, b LsLink) int {
return strings.Compare(a.Name, b.Name)
})
output[i] = LsObject{
Hash: paths[i],
Links: outputLinks,
}
}
}
done = func() error {
return cmds.EmitOnce(res, &LsOutput{output})
}
}
lsCtx, cancel := context.WithCancel(req.Context)
defer cancel()
for i, fpath := range paths {
pth, err := cmdutils.PathOrCidPath(fpath)
if err != nil {
return err
}
results := make(chan iface.DirEntry)
lsErr := make(chan error, 1)
go func() {
lsErr <- api.Unixfs().Ls(lsCtx, pth, results,
options.Unixfs.ResolveChildren(resolveSize || resolveType))
}()
processLink, dirDone = processDir()
for link := range results {
var ftype unixfs_pb.Data_DataType
switch link.Type {
case iface.TFile:
ftype = unixfs.TFile
case iface.TDirectory:
ftype = unixfs.TDirectory
case iface.TSymlink:
ftype = unixfs.TSymlink
}
lsLink := LsLink{
Name: link.Name,
Hash: enc.Encode(link.Cid),
Size: link.Size,
Type: ftype,
Target: link.Target,
Mode: link.Mode,
ModTime: link.ModTime,
}
if err = processLink(paths[i], lsLink); err != nil {
return err
}
}
if err = <-lsErr; err != nil {
return err
}
dirDone(i)
}
return done()
},
PostRun: cmds.PostRunMap{
cmds.CLI: func(res cmds.Response, re cmds.ResponseEmitter) error {
req := res.Request()
lastObjectHash := ""
for {
v, err := res.Next()
if err != nil {
if err == io.EOF {
return nil
}
return err
}
out := v.(*LsOutput)
lastObjectHash = tabularOutput(req, os.Stdout, out, lastObjectHash, false)
}
},
},
Encoders: cmds.EncoderMap{
cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *LsOutput) error {
// when streaming over HTTP using a text encoder, we cannot render breaks
// between directories because we don't know the hash of the last
// directory encoder
ignoreBreaks, _ := req.Options[lsStreamOptionName].(bool)
tabularOutput(req, w, out, "", ignoreBreaks)
return nil
}),
},
Type: LsOutput{},
}
// formatMode formats os.FileMode to Unix-style permission string (e.g., "-rw-r--r--").
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 rune) byte {
if mode&bit != 0 {
return byte(char)
}
return '-'
}
// formatModTime formats time.Time for display.
// Returns empty string if time is zero.
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
if stream {
minTabWidth = 10
} else {
minTabWidth = 1
}
multipleFolders := len(req.Arguments) > 1
tw := tabwriter.NewWriter(w, minTabWidth, 2, 1, ' ', 0)
for _, object := range out.Objects {
if !ignoreBreaks && object.Hash != lastObjectHash {
if multipleFolders {
if lastObjectHash != "" {
fmt.Fprintln(tw)
}
fmt.Fprintf(tw, "%s:\n", object.Hash)
}
if headers {
s := "Hash\tName"
if size {
s = "Hash\tSize\tName"
}
if long {
s = "Mode\t" + s + "\tModTime"
}
fmt.Fprintln(tw, s)
}
lastObjectHash = object.Hash
}
for _, link := range object.Links {
var s string
isDir := link.Type == unixfs.TDirectory || link.Type == unixfs.THAMTShard || link.Type == unixfs.TMetadata
if long {
// Long format: Mode Hash Size ModTime Name
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"
}
if size {
fmt.Fprintf(tw, s, mode, link.Hash, modTime, cmdenv.EscNonPrint(link.Name))
} else {
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()
return lastObjectHash
}