diff --git a/changelog/unreleased/issue-3247 b/changelog/unreleased/issue-3247 new file mode 100644 index 000000000..9dcc37cd9 --- /dev/null +++ b/changelog/unreleased/issue-3247 @@ -0,0 +1,8 @@ +Change: Empty files now have size of 0 in restic ls --json output + +Restic ls --json used to omit the sizes of empty files in its output. It now +reports "size":0 explicitly for regular files, while omitting the size field +for all other types. + +https://github.com/restic/restic/issues/3247 +https://github.com/restic/restic/pull/3257 diff --git a/cmd/restic/cmd_ls.go b/cmd/restic/cmd_ls.go index 5ab1a4693..228e7696c 100644 --- a/cmd/restic/cmd_ls.go +++ b/cmd/restic/cmd_ls.go @@ -74,18 +74,42 @@ type lsSnapshot struct { StructType string `json:"struct_type"` // "snapshot" } -type lsNode struct { - Name string `json:"name"` - Type string `json:"type"` - Path string `json:"path"` - UID uint32 `json:"uid"` - GID uint32 `json:"gid"` - Size uint64 `json:"size,omitempty"` - Mode os.FileMode `json:"mode,omitempty"` - ModTime time.Time `json:"mtime,omitempty"` - AccessTime time.Time `json:"atime,omitempty"` - ChangeTime time.Time `json:"ctime,omitempty"` - StructType string `json:"struct_type"` // "node" +// Print node in our custom JSON format, followed by a newline. +func lsNodeJSON(enc *json.Encoder, path string, node *restic.Node) error { + n := &struct { + Name string `json:"name"` + Type string `json:"type"` + Path string `json:"path"` + UID uint32 `json:"uid"` + GID uint32 `json:"gid"` + Size *uint64 `json:"size,omitempty"` + Mode os.FileMode `json:"mode,omitempty"` + ModTime time.Time `json:"mtime,omitempty"` + AccessTime time.Time `json:"atime,omitempty"` + ChangeTime time.Time `json:"ctime,omitempty"` + StructType string `json:"struct_type"` // "node" + + size uint64 // Target for Size pointer. + }{ + Name: node.Name, + Type: node.Type, + Path: path, + UID: node.UID, + GID: node.GID, + size: node.Size, + Mode: node.Mode, + ModTime: node.ModTime, + AccessTime: node.AccessTime, + ChangeTime: node.ChangeTime, + StructType: "node", + } + // Always print size for regular files, even when empty, + // but never for other types. + if node.Type == "file" { + n.Size = &n.size + } + + return enc.Encode(n) } func runLs(opts LsOptions, gopts GlobalOptions, args []string) error { @@ -159,7 +183,7 @@ func runLs(opts LsOptions, gopts GlobalOptions, args []string) error { enc := json.NewEncoder(gopts.stdout) printSnapshot = func(sn *restic.Snapshot) { - err = enc.Encode(lsSnapshot{ + err := enc.Encode(lsSnapshot{ Snapshot: sn, ID: sn.ID(), ShortID: sn.ID().Str(), @@ -171,19 +195,7 @@ func runLs(opts LsOptions, gopts GlobalOptions, args []string) error { } printNode = func(path string, node *restic.Node) { - err = enc.Encode(lsNode{ - Name: node.Name, - Type: node.Type, - Path: path, - UID: node.UID, - GID: node.GID, - Size: node.Size, - Mode: node.Mode, - ModTime: node.ModTime, - AccessTime: node.AccessTime, - ChangeTime: node.ChangeTime, - StructType: "node", - }) + err := lsNodeJSON(enc, path, node) if err != nil { Warnf("JSON encode failed: %v\n", err) } diff --git a/cmd/restic/cmd_ls_test.go b/cmd/restic/cmd_ls_test.go new file mode 100644 index 000000000..bef749c76 --- /dev/null +++ b/cmd/restic/cmd_ls_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "testing" + "time" + + "github.com/restic/restic/internal/restic" + rtest "github.com/restic/restic/internal/test" +) + +func TestLsNodeJSON(t *testing.T) { + for _, c := range []struct { + path string + restic.Node + expect string + }{ + // Mode is omitted when zero. + { + path: "/bar/baz", + Node: restic.Node{ + Name: "baz", + Type: "file", + Size: 12345, + UID: 10000000, + GID: 20000000, + + User: "nobody", + Group: "nobodies", + Links: 1, + }, + expect: `{"name":"baz","type":"file","path":"/bar/baz","uid":10000000,"gid":20000000,"size":12345,"mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","struct_type":"node"}`, + }, + + // Even empty files get an explicit size. + { + path: "/foo/empty", + Node: restic.Node{ + Name: "empty", + Type: "file", + Size: 0, + UID: 1001, + GID: 1001, + + User: "not printed", + Group: "not printed", + Links: 0xF00, + }, + expect: `{"name":"empty","type":"file","path":"/foo/empty","uid":1001,"gid":1001,"size":0,"mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","struct_type":"node"}`, + }, + + // Non-regular files do not get a size. + // Mode is printed in decimal, including the type bits. + { + path: "/foo/link", + Node: restic.Node{ + Name: "link", + Type: "symlink", + Mode: os.ModeSymlink | 0777, + LinkTarget: "not printed", + }, + expect: `{"name":"link","type":"symlink","path":"/foo/link","uid":0,"gid":0,"mode":134218239,"mtime":"0001-01-01T00:00:00Z","atime":"0001-01-01T00:00:00Z","ctime":"0001-01-01T00:00:00Z","struct_type":"node"}`, + }, + + { + path: "/some/directory", + Node: restic.Node{ + Name: "directory", + Type: "dir", + Mode: os.ModeDir | 0755, + ModTime: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC), + AccessTime: time.Date(2021, 2, 3, 4, 5, 6, 7, time.UTC), + ChangeTime: time.Date(2022, 3, 4, 5, 6, 7, 8, time.UTC), + }, + expect: `{"name":"directory","type":"dir","path":"/some/directory","uid":0,"gid":0,"mode":2147484141,"mtime":"2020-01-02T03:04:05Z","atime":"2021-02-03T04:05:06.000000007Z","ctime":"2022-03-04T05:06:07.000000008Z","struct_type":"node"}`, + }, + } { + buf := new(bytes.Buffer) + enc := json.NewEncoder(buf) + err := lsNodeJSON(enc, c.path, &c.Node) + rtest.OK(t, err) + rtest.Equals(t, c.expect+"\n", buf.String()) + + // Sanity check: output must be valid JSON. + var v interface{} + err = json.NewDecoder(buf).Decode(&v) + rtest.OK(t, err) + } +}