Commit eac61bbc authored by Han-Wen Nienhuys's avatar Han-Wen Nienhuys

zipfs: rewrite using new nodefs API.

Remove memtree.
parent dddd77b8
...@@ -60,3 +60,5 @@ To do/To decide ...@@ -60,3 +60,5 @@ To do/To decide
* decide on a final package name * decide on a final package name
* handle less open/create. * handle less open/create.
* Symlink []byte vs string.
// Copyright 2016 the Go-FUSE 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 zipfs
import (
"fmt"
"strings"
"github.com/hanwen/go-fuse/fuse"
"github.com/hanwen/go-fuse/fuse/nodefs"
)
type MemFile interface {
Stat(out *fuse.Attr)
Data() []byte
}
type memNode struct {
nodefs.Node
file MemFile
fs *MemTreeFs
}
// memTreeFs creates a tree of internal Inodes. Since the tree is
// loaded in memory completely at startup, it does not need inode
// discovery through Lookup() at serve time.
type MemTreeFs struct {
root *memNode
files map[string]MemFile
Name string
}
func NewMemTreeFs(files map[string]MemFile) *MemTreeFs {
fs := &MemTreeFs{
root: &memNode{Node: nodefs.NewDefaultNode()},
files: files,
}
fs.root.fs = fs
return fs
}
func (fs *MemTreeFs) String() string {
return fs.Name
}
func (fs *MemTreeFs) Root() nodefs.Node {
return fs.root
}
func (fs *MemTreeFs) onMount() {
for k, v := range fs.files {
fs.addFile(k, v)
}
fs.files = nil
}
func (n *memNode) OnMount(c *nodefs.FileSystemConnector) {
n.fs.onMount()
}
func (n *memNode) Print(indent int) {
s := ""
for i := 0; i < indent; i++ {
s = s + " "
}
children := n.Inode().Children()
for k, v := range children {
if v.IsDir() {
fmt.Println(s + k + ":")
mn, ok := v.Node().(*memNode)
if ok {
mn.Print(indent + 2)
}
} else {
fmt.Println(s + k)
}
}
}
func (n *memNode) OpenDir(context *fuse.Context) (stream []fuse.DirEntry, code fuse.Status) {
children := n.Inode().Children()
stream = make([]fuse.DirEntry, 0, len(children))
for k, v := range children {
mode := fuse.S_IFREG | 0666
if v.IsDir() {
mode = fuse.S_IFDIR | 0777
}
stream = append(stream, fuse.DirEntry{
Name: k,
Mode: uint32(mode),
})
}
return stream, fuse.OK
}
func (n *memNode) Open(flags uint32, context *fuse.Context) (fuseFile nodefs.File, code fuse.Status) {
if flags&fuse.O_ANYWRITE != 0 {
return nil, fuse.EPERM
}
return nodefs.NewDataFile(n.file.Data()), fuse.OK
}
func (n *memNode) Deletable() bool {
return false
}
func (n *memNode) GetAttr(out *fuse.Attr, file nodefs.File, context *fuse.Context) fuse.Status {
if n.Inode().IsDir() {
out.Mode = fuse.S_IFDIR | 0777
return fuse.OK
}
n.file.Stat(out)
out.Blocks = (out.Size + 511) / 512
return fuse.OK
}
func (n *MemTreeFs) addFile(name string, f MemFile) {
comps := strings.Split(name, "/")
node := n.root.Inode()
for i, c := range comps {
child := node.GetChild(c)
if child == nil {
fsnode := &memNode{
Node: nodefs.NewDefaultNode(),
fs: n,
}
if i == len(comps)-1 {
fsnode.file = f
}
child = node.NewChild(c, fsnode.file == nil, fsnode)
}
node = child
}
}
...@@ -15,191 +15,69 @@ symlinking path/to/zipfile to /config/zipmount ...@@ -15,191 +15,69 @@ symlinking path/to/zipfile to /config/zipmount
*/ */
import ( import (
"context"
"log" "log"
"path/filepath" "syscall"
"sync"
"github.com/hanwen/go-fuse/fuse" "github.com/hanwen/go-fuse/fuse"
"github.com/hanwen/go-fuse/fuse/nodefs" "github.com/hanwen/go-fuse/nodefs"
"github.com/hanwen/go-fuse/fuse/pathfs"
) )
const ( // MultiZipFs is a filesystem that mounts zipfiles.
CONFIG_PREFIX = "config/"
)
////////////////////////////////////////////////////////////////
// MultiZipFs is a path filesystem that mounts zipfiles.
type MultiZipFs struct { type MultiZipFs struct {
lock sync.RWMutex nodefs.Inode
zips map[string]nodefs.Node
dirZipFileMap map[string]string
// zip files that we are in the process of unmounting.
zombie map[string]bool
nodeFs *pathfs.PathNodeFs
pathfs.FileSystem
} }
func NewMultiZipFs() *MultiZipFs { func (fs *MultiZipFs) OnAdd(ctx context.Context) {
m := &MultiZipFs{ n := fs.NewPersistentInode(ctx, &configRoot{}, nodefs.NodeAttr{Mode: syscall.S_IFDIR})
zips: make(map[string]nodefs.Node),
zombie: make(map[string]bool),
dirZipFileMap: make(map[string]string),
FileSystem: pathfs.NewDefaultFileSystem(),
}
return m
}
func (fs *MultiZipFs) String() string { fs.AddChild("config", n, false)
return "MultiZipFs"
} }
func (fs *MultiZipFs) OnMount(nodeFs *pathfs.PathNodeFs) { type configRoot struct {
fs.nodeFs = nodeFs nodefs.Inode
} }
func (fs *MultiZipFs) OpenDir(name string, context *fuse.Context) (stream []fuse.DirEntry, code fuse.Status) { var _ = (nodefs.Unlinker)((*configRoot)(nil))
fs.lock.RLock() var _ = (nodefs.Symlinker)((*configRoot)(nil))
defer fs.lock.RUnlock()
stream = make([]fuse.DirEntry, 0, len(fs.zips)+2) func (r *configRoot) Unlink(ctx context.Context, basename string) syscall.Errno {
if name == "" { if r.GetChild(basename) == nil {
var d fuse.DirEntry return syscall.ENOENT
d.Name = "config"
d.Mode = fuse.S_IFDIR | 0700
stream = append(stream, fuse.DirEntry(d))
} }
if name == "config" { // XXX RmChild should return Inode?
for k := range fs.zips {
var d fuse.DirEntry
d.Name = k
d.Mode = fuse.S_IFLNK
stream = append(stream, fuse.DirEntry(d))
}
}
return stream, fuse.OK _, parent := r.Parent()
} ch := parent.GetChild(basename)
if ch == nil {
func (fs *MultiZipFs) GetAttr(name string, context *fuse.Context) (*fuse.Attr, fuse.Status) { return syscall.ENOENT
a := &fuse.Attr{}
a.Owner = *fuse.CurrentOwner()
if name == "" {
// Should not write in top dir.
a.Mode = fuse.S_IFDIR | 0500
return a, fuse.OK
}
if name == "config" {
a.Mode = fuse.S_IFDIR | 0700
return a, fuse.OK
}
dir, base := filepath.Split(name)
if dir != "" && dir != CONFIG_PREFIX {
return nil, fuse.ENOENT
}
submode := uint32(fuse.S_IFDIR | 0700)
if dir == CONFIG_PREFIX {
submode = fuse.S_IFLNK | 0600
} }
success, _ := parent.RmChild(basename)
fs.lock.RLock() if !success {
defer fs.lock.RUnlock() return syscall.EIO
a.Mode = submode
_, hasDir := fs.zips[base]
if hasDir {
return a, fuse.OK
} }
return nil, fuse.ENOENT ch.RmAllChildren()
parent.RmChild(basename)
parent.NotifyEntry(basename)
return 0
} }
func (fs *MultiZipFs) Unlink(name string, context *fuse.Context) (code fuse.Status) { func (r *configRoot) Symlink(ctx context.Context, target string, base string, out *fuse.EntryOut) (*nodefs.Inode, syscall.Errno) {
dir, basename := filepath.Split(name) root, err := NewArchiveFileSystem(target)
if dir == CONFIG_PREFIX {
fs.lock.Lock()
defer fs.lock.Unlock()
if fs.zombie[basename] {
return fuse.ENOENT
}
root, ok := fs.zips[basename]
if !ok {
return fuse.ENOENT
}
name := fs.dirZipFileMap[basename]
fs.zombie[basename] = true
delete(fs.zips, basename)
delete(fs.dirZipFileMap, basename)
// Drop the lock to ensure that notify doesn't cause a deadlock.
fs.lock.Unlock()
code = fs.nodeFs.UnmountNode(root.Inode())
fs.lock.Lock()
delete(fs.zombie, basename)
if !code.Ok() {
// Failed: reinstate
fs.zips[basename] = root
fs.dirZipFileMap[basename] = name
}
return code
}
return fuse.EPERM
}
func (fs *MultiZipFs) Readlink(path string, context *fuse.Context) (val string, code fuse.Status) {
dir, base := filepath.Split(path)
if dir != CONFIG_PREFIX {
return "", fuse.ENOENT
}
fs.lock.Lock()
defer fs.lock.Unlock()
if fs.zombie[base] {
return "", fuse.ENOENT
}
zipfile, ok := fs.dirZipFileMap[base]
if !ok {
return "", fuse.ENOENT
}
return zipfile, fuse.OK
}
func (fs *MultiZipFs) Symlink(value string, linkName string, context *fuse.Context) (code fuse.Status) {
dir, base := filepath.Split(linkName)
if dir != CONFIG_PREFIX {
return fuse.EPERM
}
fs.lock.Lock()
defer fs.lock.Unlock()
if fs.zombie[base] {
return fuse.EBUSY
}
_, ok := fs.dirZipFileMap[base]
if ok {
return fuse.EBUSY
}
root, err := NewArchiveFileSystem(value)
if err != nil { if err != nil {
log.Println("NewZipArchiveFileSystem failed.", err) log.Println("NewZipArchiveFileSystem failed.", err)
return fuse.EINVAL return nil, syscall.EINVAL
} }
code = fs.nodeFs.Mount(base, root, nil) _, parent := r.Parent()
if !code.Ok() { ch := r.NewPersistentInode(ctx, root, nodefs.NodeAttr{Mode: syscall.S_IFDIR})
return code parent.AddChild(base, ch, false)
}
fs.dirZipFileMap[base] = value link := r.NewPersistentInode(ctx, &nodefs.MemSymlink{
fs.zips[base] = root Data: []byte(target),
return fuse.OK }, nodefs.NodeAttr{Mode: syscall.S_IFLNK})
r.AddChild(base, link, false)
return link, 0
} }
...@@ -11,31 +11,29 @@ import ( ...@@ -11,31 +11,29 @@ import (
"time" "time"
"github.com/hanwen/go-fuse/fuse" "github.com/hanwen/go-fuse/fuse"
"github.com/hanwen/go-fuse/fuse/nodefs"
"github.com/hanwen/go-fuse/fuse/pathfs"
"github.com/hanwen/go-fuse/internal/testutil" "github.com/hanwen/go-fuse/internal/testutil"
"github.com/hanwen/go-fuse/nodefs"
) )
const testTtl = 100 * time.Millisecond const testTtl = 100 * time.Millisecond
func setupMzfs(t *testing.T) (mountPoint string, state *fuse.Server, cleanup func()) { func setupMzfs(t *testing.T) (mountPoint string, state *fuse.Server, cleanup func()) {
fs := NewMultiZipFs() root := &MultiZipFs{}
mountPoint = testutil.TempDir() mountPoint = testutil.TempDir()
nfs := pathfs.NewPathNodeFs(fs, nil)
state, _, err := nodefs.MountRoot(mountPoint, nfs.Root(), &nodefs.Options{ dt := testTtl
EntryTimeout: testTtl, opts := &nodefs.Options{
AttrTimeout: testTtl, EntryTimeout: &dt,
NegativeTimeout: 0.0, AttrTimeout: &dt,
Debug: testutil.VerboseTest(), }
}) opts.Debug = testutil.VerboseTest()
server, err := nodefs.Mount(mountPoint, root, opts)
if err != nil { if err != nil {
t.Fatalf("MountNodeFileSystem failed: %v", err) t.Fatalf("MountNodeFileSystem failed: %v", err)
} }
go state.Serve() return mountPoint, server, func() {
state.WaitMount() server.Unmount()
return mountPoint, state, func() {
state.Unmount()
os.RemoveAll(mountPoint) os.RemoveAll(mountPoint)
} }
} }
......
...@@ -9,11 +9,15 @@ import ( ...@@ -9,11 +9,15 @@ import (
"bytes" "bytes"
"compress/bzip2" "compress/bzip2"
"compress/gzip" "compress/gzip"
"github.com/hanwen/go-fuse/fuse" "context"
"io" "io"
"os" "os"
"path/filepath"
"strings" "strings"
"syscall" "syscall"
"github.com/hanwen/go-fuse/fuse"
"github.com/hanwen/go-fuse/nodefs"
) )
// TODO - handle symlinks. // TODO - handle symlinks.
...@@ -40,9 +44,14 @@ func (f *TarFile) Data() []byte { ...@@ -40,9 +44,14 @@ func (f *TarFile) Data() []byte {
return f.data return f.data
} }
func NewTarTree(r io.Reader) map[string]MemFile { type tarRoot struct {
files := map[string]MemFile{} nodefs.Inode
tr := tar.NewReader(r) rc io.ReadCloser
}
func (r *tarRoot) OnAdd(ctx context.Context) {
tr := tar.NewReader(r.rc)
defer r.rc.Close()
var longName *string var longName *string
for { for {
...@@ -74,35 +83,64 @@ func NewTarTree(r io.Reader) map[string]MemFile { ...@@ -74,35 +83,64 @@ func NewTarTree(r io.Reader) map[string]MemFile {
buf := bytes.NewBuffer(make([]byte, 0, hdr.Size)) buf := bytes.NewBuffer(make([]byte, 0, hdr.Size))
io.Copy(buf, tr) io.Copy(buf, tr)
df := &nodefs.MemRegularFile{
files[hdr.Name] = &TarFile{ Data: buf.Bytes(),
Header: *hdr, }
data: buf.Bytes(), dir, base := filepath.Split(filepath.Clean(hdr.Name))
p := r.EmbeddedInode()
for _, comp := range strings.Split(dir, "/") {
if len(comp) == 0 {
continue
}
ch := p.GetChild(comp)
if ch == nil {
p.AddChild(comp, p.NewPersistentInode(ctx,
&nodefs.Inode{},
nodefs.NodeAttr{Mode: syscall.S_IFDIR}), false)
}
p = ch
} }
HeaderToFileInfo(&df.Attr, hdr)
p.AddChild(base, r.NewPersistentInode(ctx, df, nodefs.NodeAttr{}), false)
} }
return files
} }
func NewTarCompressedTree(name string, format string) (map[string]MemFile, error) { type readCloser struct {
io.Reader
close func() error
}
func (rc *readCloser) Close() error {
return rc.close()
}
func NewTarCompressedTree(name string, format string) (nodefs.InodeEmbedder, error) {
f, err := os.Open(name) f, err := os.Open(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer f.Close() defer f.Close()
var stream io.Reader var stream io.ReadCloser
switch format { switch format {
case "gz": case "gz":
unzip, err := gzip.NewReader(f) unzip, err := gzip.NewReader(f)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer unzip.Close() stream = &readCloser{
stream = unzip unzip,
f.Close,
}
case "bz2": case "bz2":
unzip := bzip2.NewReader(f) unzip := bzip2.NewReader(f)
stream = unzip stream = &readCloser{
unzip,
f.Close,
}
} }
return NewTarTree(stream), nil return &tarRoot{rc: stream}, nil
} }
...@@ -7,14 +7,18 @@ package zipfs ...@@ -7,14 +7,18 @@ package zipfs
import ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"syscall"
"github.com/hanwen/go-fuse/fuse" "github.com/hanwen/go-fuse/fuse"
"github.com/hanwen/go-fuse/fuse/nodefs" "github.com/hanwen/go-fuse/nodefs"
) )
type ZipFile struct { type ZipFile struct {
...@@ -22,11 +26,6 @@ type ZipFile struct { ...@@ -22,11 +26,6 @@ type ZipFile struct {
} }
func (f *ZipFile) Stat(out *fuse.Attr) { func (f *ZipFile) Stat(out *fuse.Attr) {
out.Mode = fuse.S_IFREG | uint32(f.File.Mode())
out.Size = uint64(f.File.UncompressedSize)
out.Mtime = uint64(f.File.ModTime().Unix())
out.Atime = out.Mtime
out.Ctime = out.Mtime
} }
func (f *ZipFile) Data() []byte { func (f *ZipFile) Data() []byte {
...@@ -44,41 +43,123 @@ func (f *ZipFile) Data() []byte { ...@@ -44,41 +43,123 @@ func (f *ZipFile) Data() []byte {
return dest.Bytes() return dest.Bytes()
} }
type zipRoot struct {
nodefs.Inode
zr *zip.ReadCloser
}
var _ = (nodefs.OnAdder)((*zipRoot)(nil))
func (zr *zipRoot) OnAdd(ctx context.Context) {
for _, f := range zr.zr.File {
if f.FileInfo().IsDir() {
continue
}
dir, base := filepath.Split(filepath.Clean(f.Name))
p := &zr.Inode
for _, component := range strings.Split(dir, "/") {
if len(component) == 0 {
continue
}
ch := p.GetChild(component)
if ch == nil {
ch = p.NewPersistentInode(ctx, &nodefs.Inode{},
nodefs.NodeAttr{Mode: fuse.S_IFDIR})
p.AddChild(component, ch, true)
}
p = ch
}
ch := p.NewPersistentInode(ctx, &zipFile{file: f}, nodefs.NodeAttr{})
p.AddChild(base, ch, true)
}
}
// NewZipTree creates a new file-system for the zip file named name. // NewZipTree creates a new file-system for the zip file named name.
func NewZipTree(name string) (map[string]MemFile, error) { func NewZipTree(name string) (nodefs.InodeEmbedder, error) {
r, err := zip.OpenReader(name) r, err := zip.OpenReader(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := map[string]MemFile{} return &zipRoot{zr: r}, nil
for _, f := range r.File { }
if strings.HasSuffix(f.Name, "/") {
continue // zipFile is a file read from a zip archive.
type zipFile struct {
nodefs.Inode
file *zip.File
mu sync.Mutex
data []byte
}
var _ = (nodefs.Opener)((*zipFile)(nil))
var _ = (nodefs.Getattrer)((*zipFile)(nil))
// Getattr sets the minimum, which is the size. A more full-featured
// FS would also set timestamps and permissions.
func (zf *zipFile) Getattr(ctx context.Context, f nodefs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Mode = uint32(zf.file.Mode()) & 07777
out.Nlink = 1
out.Mtime = uint64(zf.file.ModTime().Unix())
out.Atime = out.Mtime
out.Ctime = out.Mtime
out.Size = zf.file.UncompressedSize64
out.Blocks = (out.Size + 511) / 512
return 0
}
// Open lazily unpacks zip data
func (zf *zipFile) Open(ctx context.Context, flags uint32) (nodefs.FileHandle, uint32, syscall.Errno) {
zf.mu.Lock()
defer zf.mu.Unlock()
if zf.data == nil {
rc, err := zf.file.Open()
if err != nil {
return nil, 0, syscall.EIO
}
content, err := ioutil.ReadAll(rc)
if err != nil {
return nil, 0, syscall.EIO
} }
n := filepath.Clean(f.Name)
zf := &ZipFile{f} zf.data = content
out[n] = zf }
// We don't return a filehandle since we don't really need
// one. The file content is immutable, so hint the kernel to
// cache the data.
return nil, fuse.FOPEN_KEEP_CACHE, 0
}
// Read simply returns the data that was already unpacked in the Open call
func (zf *zipFile) Read(ctx context.Context, f nodefs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
end := int(off) + len(dest)
if end > len(zf.data) {
end = len(zf.data)
} }
return out, nil return fuse.ReadResultData(zf.data[off:end]), 0
} }
func NewArchiveFileSystem(name string) (root nodefs.Node, err error) { var _ = (nodefs.OnAdder)((*zipRoot)(nil))
var files map[string]MemFile
func NewArchiveFileSystem(name string) (root nodefs.InodeEmbedder, err error) {
switch { switch {
case strings.HasSuffix(name, ".zip"): case strings.HasSuffix(name, ".zip"):
files, err = NewZipTree(name) root, err = NewZipTree(name)
case strings.HasSuffix(name, ".tar.gz"): case strings.HasSuffix(name, ".tar.gz"):
files, err = NewTarCompressedTree(name, "gz") root, err = NewTarCompressedTree(name, "gz")
case strings.HasSuffix(name, ".tar.bz2"): case strings.HasSuffix(name, ".tar.bz2"):
files, err = NewTarCompressedTree(name, "bz2") root, err = NewTarCompressedTree(name, "bz2")
case strings.HasSuffix(name, ".tar"): case strings.HasSuffix(name, ".tar"):
f, err := os.Open(name) f, err := os.Open(name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
files = NewTarTree(f) root = &tarRoot{rc: f}
default: default:
return nil, fmt.Errorf("unknown archive format %q", name) return nil, fmt.Errorf("unknown archive format %q", name)
} }
...@@ -87,7 +168,5 @@ func NewArchiveFileSystem(name string) (root nodefs.Node, err error) { ...@@ -87,7 +168,5 @@ func NewArchiveFileSystem(name string) (root nodefs.Node, err error) {
return nil, err return nil, err
} }
mfs := NewMemTreeFs(files) return root, nil
mfs.Name = fmt.Sprintf("fs(%s)", name)
return mfs.Root(), nil
} }
...@@ -14,8 +14,8 @@ import ( ...@@ -14,8 +14,8 @@ import (
"time" "time"
"github.com/hanwen/go-fuse/fuse" "github.com/hanwen/go-fuse/fuse"
"github.com/hanwen/go-fuse/fuse/nodefs"
"github.com/hanwen/go-fuse/internal/testutil" "github.com/hanwen/go-fuse/internal/testutil"
"github.com/hanwen/go-fuse/nodefs"
) )
func testZipFile() string { func testZipFile() string {
...@@ -34,15 +34,12 @@ func setupZipfs(t *testing.T) (mountPoint string, cleanup func()) { ...@@ -34,15 +34,12 @@ func setupZipfs(t *testing.T) (mountPoint string, cleanup func()) {
} }
mountPoint = testutil.TempDir() mountPoint = testutil.TempDir()
state, _, err := nodefs.MountRoot(mountPoint, root, &nodefs.Options{ opts := &nodefs.Options{}
Debug: testutil.VerboseTest(), opts.Debug = testutil.VerboseTest()
}) server, err := nodefs.Mount(mountPoint, root, opts)
go state.Serve()
state.WaitMount()
return mountPoint, func() { return mountPoint, func() {
state.Unmount() server.Unmount()
os.RemoveAll(mountPoint) os.RemoveAll(mountPoint)
} }
} }
...@@ -70,19 +67,17 @@ func TestZipFs(t *testing.T) { ...@@ -70,19 +67,17 @@ func TestZipFs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Stat failed: %v", err) t.Fatalf("Stat failed: %v", err)
} }
if fi.Mode() != 0664 { if got, want := fi.Mode(), 0664; int(got) != want {
t.Fatalf("File mode 0%o != 0664", fi.Mode()) t.Fatalf("File mode: got 0%o want 0%o", got, want)
} }
if st := fi.Sys().(*syscall.Stat_t); st.Blocks != 1 { if st := fi.Sys().(*syscall.Stat_t); st.Blocks != 1 {
t.Errorf("got block count %d, want 1", st.Blocks) t.Errorf("got block count %d, want 1", st.Blocks)
} }
mtime, err := time.Parse(time.RFC3339, "2011-02-22T12:56:12Z") if want, err := time.Parse(time.RFC3339, "2011-02-22T12:56:12Z"); err != nil {
if err != nil {
panic(err) panic(err)
} } else if !fi.ModTime().Equal(want) {
if !fi.ModTime().Equal(mtime) { t.Fatalf("File mtime got %v, want %v", fi.ModTime(), want)
t.Fatalf("File mtime %v != %v", fi.ModTime(), mtime)
} }
if fi.IsDir() { if fi.IsDir() {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment