mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-29 20:27:02 +00:00
* mount: stop a replaced rename destination from flushing over the rename
Rename replaces whatever the destination held, which deletes that entry, but
only the source handle was told. A handle still open on the replaced entry
went on flushing its metadata under that name, and on Windows -- where the
close carrying the flush runs after the application's CloseHandle has already
returned -- the flush landed after the rename and put the destination's old
content back:
dir Rename old_entry:{name:"src"} new_entry:{name:"dst" ... inode:...3416}
doFlush /dst fh 1521468582993181449
/dst saveToStorage 1,6872462993 [0,3)
flushMetadataToFiler /dst inode 11939747521756968515
InsertEntry /dst
The next read of the destination returned the content the rename was supposed
to replace. Unlink already handles this with markHandleDeleted, which raises
the flag under the handle's flush lock so a flush already writing finishes
first and any later one sees it; a rename that replaces an entry deletes it
just the same, so it now does likewise.
Verified on the Windows runner: TestRenameOverExisting 300/300, where the same
loop reproduced the corruption twice without this.
* test/winfsp: say which layer kept a renamed-away name
The failure only reported the stat. Which layer answered narrows the search a
lot: a listing reads no per-path cache, the mount's own forgets within a
second, and a name that survives both is still in the meta cache.
* mount: keep the destination barrier honest when the rename does not happen
Two gaps in the barrier the previous commit put in front of a replaced rename
destination:
The flag was raised before the filer rename, which can still fail. The
destination then stays exactly where it was, with its handle marked deleted
and its dirty metadata silently dropped from then on, so a rename that
returned an error has to put the flag back.
The handle was only found through the path mapping, which Forget drops while
the handle is still open. The source side already falls back to the inode the
entry carries; the destination now does the same, off the entry the sticky-bit
check had already loaded.
* mount: let only the caller that raised a delete mark lift it
Restoring the destination handle after a failed rename cleared isDeleted
outright, so an unlink that marked the same handle in between lost its mark and
a later flush could write the unlinked entry back.
Every raise of the flag already happens under the handle's flush lock, so
counting them there is enough to tell one caller's mark from another's: the
rename lifts only the mark it made itself.
* mount: drain the destination flush before marking it deleted
A flush already queued for the destination belongs to the entry as it stands.
Marking first meant the drain waited on a flush that then skipped its metadata
as deleted and released its handle, so a rename that failed afterwards had
nothing left to restore and the queued update was gone, its chunks orphaned.
Draining first lets that flush finish as itself, before the rename has taken
anything away.
550 lines
15 KiB
Go
550 lines
15 KiB
Go
package winfsp
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTruncate(t *testing.T) {
|
|
dir := testRoot(t)
|
|
path := filepath.Join(dir, "resize")
|
|
original := bytes.Repeat([]byte("abcd"), 4096) // 16 KiB
|
|
|
|
if err := os.WriteFile(path, original, 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
// Shrink.
|
|
if err := os.Truncate(path, 100); err != nil {
|
|
t.Fatalf("truncate down: %v", err)
|
|
}
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read after shrink: %v", err)
|
|
}
|
|
if len(got) != 100 {
|
|
t.Fatalf("size after shrink = %d, want 100", len(got))
|
|
}
|
|
if !bytes.Equal(got, original[:100]) {
|
|
t.Fatal("the surviving prefix does not match the original")
|
|
}
|
|
|
|
// Grow: the new space has to read back as zeros, not stale bytes.
|
|
if err := os.Truncate(path, 8192); err != nil {
|
|
t.Fatalf("truncate up: %v", err)
|
|
}
|
|
got, err = os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read after grow: %v", err)
|
|
}
|
|
if len(got) != 8192 {
|
|
t.Fatalf("size after grow = %d, want 8192", len(got))
|
|
}
|
|
if !bytes.Equal(got[100:], make([]byte, 8192-100)) {
|
|
t.Fatal("extension is not zero-filled")
|
|
}
|
|
|
|
// Truncate to empty.
|
|
if err := os.Truncate(path, 0); err != nil {
|
|
t.Fatalf("truncate to zero: %v", err)
|
|
}
|
|
if info, err := os.Stat(path); err != nil || info.Size() != 0 {
|
|
t.Fatalf("stat after zero truncate: size=%v err=%v", info, err)
|
|
}
|
|
}
|
|
|
|
func TestAppend(t *testing.T) {
|
|
dir := testRoot(t)
|
|
path := filepath.Join(dir, "log")
|
|
|
|
for i := 0; i < 5; i++ {
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
|
if err != nil {
|
|
t.Fatalf("open for append: %v", err)
|
|
}
|
|
if _, err := fmt.Fprintf(f, "line %d\n", i); err != nil {
|
|
t.Fatalf("append: %v", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
}
|
|
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
want := "line 0\nline 1\nline 2\nline 3\nline 4\n"
|
|
if string(got) != want {
|
|
t.Fatalf("appended content = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestReadPastEndOfFile(t *testing.T) {
|
|
dir := testRoot(t)
|
|
path := filepath.Join(dir, "short")
|
|
if err := os.WriteFile(path, []byte("12345"), 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
buf := make([]byte, 16)
|
|
n, err := f.ReadAt(buf, 100)
|
|
if n != 0 {
|
|
t.Fatalf("read %d bytes past EOF, want 0", n)
|
|
}
|
|
if err == nil {
|
|
t.Fatal("reading past EOF returned no error, want io.EOF")
|
|
}
|
|
}
|
|
|
|
func TestErrorPaths(t *testing.T) {
|
|
dir := testRoot(t)
|
|
|
|
t.Run("open missing file", func(t *testing.T) {
|
|
_, err := os.Open(filepath.Join(dir, "does-not-exist"))
|
|
if !os.IsNotExist(err) {
|
|
t.Fatalf("got %v, want a not-exist error", err)
|
|
}
|
|
})
|
|
|
|
t.Run("exclusive create over existing", func(t *testing.T) {
|
|
path := filepath.Join(dir, "taken")
|
|
if err := os.WriteFile(path, []byte("x"), 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
_, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
|
if !os.IsExist(err) {
|
|
t.Fatalf("got %v, want an already-exists error", err)
|
|
}
|
|
})
|
|
|
|
t.Run("remove missing file", func(t *testing.T) {
|
|
if err := os.Remove(filepath.Join(dir, "never-there")); !os.IsNotExist(err) {
|
|
t.Fatalf("got %v, want a not-exist error", err)
|
|
}
|
|
})
|
|
|
|
t.Run("mkdir under a file", func(t *testing.T) {
|
|
path := filepath.Join(dir, "regular")
|
|
if err := os.WriteFile(path, []byte("x"), 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if err := os.Mkdir(filepath.Join(path, "child"), 0755); err == nil {
|
|
t.Fatal("created a directory underneath a regular file")
|
|
}
|
|
})
|
|
|
|
t.Run("readdir of a missing directory", func(t *testing.T) {
|
|
if _, err := os.ReadDir(filepath.Join(dir, "no-such-dir")); err == nil {
|
|
t.Fatal("listed a directory that does not exist")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRenameOverExisting(t *testing.T) {
|
|
dir := testRoot(t)
|
|
src := filepath.Join(dir, "src")
|
|
dst := filepath.Join(dir, "dst")
|
|
|
|
if err := os.WriteFile(src, []byte("new"), 0644); err != nil {
|
|
t.Fatalf("write src: %v", err)
|
|
}
|
|
if err := os.WriteFile(dst, []byte("old"), 0644); err != nil {
|
|
t.Fatalf("write dst: %v", err)
|
|
}
|
|
if err := os.Rename(src, dst); err != nil {
|
|
t.Fatalf("rename over existing: %v", err)
|
|
}
|
|
got, err := os.ReadFile(dst)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if string(got) != "new" {
|
|
t.Fatalf("target holds %q, want the source content", got)
|
|
}
|
|
if fi, err := os.Stat(src); !os.IsNotExist(err) {
|
|
// Both a stale positive and a transient non-ENOENT error land here, and
|
|
// they indict different layers; a second look says whether it persists.
|
|
time.Sleep(200 * time.Millisecond)
|
|
fi2, err2 := os.Stat(src)
|
|
// A listing reads no per-path cache and the mount's own forgets
|
|
// within a second, so a name that survives both is back on the filer.
|
|
listed := dirNames(t, dir)
|
|
time.Sleep(2 * time.Second)
|
|
_, errLater := os.Stat(src)
|
|
t.Fatalf("stat of the renamed-away source did not return not-exist: stat=%v err=%v; 200ms later stat=%v err=%v; past the path cache err=%v; %s lists %v",
|
|
describeFileInfo(fi), err, describeFileInfo(fi2), err2, errLater, dir, listed)
|
|
}
|
|
}
|
|
|
|
// dirNames lists a directory for a failure message, reporting the error in
|
|
// place of the names rather than failing a test that is already failing.
|
|
func dirNames(t *testing.T, dir string) []string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return []string{"readdir: " + err.Error()}
|
|
}
|
|
names := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
names = append(names, entry.Name())
|
|
}
|
|
return names
|
|
}
|
|
|
|
func TestRenameAcrossDirectories(t *testing.T) {
|
|
dir := testRoot(t)
|
|
from := filepath.Join(dir, "from")
|
|
to := filepath.Join(dir, "to")
|
|
for _, d := range []string{from, to} {
|
|
if err := os.Mkdir(d, 0755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", d, err)
|
|
}
|
|
}
|
|
|
|
src := filepath.Join(from, "file")
|
|
dst := filepath.Join(to, "file")
|
|
if err := os.WriteFile(src, []byte("moved"), 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if err := os.Rename(src, dst); err != nil {
|
|
t.Fatalf("rename across directories: %v", err)
|
|
}
|
|
got, err := os.ReadFile(dst)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if string(got) != "moved" {
|
|
t.Fatalf("content after move = %q", got)
|
|
}
|
|
|
|
// A directory should move too, children and all.
|
|
nested := filepath.Join(from, "sub")
|
|
if err := os.Mkdir(nested, 0755); err != nil {
|
|
t.Fatalf("mkdir sub: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(nested, "child"), []byte("c"), 0644); err != nil {
|
|
t.Fatalf("write child: %v", err)
|
|
}
|
|
if err := os.Rename(nested, filepath.Join(to, "sub")); err != nil {
|
|
t.Fatalf("rename directory: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(to, "sub", "child")); err != nil {
|
|
t.Fatalf("child did not come along: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestAwkwardNames covers what has to survive the UTF-16 boundary and the
|
|
// shapes Windows software tends to produce.
|
|
func TestAwkwardNames(t *testing.T) {
|
|
dir := testRoot(t)
|
|
names := []string{
|
|
"café.txt",
|
|
"日本語のファイル.dat",
|
|
"emoji-🐟.bin",
|
|
"with space.txt",
|
|
"with.many.dots.txt",
|
|
"UPPER and lower.TXT",
|
|
"dash-and_underscore.txt",
|
|
"'quoted'.txt",
|
|
"(parens).txt",
|
|
"#hash&.txt",
|
|
strings.Repeat("x", 200) + ".txt",
|
|
}
|
|
for _, name := range names {
|
|
t.Run(name, func(t *testing.T) {
|
|
path := filepath.Join(dir, name)
|
|
want := []byte(name)
|
|
if err := os.WriteFile(path, want, 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if !bytes.Equal(got, want) {
|
|
t.Fatalf("content = %q, want %q", got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("readdir: %v", err)
|
|
}
|
|
found := make(map[string]bool, len(entries))
|
|
for _, e := range entries {
|
|
found[e.Name()] = true
|
|
}
|
|
for _, name := range names {
|
|
if !found[name] {
|
|
t.Errorf("%q did not come back from readdir", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeepDirectoryNesting(t *testing.T) {
|
|
dir := testRoot(t)
|
|
deep := dir
|
|
for i := 0; i < 24; i++ {
|
|
deep = filepath.Join(deep, fmt.Sprintf("level%02d", i))
|
|
}
|
|
if err := os.MkdirAll(deep, 0755); err != nil {
|
|
t.Fatalf("mkdirall depth 24: %v", err)
|
|
}
|
|
leaf := filepath.Join(deep, "leaf.txt")
|
|
if err := os.WriteFile(leaf, []byte("bottom"), 0644); err != nil {
|
|
t.Fatalf("write leaf: %v", err)
|
|
}
|
|
got, err := os.ReadFile(leaf)
|
|
if err != nil {
|
|
t.Fatalf("read leaf: %v", err)
|
|
}
|
|
if string(got) != "bottom" {
|
|
t.Fatalf("leaf content = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestConcurrentSameFile is the interleaving that matters: several handles on
|
|
// one file at once, rather than one writer each on separate files.
|
|
func TestConcurrentSameFile(t *testing.T) {
|
|
dir := testRoot(t)
|
|
path := filepath.Join(dir, "shared")
|
|
|
|
const writers = 4
|
|
const blockSize = 4096
|
|
if err := os.WriteFile(path, make([]byte, writers*blockSize), 0644); err != nil {
|
|
t.Fatalf("preallocate: %v", err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, writers)
|
|
for w := 0; w < writers; w++ {
|
|
wg.Add(1)
|
|
go func(w int) {
|
|
defer wg.Done()
|
|
f, err := os.OpenFile(path, os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
errs <- fmt.Errorf("writer %d open: %w", w, err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
block := bytes.Repeat([]byte{byte('A' + w)}, blockSize)
|
|
if _, err := f.WriteAt(block, int64(w*blockSize)); err != nil {
|
|
errs <- fmt.Errorf("writer %d write: %w", w, err)
|
|
}
|
|
}(w)
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if len(got) != writers*blockSize {
|
|
t.Fatalf("size = %d, want %d", len(got), writers*blockSize)
|
|
}
|
|
for w := 0; w < writers; w++ {
|
|
want := bytes.Repeat([]byte{byte('A' + w)}, blockSize)
|
|
if !bytes.Equal(got[w*blockSize:(w+1)*blockSize], want) {
|
|
t.Fatalf("block %d was not written whole", w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConcurrentReaders(t *testing.T) {
|
|
dir := testRoot(t)
|
|
path := filepath.Join(dir, "read-me")
|
|
want := contentFor("concurrent-readers", 1<<20)
|
|
if err := os.WriteFile(path, want, 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
// WriteFile's own existence probe ran while the file did not exist, and
|
|
// WinFsp may serve that answer from its metadata cache for up to the
|
|
// mount's FileInfoTimeout. Establish visibility once before racing the
|
|
// readers, so the race exercises concurrent reading rather than the
|
|
// cache window.
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(path); err == nil {
|
|
break
|
|
} else if time.Now().After(deadline) {
|
|
t.Fatalf("file never became visible: %v", err)
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 8)
|
|
for r := 0; r < 8; r++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
if !bytes.Equal(got, want) {
|
|
errs <- fmt.Errorf("reader saw %d bytes, want %d", len(got), len(want))
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
}
|
|
|
|
// Hard links are not something WinFsp offers, so the mount has to refuse them
|
|
// rather than appear to succeed.
|
|
func TestHardLinkUnsupported(t *testing.T) {
|
|
dir := testRoot(t)
|
|
target := filepath.Join(dir, "original")
|
|
if err := os.WriteFile(target, []byte("x"), 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if err := os.Link(target, filepath.Join(dir, "hardlink")); err == nil {
|
|
t.Fatal("hard link creation reported success")
|
|
}
|
|
}
|
|
|
|
// Symlinks are refused rather than half-supported: WinFsp needs a reparse
|
|
// point to follow one, and an entry it cannot follow reads back empty.
|
|
func TestSymlinkUnsupported(t *testing.T) {
|
|
dir := testRoot(t)
|
|
target := filepath.Join(dir, "target.txt")
|
|
if err := os.WriteFile(target, []byte("pointed at"), 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if err := os.Symlink(target, filepath.Join(dir, "link.txt")); err == nil {
|
|
t.Fatal("symlink creation reported success")
|
|
}
|
|
}
|
|
|
|
func TestModTimeAdvances(t *testing.T) {
|
|
dir := testRoot(t)
|
|
path := filepath.Join(dir, "touched")
|
|
if err := os.WriteFile(path, []byte("one"), 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
first, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat: %v", err)
|
|
}
|
|
|
|
time.Sleep(1100 * time.Millisecond)
|
|
if err := os.WriteFile(path, []byte("two"), 0644); err != nil {
|
|
t.Fatalf("rewrite: %v", err)
|
|
}
|
|
second, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat again: %v", err)
|
|
}
|
|
if !second.ModTime().After(first.ModTime()) {
|
|
t.Fatalf("mtime did not advance: %v then %v", first.ModTime(), second.ModTime())
|
|
}
|
|
}
|
|
|
|
func TestChtimes(t *testing.T) {
|
|
dir := testRoot(t)
|
|
path := filepath.Join(dir, "dated")
|
|
if err := os.WriteFile(path, []byte("x"), 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
want := time.Date(2020, 3, 4, 5, 6, 7, 0, time.UTC)
|
|
if err := os.Chtimes(path, want, want); err != nil {
|
|
t.Fatalf("chtimes: %v", err)
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat: %v", err)
|
|
}
|
|
if diff := info.ModTime().UTC().Sub(want); diff > 2*time.Second || diff < -2*time.Second {
|
|
t.Fatalf("mtime = %v, want about %v", info.ModTime().UTC(), want)
|
|
}
|
|
}
|
|
|
|
func TestStatfsIsSelfConsistent(t *testing.T) {
|
|
if *mountPoint == "" {
|
|
t.Skip("no -mountpoint given; this test needs a live WinFsp mount")
|
|
}
|
|
var free, total, totalFree uint64
|
|
if err := getDiskFreeSpace(*mountPoint, &free, &total, &totalFree); err != nil {
|
|
t.Fatalf("GetDiskFreeSpaceEx: %v", err)
|
|
}
|
|
if total == 0 {
|
|
t.Fatal("drive reports zero total bytes")
|
|
}
|
|
if free > total {
|
|
t.Fatalf("free (%d) exceeds total (%d)", free, total)
|
|
}
|
|
if totalFree > total {
|
|
t.Fatalf("total free (%d) exceeds total (%d)", totalFree, total)
|
|
}
|
|
}
|
|
|
|
// TestDeleteOnClose covers the pattern Windows software uses for temporaries:
|
|
// the file goes away when the last handle closes, with no explicit delete. A
|
|
// filesystem that ignores the flag leaves the name behind, and the next
|
|
// program to create it gets a collision.
|
|
func TestDeleteOnClose(t *testing.T) {
|
|
dir := testRoot(t)
|
|
path := filepath.Join(dir, "ephemeral.tmp")
|
|
|
|
h, err := createDeleteOnClose(path)
|
|
if err != nil {
|
|
if errors.Is(err, errWindowsOnly) {
|
|
t.Skip("delete-on-close needs the Win32 create call")
|
|
}
|
|
t.Fatalf("open with delete-on-close: %v", err)
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
closeHandle(h)
|
|
t.Fatalf("file is not visible while its handle is open: %v", err)
|
|
}
|
|
if err := closeHandle(h); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
if _, err := os.Stat(path); err == nil {
|
|
t.Fatal("file outlived its last handle")
|
|
} else if !os.IsNotExist(err) {
|
|
t.Fatalf("stat after close: %v", err)
|
|
}
|
|
// The name has to be free again, which is what the conformance suite
|
|
// tripped over when an aborted test left its file behind.
|
|
h, err = createDeleteOnClose(path)
|
|
if err != nil {
|
|
t.Fatalf("recreating the same name failed: %v", err)
|
|
}
|
|
if err := closeHandle(h); err != nil {
|
|
t.Fatalf("close after recreate: %v", err)
|
|
}
|
|
}
|
|
|
|
func describeFileInfo(fi os.FileInfo) string {
|
|
if fi == nil {
|
|
return "<nil>"
|
|
}
|
|
return fmt.Sprintf("{size=%d mode=%v mtime=%s}", fi.Size(), fi.Mode(), fi.ModTime().Format(time.RFC3339Nano))
|
|
}
|