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