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 #289

Closed
wants to merge 17 commits into from
70 changes: 70 additions & 0 deletions txtar/fs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright 2023 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 (
"fmt"
"io/fs"
"testing/fstest"
"time"
)

// FS returns the file system form of an Archive.
// It returns an error if any of the file names in the archive
// are not valid file system names.
// It also builds an index of the files and their content:
// adding, removing, or renaming files in the archive
// after calling FS will not affect file system method calls.
// However, FS does not copy the underlying file contents:
// change to file content will be visible in file system method calls.
func FS(a *Archive) (fs.FS, error) {
m := make(fstest.MapFS, len(a.Files))
for _, f := range a.Files {
if !fs.ValidPath(f.Name) {
return nil, fmt.Errorf("txtar.FS: Archive contains invalid fs.FS path: %q", f.Name)
}
m[f.Name] = &fstest.MapFile{
Data: f.Data,
Mode: 0o666,
ModTime: time.Time{},
Sys: f,
}
}
return m, 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 contains 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
}
138 changes: 138 additions & 0 deletions txtar/fs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright 2023 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"
"path"
"sort"
"strings"
"testing"
"testing/fstest"
)

func TestFS(t *testing.T) {
var fstestcases = []struct {
name, input, files string
invalidNames bool
}{
{
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",
},
{
name: "unclean file names",
input: `
-- 1/../one.txt --
one
-- 2/sub/../two.txt --
two
`,
invalidNames: true,
},
{
name: "overlapping names",
input: `
-- 1/../one.txt --
one
-- 2/../one.txt --
two
`,
files: "one.txt",
invalidNames: true,
},
{
name: "invalid name",
input: `
-- ../one.txt --
one
`,
invalidNames: true,
},
}

for _, tc := range fstestcases {
t.Run(tc.name, func(t *testing.T) {
files := strings.Fields(tc.files)
a := Parse([]byte(tc.input))
fsys, err := FS(a)
if tc.invalidNames {
if err == nil {
t.Fatal("expected error: got nil")
}
return
}
if err != nil {
t.Fatal(err)
}
if err := fstest.TestFS(fsys, files...); err != nil {
t.Fatal(err)
}

for _, f := range a.Files {
b, err := fs.ReadFile(fsys, f.Name)
if err != nil {
t.Fatalf("could not read %s from fsys: %v", f.Name, err)
}
if string(b) != string(f.Data) {
t.Fatalf("mismatched contents for %q", f.Name)
}
}
a2, err := From(fsys)
if err != nil {
t.Fatalf("error building Archive from fsys: %v", err)
}

if in, out := normalized(a), normalized(a2); in != out {
t.Error("did not round trip")
}
})
}
}

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