diff --git a/sync/copy.go b/sync/copy.go new file mode 100644 index 0000000..5a04e08 --- /dev/null +++ b/sync/copy.go @@ -0,0 +1,148 @@ +package sync + +import ( + "errors" + "fmt" + "io" + "io/fs" + "log" + "os" + + "github.com/diskfs/go-diskfs/disk" + "github.com/diskfs/go-diskfs/filesystem" + "github.com/diskfs/go-diskfs/partition/part" +) + +var excludedPaths = map[string]bool{ + "lost+found": true, + ".DS_Store": true, + "System Volume Information": true, +} + +type copyData struct { + count int64 + err error +} + +// CopyFileSystem copies files from a source fs.FS to a destination filesystem.FileSystem, preserving structure and contents. +func CopyFileSystem(src fs.FS, dst filesystem.FileSystem) error { + return fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + // filter out special directories/files + if excludedPaths[d.Name()] { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if path == "." || path == "/" || path == "\\" { + return nil + } + + info, err := d.Info() + if err != nil { + return err + } + + // symlinks, when they exist + if info.Mode()&os.ModeSymlink != 0 { + // Check if your destination interface supports symlinks + // Most custom 'filesystem.FileSystem' interfaces might not. + return handleSymlink(src, dst, path) + } + + if d.IsDir() { + if path == "." { + return nil + } + return dst.Mkdir(path) + } + + if !info.Mode().IsRegular() { + // FAT32 / ISO / SquashFS should not have others + return nil + } + + return copyOneFile(src, dst, path, info) + }) +} + +func copyOneFile(src fs.FS, dst filesystem.FileSystem, path string, info fs.FileInfo) error { + in, err := src.Open(path) + if err != nil { + return err + } + defer func() { _ = in.Close() }() + + out, err := dst.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_RDWR) + if err != nil { + return err + } + defer func() { _ = out.Close() }() + + if _, err := io.Copy(out, in); err != nil { + return err + } + + // Restore timestamps *after* data is written (tar semantics) + atime := getAccessTime(info) + if atime.IsZero() { + atime = info.ModTime() // fallback + } + return dst.Chtimes( + path, + info.ModTime(), // creation time fallback if not available + atime, // access time: optional / policy choice + info.ModTime(), + ) +} + +// handleSymlink handles copying a symlink from src to dst. It reads the link target +// +//nolint:revive,unparam // keeping args for clarity of intent. +func handleSymlink(src fs.FS, dst filesystem.FileSystem, path string) error { + // Note: src must support ReadLink. If src is an os.DirFS, + // you might need a type assertion or use os.Readlink directly. + linkTarget, err := os.Readlink(path) + if err != nil { + return nil // Or handle error + } + + // This assumes your 'dst' interface has a Symlink method + return dst.Symlink(linkTarget, path) +} + +// CopyPartitionRaw copies raw data from one partition to another and verifies the copy. +func CopyPartitionRaw(d *disk.Disk, from, to int) error { + // copy raw data using a pipe so reads feed writes concurrently + pr, pw := io.Pipe() + ch := make(chan copyData, 1) + + go func() { + defer func() { _ = pw.Close() }() + read, err := d.ReadPartitionContents(from, pw) + ch <- copyData{count: read, err: err} + }() + + written, err := d.WritePartitionContents(to, pr) + var ierr *part.IncompletePartitionWriteError + if err != nil && !errors.As(err, &ierr) { + return fmt.Errorf("failed to write raw data for partition %d: %v", to, err) + } + + readData := <-ch + if readData.err != nil { + return fmt.Errorf("failed to read raw data for partition %d: %v", from, readData.err) + } + if readData.count != written { + return fmt.Errorf("mismatched read/write sizes for partition %d: read %d bytes, wrote %d bytes", from, readData.count, written) + } + log.Printf("partition %d -> %d: contents copied byte for byte, %d bytes copied", from, to, written) + if err := verifyBlockCopy(d, from, to, readData.count); err != nil { + return fmt.Errorf("verification failed for partition %d: %v", from, err) + } + log.Printf("partition %d -> %d: block copy verified", from, to) + return nil +} diff --git a/sync/copy_test.go b/sync/copy_test.go new file mode 100644 index 0000000..4421f6c --- /dev/null +++ b/sync/copy_test.go @@ -0,0 +1,206 @@ +package sync + +import ( + "bytes" + "io/fs" + "os" + "testing" + "testing/fstest" + "time" + + "github.com/diskfs/go-diskfs/filesystem" +) + +// fakeFS implements filesystem.FileSystem for testing CopyFileSystem. +type fakeFS struct { + dirs []string + files map[string][]byte + times map[string]time.Time +} + +// fakeFile satisfies filesystem.File. +type fakeFile struct { + path string + buf *bytes.Buffer + fs *fakeFS +} + +// Mkdir records directory creations. +func (f *fakeFS) Mkdir(path string) error { + f.dirs = append(f.dirs, path) + return nil +} + +// OpenFile returns a fakeFile for writing. + +// Chtimes records the creation time for a file. +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) Chtimes(path string, ctime, atime, mtime time.Time) error { + f.times[path] = ctime + return nil +} + +// Chmod satisfies filesystem.FileSystem interface (no-op). +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) Chmod(path string, mode os.FileMode) error { return nil } + +// Chown satisfies filesystem.FileSystem interface (no-op). +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) Chown(path string, uid, gid int) error { return nil } + +// Remove satisfies filesystem.FileSystem interface (no-op). +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) Remove(path string) error { return nil } + +// Rename satisfies filesystem.FileSystem interface (no-op). +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) Rename(oldpath, newpath string) error { return nil } + +// Stat satisfies filesystem.FileSystem interface (no-op). +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) Stat(path string) (os.FileInfo, error) { return nil, nil } + +// Close satisfies filesystem.FileSystem interface (no-op). +func (f *fakeFS) Close() error { return nil } + +// Type satisfies filesystem.FileSystem interface. +func (f *fakeFS) Type() filesystem.Type { return filesystem.TypeFat32 } + +// Mknod satisfies filesystem.FileSystem interface. +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) Mknod(pathname string, mode uint32, dev int) error { return nil } + +// Link satisfies filesystem.FileSystem interface. +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) Link(oldpath, newpath string) error { return nil } + +// Symlink satisfies filesystem.FileSystem interface. +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) Symlink(oldpath, newpath string) error { return nil } + +// Open satisfies filesystem.FileSystem interface (unused for copy). +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) Open(pathname string) (fs.File, error) { return nil, nil } + +// ReadDir satisfies fs.ReadDirFS (no-op). +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) ReadDir(name string) ([]fs.DirEntry, error) { return nil, nil } + +// ReadFile satisfies fs.ReadFileFS (no-op). +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) ReadFile(name string) ([]byte, error) { return nil, nil } + +// OpenFile satisfies filesystem.FileSystem interface for writing files. +// +//nolint:revive // flag is unused, keeping for clarity of intent. +func (f *fakeFS) OpenFile(pathname string, flag int) (filesystem.File, error) { + buf := &bytes.Buffer{} + ff := &fakeFile{path: pathname, buf: buf, fs: f} + if f.files == nil { + f.files = make(map[string][]byte) + } + if f.times == nil { + f.times = make(map[string]time.Time) + } + return ff, nil +} + +// Label satisfies filesystem.FileSystem interface. +func (f *fakeFS) Label() string { return "" } + +// SetLabel satisfies filesystem.FileSystem interface. +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFS) SetLabel(label string) error { return nil } + +// Write implements io.Writer. +func (f *fakeFile) Write(p []byte) (int, error) { + n, err := f.buf.Write(p) + f.fs.files[f.path] = f.buf.Bytes() + return n, err +} + +// Read implements io.Reader (unused here). +func (f *fakeFile) Read(p []byte) (int, error) { return f.buf.Read(p) } + +// Close is a no-op. +func (f *fakeFile) Close() error { return nil } + +// Seek is a no-op. +// +//nolint:revive // keeping args for clarity of intent. +func (f *fakeFile) Seek(offset int64, whence int) (int64, error) { return 0, nil } + +// Stat returns a minimal FileInfo. +func (f *fakeFile) Stat() (os.FileInfo, error) { + return f, nil +} + +// The fakeFile itself implements os.FileInfo for simplicity. +func (f *fakeFile) Name() string { return f.path } +func (f *fakeFile) Size() int64 { return int64(f.buf.Len()) } +func (f *fakeFile) Mode() os.FileMode { return 0 } +func (f *fakeFile) ModTime() time.Time { return f.fs.times[f.path] } +func (f *fakeFile) IsDir() bool { return false } +func (f *fakeFile) Sys() interface{} { return nil } + +// TestCopyFileSystem_Basic verifies directories and files are copied. +func TestCopyFileSystem_Basic(t *testing.T) { + now := time.Now() + src := fstest.MapFS{ + "foo.txt": {Data: []byte("hello"), ModTime: now}, + "dir": {Mode: fs.ModeDir, ModTime: now}, + "dir/bar": {Data: []byte("world"), ModTime: now}, + } + dst := &fakeFS{} + if err := CopyFileSystem(src, dst); err != nil { + t.Fatalf("CopyFileSystem failed: %v", err) + } + // directory created + found := false + for _, d := range dst.dirs { + if d == "dir" { + found = true + } + } + if !found { + t.Errorf("expected Mkdir(\"dir\"), got %v", dst.dirs) + } + // files copied + if string(dst.files["foo.txt"]) != "hello" { + t.Errorf("foo.txt = %q, want %q", dst.files["foo.txt"], "hello") + } + if string(dst.files["dir/bar"]) != "world" { + t.Errorf("dir/bar = %q, want %q", dst.files["dir/bar"], "world") + } + // timestamp recorded (should default to zero time) + if ts, ok := dst.times["foo.txt"]; !ok || ts != now { + t.Errorf("expected timestamp for foo.txt, got %v", ts) + } +} + +// TestCopyFileSystem_SkipNonRegular ensures non-regular entries (symlinks) are skipped. +func TestCopyFileSystem_SkipNonRegular(t *testing.T) { + src := fstest.MapFS{ + "sl": {Data: []byte(""), Mode: fs.ModeSymlink}, + } + dst := &fakeFS{} + if err := CopyFileSystem(src, dst); err != nil { + t.Fatalf("CopyFileSystem failed: %v", err) + } + if _, ok := dst.files["sl"]; ok { + t.Errorf("expected non-regular file to be skipped, but copied") + } +} diff --git a/sync/time_other.go b/sync/time_other.go new file mode 100644 index 0000000..e9e1a09 --- /dev/null +++ b/sync/time_other.go @@ -0,0 +1,23 @@ +//go:build linux || darwin || freebsd || netbsd || openbsd + +package sync + +import ( + "io/fs" + "time" + + "golang.org/x/sys/unix" +) + +func getAccessTime(info fs.FileInfo) time.Time { + sys := info.Sys() + if sys == nil { + // return zero time + return time.Time{} + } + stat, ok := sys.(*unix.Stat_t) + if !ok { + return time.Time{} + } + return time.Unix(stat.Atim.Sec, stat.Atim.Nsec) +} diff --git a/sync/time_windows.go b/sync/time_windows.go new file mode 100644 index 0000000..fbc6421 --- /dev/null +++ b/sync/time_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package sync + +import ( + "io/fs" + "syscall" + "time" +) + +func getAccessTime(info fs.FileInfo) time.Time { + sys := info.Sys() + if sys == nil { + // return zero time + return time.Time{} + } + stat := sys.(*syscall.Win32FileAttributeData) + return time.Unix(0, stat.LastAccessTime.Nanoseconds()) +} diff --git a/sync/verify.go b/sync/verify.go new file mode 100644 index 0000000..cdd5ff0 --- /dev/null +++ b/sync/verify.go @@ -0,0 +1,167 @@ +package sync + +import ( + "bytes" + "crypto/sha256" + "fmt" + "io" + "io/fs" + "path" + + "github.com/diskfs/go-diskfs/disk" +) + +func verifyBlockCopy(d *disk.Disk, from, to int, expectedSize int64) error { + // open both partitions for reading + origPart, err := d.GetPartition(from) + if err != nil { + return err + } + targetPart, err := d.GetPartition(to) + if err != nil { + return err + } + // create a sha256sum of both partitions and compare + // but limit it to expectedSize + origHasher := sha256.New() + size, err := origPart.ReadContents(d.Backend, origHasher) + if err != nil { + return err + } + if size != expectedSize { + return fmt.Errorf("original partition size %d is different than expected size %d", size, expectedSize) + } + origResult := origHasher.Sum(nil) + + targetHasher := sha256.New() + size, err = targetPart.ReadContents(d.Backend, NewLimitWriter(targetHasher, expectedSize)) + if err != nil { + return err + } + if size != expectedSize { + return fmt.Errorf("target partition size %d is different than expected size %d", size, expectedSize) + } + targetResult := targetHasher.Sum(nil) + + if !bytes.Equal(origResult, targetResult) { + return fmt.Errorf("data mismatch between original and target partitions") + } + return nil +} + +// CompareFS compares two fs.FS instances for identical structure and contents. +func CompareFS(origFS, targetFS fs.FS) error { + seen := make(map[string]struct{}) + + // Walk original FS + err := fs.WalkDir(origFS, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + seen[p] = struct{}{} + + // Check existence in target FS + td, err := fs.Stat(targetFS, p) + if err != nil { + return fmt.Errorf("path %q missing in target FS: %w", p, err) + } + + // Compare type + if d.IsDir() != td.IsDir() { + return fmt.Errorf("type mismatch at %q", p) + } + + if d.IsDir() { + return nil + } + + // Compare file size + od, err := d.Info() + if err != nil { + return err + } + if od.Size() != td.Size() { + return fmt.Errorf("size mismatch at %q", p) + } + + // Compare file contents + return compareFileContents(origFS, targetFS, p) + }) + if err != nil { + return err + } + + // Ensure target FS has no extra files + // + //nolint:revive // keeping args for clarity of intent. + return fs.WalkDir(targetFS, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if _, ok := seen[p]; !ok { + return fmt.Errorf("extra path %q in target FS", p) + } + return nil + }) +} + +func compareFileContents(a, b fs.FS, name string) error { + af, err := a.Open(name) + if err != nil { + return err + } + defer func() { _ = af.Close() }() + + bf, err := b.Open(name) + if err != nil { + return err + } + defer func() { _ = bf.Close() }() + + const bufSize = 32 * 1024 + bufA := make([]byte, bufSize) + bufB := make([]byte, bufSize) + + for { + na, ea := af.Read(bufA) + nb, eb := bf.Read(bufB) + + if na != nb || !bytes.Equal(bufA[:na], bufB[:nb]) { + return fmt.Errorf("content mismatch at %q", path.Clean(name)) + } + + if ea == io.EOF && eb == io.EOF { + return nil + } + if ea != nil && ea != io.EOF { + return ea + } + if eb != nil && eb != io.EOF { + return eb + } + } +} + +// LimitedWriter writes to W but limits the total amount of data written to N bytes. +// Each call to Write updates N to reflect the new amount remaining. +type LimitedWriter struct { + W io.Writer // underlying writer + N int64 // max bytes remaining +} + +func (l *LimitedWriter) Write(p []byte) (n int, err error) { + if l.N <= 0 { + return 0, io.EOF // Or another appropriate error + } + if int64(len(p)) > l.N { + p = p[:l.N] + } + n, err = l.W.Write(p) + l.N -= int64(n) + return n, err +} + +// NewLimitWriter creates a new LimitedWriter. +func NewLimitWriter(w io.Writer, n int64) io.Writer { + return &LimitedWriter{W: w, N: n} +} diff --git a/sync/verify_test.go b/sync/verify_test.go new file mode 100644 index 0000000..3c64aed --- /dev/null +++ b/sync/verify_test.go @@ -0,0 +1,90 @@ +package sync + +import ( + "testing" + "testing/fstest" +) + +func TestCompareFS(t *testing.T) { + tests := []struct { + name string + origFS fstest.MapFS + targetFS fstest.MapFS + wantErr bool + }{ + { + name: "identical filesystems", + origFS: fstest.MapFS{ + "file.txt": {Data: []byte("hello")}, + "dir/nested.txt": {Data: []byte("world")}, + }, + targetFS: fstest.MapFS{ + "file.txt": {Data: []byte("hello")}, + "dir/nested.txt": {Data: []byte("world")}, + }, + wantErr: false, + }, + { + name: "different file contents", + origFS: fstest.MapFS{ + "file.txt": {Data: []byte("hello")}, + }, + targetFS: fstest.MapFS{ + "file.txt": {Data: []byte("HELLO")}, + }, + wantErr: true, + }, + { + name: "missing file in target", + origFS: fstest.MapFS{ + "file.txt": {Data: []byte("hello")}, + }, + targetFS: fstest.MapFS{}, + wantErr: true, + }, + { + name: "extra file in target", + origFS: fstest.MapFS{ + "file.txt": {Data: []byte("hello")}, + }, + targetFS: fstest.MapFS{ + "file.txt": {Data: []byte("hello")}, + "extra.txt": {Data: []byte("extra")}, + }, + wantErr: true, + }, + { + name: "directory vs file mismatch", + origFS: fstest.MapFS{ + "dir/file.txt": {Data: []byte("hello")}, + }, + targetFS: fstest.MapFS{ + "dir": {Data: []byte("not a dir")}, + }, + wantErr: true, + }, + { + name: "different file size", + origFS: fstest.MapFS{ + "file.txt": {Data: []byte("hello")}, + }, + targetFS: fstest.MapFS{ + "file.txt": {Data: []byte("hello world")}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CompareFS(tt.origFS, tt.targetFS) + + if tt.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +}