/ core / commands / ls.go
ls.go
  1  package commands
  2  
  3  import (
  4  	"context"
  5  	"fmt"
  6  	"io"
  7  	"os"
  8  	"slices"
  9  	"strings"
 10  	"text/tabwriter"
 11  	"time"
 12  
 13  	cmdenv "github.com/ipfs/kubo/core/commands/cmdenv"
 14  	"github.com/ipfs/kubo/core/commands/cmdutils"
 15  
 16  	unixfs "github.com/ipfs/boxo/ipld/unixfs"
 17  	unixfs_pb "github.com/ipfs/boxo/ipld/unixfs/pb"
 18  	cmds "github.com/ipfs/go-ipfs-cmds"
 19  	iface "github.com/ipfs/kubo/core/coreiface"
 20  	options "github.com/ipfs/kubo/core/coreiface/options"
 21  )
 22  
 23  // LsLink contains printable data for a single ipld link in ls output
 24  type LsLink struct {
 25  	Name, Hash string
 26  	Size       uint64
 27  	Type       unixfs_pb.Data_DataType
 28  	Target     string
 29  	Mode       os.FileMode
 30  	ModTime    time.Time
 31  }
 32  
 33  // LsObject is an element of LsOutput
 34  // It can represent all or part of a directory
 35  type LsObject struct {
 36  	Hash  string
 37  	Links []LsLink
 38  }
 39  
 40  // LsOutput is a set of printable data for directories,
 41  // it can be complete or partial
 42  type LsOutput struct {
 43  	Objects []LsObject
 44  }
 45  
 46  const (
 47  	lsHeadersOptionNameTime = "headers"
 48  	lsResolveTypeOptionName = "resolve-type"
 49  	lsSizeOptionName        = "size"
 50  	lsStreamOptionName      = "stream"
 51  	lsLongOptionName        = "long"
 52  )
 53  
 54  var LsCmd = &cmds.Command{
 55  	Helptext: cmds.HelpText{
 56  		Tagline: "List directory contents for Unix filesystem objects.",
 57  		ShortDescription: `
 58  Displays the contents of an IPFS or IPNS object(s) at the given path, with
 59  the following format:
 60  
 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  `,
 84  	},
 85  
 86  	Arguments: []cmds.Argument{
 87  		cmds.StringArg("ipfs-path", true, true, "The path to the IPFS object(s) to list links from.").EnableStdin(),
 88  	},
 89  	Options: []cmds.Option{
 90  		cmds.BoolOption(lsHeadersOptionNameTime, "v", "Print table headers (Hash, Size, Name)."),
 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)
 98  		if err != nil {
 99  			return err
100  		}
101  
102  		resolveType, _ := req.Options[lsResolveTypeOptionName].(bool)
103  		resolveSize, _ := req.Options[lsSizeOptionName].(bool)
104  		stream, _ := req.Options[lsStreamOptionName].(bool)
105  
106  		err = req.ParseBodyArgs()
107  		if err != nil {
108  			return err
109  		}
110  		paths := req.Arguments
111  
112  		enc, err := cmdenv.GetCidEncoder(req)
113  		if err != nil {
114  			return err
115  		}
116  
117  		var processLink func(path string, link LsLink) error
118  		var dirDone func(i int)
119  
120  		processDir := func() (func(path string, link LsLink) error, func(i int)) {
121  			return func(path string, link LsLink) error {
122  				output := []LsObject{{
123  					Hash:  path,
124  					Links: []LsLink{link},
125  				}}
126  				return res.Emit(&LsOutput{output})
127  			}, func(i int) {}
128  		}
129  		done := func() error { return nil }
130  
131  		if !stream {
132  			output := make([]LsObject, len(req.Arguments))
133  
134  			processDir = func() (func(path string, link LsLink) error, func(i int)) {
135  				// for each dir
136  				outputLinks := make([]LsLink, 0)
137  				return func(path string, link LsLink) error {
138  						// for each link
139  						outputLinks = append(outputLinks, link)
140  						return nil
141  					}, func(i int) {
142  						// after each dir
143  						slices.SortFunc(outputLinks, func(a, b LsLink) int {
144  							return strings.Compare(a.Name, b.Name)
145  						})
146  
147  						output[i] = LsObject{
148  							Hash:  paths[i],
149  							Links: outputLinks,
150  						}
151  					}
152  			}
153  
154  			done = func() error {
155  				return cmds.EmitOnce(res, &LsOutput{output})
156  			}
157  		}
158  
159  		lsCtx, cancel := context.WithCancel(req.Context)
160  		defer cancel()
161  
162  		for i, fpath := range paths {
163  			pth, err := cmdutils.PathOrCidPath(fpath)
164  			if err != nil {
165  				return err
166  			}
167  
168  			results := make(chan iface.DirEntry)
169  			lsErr := make(chan error, 1)
170  			go func() {
171  				lsErr <- api.Unixfs().Ls(lsCtx, pth, results,
172  					options.Unixfs.ResolveChildren(resolveSize || resolveType))
173  			}()
174  
175  			processLink, dirDone = processDir()
176  			for link := range results {
177  				var ftype unixfs_pb.Data_DataType
178  				switch link.Type {
179  				case iface.TFile:
180  					ftype = unixfs.TFile
181  				case iface.TDirectory:
182  					ftype = unixfs.TDirectory
183  				case iface.TSymlink:
184  					ftype = unixfs.TSymlink
185  				}
186  				lsLink := LsLink{
187  					Name: link.Name,
188  					Hash: enc.Encode(link.Cid),
189  
190  					Size:   link.Size,
191  					Type:   ftype,
192  					Target: link.Target,
193  
194  					Mode:    link.Mode,
195  					ModTime: link.ModTime,
196  				}
197  				if err = processLink(paths[i], lsLink); err != nil {
198  					return err
199  				}
200  			}
201  			if err = <-lsErr; err != nil {
202  				return err
203  			}
204  			dirDone(i)
205  		}
206  		return done()
207  	},
208  	PostRun: cmds.PostRunMap{
209  		cmds.CLI: func(res cmds.Response, re cmds.ResponseEmitter) error {
210  			req := res.Request()
211  			lastObjectHash := ""
212  
213  			for {
214  				v, err := res.Next()
215  				if err != nil {
216  					if err == io.EOF {
217  						return nil
218  					}
219  					return err
220  				}
221  				out := v.(*LsOutput)
222  				lastObjectHash = tabularOutput(req, os.Stdout, out, lastObjectHash, false)
223  			}
224  		},
225  	},
226  	Encoders: cmds.EncoderMap{
227  		cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *LsOutput) error {
228  			// when streaming over HTTP using a text encoder, we cannot render breaks
229  			// between directories because we don't know the hash of the last
230  			// directory encoder
231  			ignoreBreaks, _ := req.Options[lsStreamOptionName].(bool)
232  			tabularOutput(req, w, out, "", ignoreBreaks)
233  			return nil
234  		}),
235  	},
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
357  	if stream {
358  		minTabWidth = 10
359  	} else {
360  		minTabWidth = 1
361  	}
362  
363  	multipleFolders := len(req.Arguments) > 1
364  
365  	tw := tabwriter.NewWriter(w, minTabWidth, 2, 1, ' ', 0)
366  
367  	for _, object := range out.Objects {
368  
369  		if !ignoreBreaks && object.Hash != lastObjectHash {
370  			if multipleFolders {
371  				if lastObjectHash != "" {
372  					fmt.Fprintln(tw)
373  				}
374  				fmt.Fprintf(tw, "%s:\n", object.Hash)
375  			}
376  			if headers {
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  			}
395  			lastObjectHash = object.Hash
396  		}
397  
398  		for _, link := range object.Links {
399  			var s string
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 {
410  					mode = formatMode(link.Mode)
411  				}
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 {
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  			}
448  		}
449  	}
450  	tw.Flush()
451  	return lastObjectHash
452  }