Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

txtar: add fs.FS support #285

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions txtar/fs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// +build go1.16

package txtar

import (
"errors"
"io"
"io/fs"
"path"
"sort"
"strings"
"time"
)

var _ fs.FS = (*Archive)(nil)

// Open implements fs.FS.
func (a *Archive) Open(name string) (fs.File, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}

for _, f := range a.Files {
if f.Name == name {
return &openFile{f, 0}, nil
}
}
var list []fileInfo
var dirs = make(map[string]bool)
if name == "." {
for _, f := range a.Files {
i := strings.Index(f.Name, "/")
if i < 0 {
list = append(list, fileInfo{f, 0666})
} else {
dirs[f.Name[:i]] = true
}
}
} else {
prefix := name + "/"
for _, f := range a.Files {
if strings.HasPrefix(f.Name, prefix) {
felem := f.Name[len(prefix):]
i := strings.Index(felem, "/")
if i < 0 {
list = append(list, fileInfo{f, 0666})
} else {
dirs[f.Name[len(prefix):len(prefix)+i]] = true
}
}
}
// If there are no children of the name,
// then the directory is treated as not existing.
if list == nil && len(dirs) == 0 {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
}
for name := range dirs {
list = append(list, fileInfo{File{Name: name}, fs.ModeDir | 0666})
}
sort.Slice(list, func(i, j int) bool {
return list[i].File.Name < list[j].File.Name
})

return &openDir{name, fileInfo{File{Name: name}, fs.ModeDir | 0666}, list, 0}, nil
}

var _ fs.ReadFileFS = (*Archive)(nil)

// ReadFile implements fs.ReadFileFS.
func (a *Archive) ReadFile(name string) ([]byte, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
if name == "." {
return nil, &fs.PathError{Op: "read", Path: name, Err: errors.New("is a directory")}
}
prefix := name + "/"
for _, f := range a.Files {
if f.Name == name {
return f.Data, nil
}
// It's a directory
if strings.HasPrefix(f.Name, prefix) {
return nil, &fs.PathError{Op: "read", Path: name, Err: errors.New("is a directory")}
}
}
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}

var _ fs.File = (*openFile)(nil)

type openFile struct {
File
offset int64
}

func (o *openFile) Stat() (fs.FileInfo, error) { return fileInfo{o.File, 0666}, nil }

func (o *openFile) Close() error { return nil }

func (f *openFile) Read(b []byte) (int, error) {
if f.offset >= int64(len(f.File.Data)) {
return 0, io.EOF
}
if f.offset < 0 {
return 0, &fs.PathError{Op: "read", Path: f.File.Name, Err: fs.ErrInvalid}
}
n := copy(b, f.File.Data[f.offset:])
f.offset += int64(n)
return n, nil
}

func (f *openFile) Seek(offset int64, whence int) (int64, error) {
switch whence {
case 0:
// offset += 0
case 1:
offset += f.offset
case 2:
offset += int64(len(f.File.Data))
}
if offset < 0 || offset > int64(len(f.File.Data)) {
return 0, &fs.PathError{Op: "seek", Path: f.File.Name, Err: fs.ErrInvalid}
}
f.offset = offset
return offset, nil
}

func (f *openFile) ReadAt(b []byte, offset int64) (int, error) {
if offset < 0 || offset > int64(len(f.File.Data)) {
return 0, &fs.PathError{Op: "read", Path: f.File.Name, Err: fs.ErrInvalid}
}
n := copy(b, f.File.Data[offset:])
if n < len(b) {
return n, io.EOF
}
return n, nil
}

var _ fs.FileInfo = fileInfo{}

type fileInfo struct {
File
m fs.FileMode
}

func (f fileInfo) Name() string { return path.Base(f.File.Name) }
func (f fileInfo) Size() int64 { return int64(len(f.File.Data)) }
func (f fileInfo) Mode() fs.FileMode { return f.m }
func (f fileInfo) Type() fs.FileMode { return f.m.Type() }
func (f fileInfo) ModTime() time.Time { return time.Time{} }
func (f fileInfo) IsDir() bool { return f.m.IsDir() }
func (f fileInfo) Sys() interface{} { return f.File }
func (f fileInfo) Info() (fs.FileInfo, error) { return f, nil }

type openDir struct {
path string
fileInfo
entry []fileInfo
offset int
}

func (d *openDir) Stat() (fs.FileInfo, error) { return &d.fileInfo, nil }
func (d *openDir) Close() error { return nil }
func (d *openDir) Read(b []byte) (int, error) {
return 0, &fs.PathError{Op: "read", Path: d.path, Err: errors.New("is a directory")}
}

func (d *openDir) ReadDir(count int) ([]fs.DirEntry, error) {
n := len(d.entry) - d.offset
if count > 0 && n > count {
n = count
}
if n == 0 && count > 0 {
return nil, io.EOF
}
list := make([]fs.DirEntry, n)
for i := range list {
list[i] = &d.entry[d.offset+i]
}
d.offset += n
return list, nil
}

// From constructs an Archive with the contents of fsys and an empty Comment.
// Subsequent changes to fsys are not reflected in the returned archive.
//
// The transformation is lossy.
// For example, because directories are implicit in txtar archives,
// empty directories in fsys will be lost, and txtar does not represent file mode, mtime, or other file metadata.
// From does not guarantee that a.File[i].Data contain no file marker lines.
// See also warnings on Format.
// In short, it is unwise to use txtar as a generic filesystem serialization mechanism.
func From(fsys fs.FS) (*Archive, error) {
ar := new(Archive)
walkfn := func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
// Directories in txtar are implicit.
return nil
}
data, err := fs.ReadFile(fsys, path)
if err != nil {
return err
}
ar.Files = append(ar.Files, File{Name: path, Data: data})
return nil
}

if err := fs.WalkDir(fsys, ".", walkfn); err != nil {
return nil, err
}
return ar, nil
}
93 changes: 93 additions & 0 deletions txtar/fs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package txtar

import (
"io/fs"
"sort"
"strings"
"testing"
"testing/fstest"
)

func TestFS(t *testing.T) {
for _, tc := range []struct{ name, input, files string }{
{
name: "empty",
input: ``,
files: "",
},
{
name: "one",
input: `
-- one.txt --
one
`,
files: "one.txt",
},
{
name: "two",
input: `
-- one.txt --
one
-- two.txt --
two
`,
files: "one.txt two.txt",
},
{
name: "subdirectories",
input: `
-- one.txt --
one
-- 2/two.txt --
two
-- 2/3/three.txt --
three
-- 4/four.txt --
three
`,
files: "one.txt 2/two.txt 2/3/three.txt 4/four.txt",
},
} {
t.Run(tc.name, func(t *testing.T) {
a := Parse([]byte(tc.input))
files := strings.Fields(tc.files)
if err := fstest.TestFS(a, files...); err != nil {
t.Fatal(err)
}
for _, name := range files {
for _, f := range a.Files {
if f.Name == name {
b, err := fs.ReadFile(a, name)
if err != nil {
t.Fatal(err)
}
if string(b) != string(f.Data) {
t.Fatalf("mismatched contents for %q", name)
}
}
}
}
a2, err := From(a)
if err != nil {
t.Fatalf("failed to write fsys for %v: %v", tc.name, err)
}

if in, out := normalized(a), normalized(a2); in != out {
t.Errorf("From round trip failed: %q != %q", in, out)
}

})
}
}

func normalized(a *Archive) string {
a.Comment = nil
sort.Slice(a.Files, func(i, j int) bool {
return a.Files[i].Name < a.Files[j].Name
})
return string(Format(a))
}