From b3263e069f11a4d689821f614f17b67087198ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Tue, 17 Mar 2026 22:07:49 +0100 Subject: [PATCH 1/2] feat: restore xattrs in directory restores --- internal/engine/restore.go | 6 + internal/engine/restore_xattrs_stub.go | 9 ++ internal/engine/restore_xattrs_unix.go | 32 ++++++ internal/engine/restore_xattrs_unix_test.go | 116 ++++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 internal/engine/restore_xattrs_stub.go create mode 100644 internal/engine/restore_xattrs_unix.go create mode 100644 internal/engine/restore_xattrs_unix_test.go diff --git a/internal/engine/restore.go b/internal/engine/restore.go index 7ff4406..9fd487f 100644 --- a/internal/engine/restore.go +++ b/internal/engine/restore.go @@ -266,6 +266,9 @@ func (w *fsRestoreWriter) MkdirAll(relPath string, meta core.FileMeta) error { if err := os.MkdirAll(fullPath, 0o755); err != nil { return err } + if err := applyRestoreXattrs(fullPath, meta); err != nil { + return err + } if meta.Mtime > 0 { mt := time.Unix(meta.Mtime, 0) _ = os.Chtimes(fullPath, mt, mt) @@ -301,6 +304,9 @@ func (w *fsRestoreWriter) WriteFile(relPath string, meta core.FileMeta, writeCon } w.bytes += cw.count + if err := applyRestoreXattrs(fullPath, meta); err != nil { + return err + } if meta.Mtime > 0 { mt := time.Unix(meta.Mtime, 0) _ = os.Chtimes(fullPath, mt, mt) diff --git a/internal/engine/restore_xattrs_stub.go b/internal/engine/restore_xattrs_stub.go new file mode 100644 index 0000000..284952d --- /dev/null +++ b/internal/engine/restore_xattrs_stub.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin + +package engine + +import "github.com/cloudstic/cli/internal/core" + +func applyRestoreXattrs(_ string, _ core.FileMeta) error { + return nil +} diff --git a/internal/engine/restore_xattrs_unix.go b/internal/engine/restore_xattrs_unix.go new file mode 100644 index 0000000..30b3dea --- /dev/null +++ b/internal/engine/restore_xattrs_unix.go @@ -0,0 +1,32 @@ +//go:build linux || darwin + +package engine + +import ( + "errors" + "fmt" + + "github.com/cloudstic/cli/internal/core" + "golang.org/x/sys/unix" +) + +var setRestoreXattr = unix.Setxattr + +func applyRestoreXattrs(path string, meta core.FileMeta) error { + for name, value := range meta.Xattrs { + if err := setRestoreXattr(path, name, value, 0); err != nil { + if isRestoreXattrBestEffortError(err) { + continue + } + return fmt.Errorf("set xattr %q on %s: %w", name, path, err) + } + } + return nil +} + +func isRestoreXattrBestEffortError(err error) bool { + return errors.Is(err, unix.ENOTSUP) || + errors.Is(err, unix.EOPNOTSUPP) || + errors.Is(err, unix.EPERM) || + errors.Is(err, unix.EACCES) +} diff --git a/internal/engine/restore_xattrs_unix_test.go b/internal/engine/restore_xattrs_unix_test.go new file mode 100644 index 0000000..8f9c305 --- /dev/null +++ b/internal/engine/restore_xattrs_unix_test.go @@ -0,0 +1,116 @@ +//go:build linux || darwin + +package engine + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/cloudstic/cli/internal/core" + "github.com/cloudstic/cli/internal/ui" + "github.com/cloudstic/cli/pkg/store" + "golang.org/x/sys/unix" +) + +func TestApplyRestoreXattrs_SetsAllXattrs(t *testing.T) { + orig := setRestoreXattr + defer func() { setRestoreXattr = orig }() + + got := map[string][]byte{} + setRestoreXattr = func(path, name string, value []byte, flags int) error { + if path != "/tmp/file" { + t.Fatalf("path=%q", path) + } + if flags != 0 { + t.Fatalf("flags=%d", flags) + } + got[name] = append([]byte(nil), value...) + return nil + } + + err := applyRestoreXattrs("/tmp/file", core.FileMeta{Xattrs: map[string][]byte{ + "user.a": []byte("one"), + "user.b": []byte("two"), + }}) + if err != nil { + t.Fatalf("applyRestoreXattrs: %v", err) + } + if string(got["user.a"]) != "one" || string(got["user.b"]) != "two" { + t.Fatalf("unexpected xattrs: %#v", got) + } +} + +func TestApplyRestoreXattrs_IgnoresBestEffortErrors(t *testing.T) { + orig := setRestoreXattr + defer func() { setRestoreXattr = orig }() + + setRestoreXattr = func(path, name string, value []byte, flags int) error { + return unix.EPERM + } + + err := applyRestoreXattrs("/tmp/file", core.FileMeta{Xattrs: map[string][]byte{"user.a": []byte("one")}}) + if err != nil { + t.Fatalf("applyRestoreXattrs: %v", err) + } +} + +func TestApplyRestoreXattrs_ReturnsUnexpectedErrors(t *testing.T) { + orig := setRestoreXattr + defer func() { setRestoreXattr = orig }() + + setRestoreXattr = func(path, name string, value []byte, flags int) error { + return errors.New("boom") + } + + err := applyRestoreXattrs("/tmp/file", core.FileMeta{Xattrs: map[string][]byte{"user.a": []byte("one")}}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRestoreManager_RunToDir_ReplaysXattrs(t *testing.T) { + orig := setRestoreXattr + defer func() { setRestoreXattr = orig }() + + seen := map[string][]byte{} + setRestoreXattr = func(path, name string, value []byte, flags int) error { + seen[path+"::"+name] = append([]byte(nil), value...) + return nil + } + + src := NewMockSource() + dest := NewMockStore() + src.Files["id_dir"] = MockFile{ + Meta: core.FileMeta{FileID: "id_dir", Name: "dir", Type: core.FileTypeFolder, Xattrs: map[string][]byte{"user.dir": []byte("d")}}, + } + src.Files["id_file"] = MockFile{ + Meta: core.FileMeta{FileID: "id_file", Name: "file.txt", Parents: []string{"id_dir"}, Xattrs: map[string][]byte{"user.file": []byte("f")}}, + Content: []byte("content"), + } + bkMgr := NewBackupManager(src, dest, ui.NewNoOpReporter(), nil) + if _, err := bkMgr.Run(t.Context()); err != nil { + t.Fatalf("backup setup failed: %v", err) + } + + outDir := filepath.Join(t.TempDir(), "restore") + writer, err := NewFSRestoreWriter(outDir) + if err != nil { + t.Fatalf("NewFSRestoreWriter: %v", err) + } + rsMgr := NewRestoreManager(store.NewCompressedStore(dest), ui.NewNoOpReporter()) + if _, err := rsMgr.Run(t.Context(), writer, ""); err != nil { + t.Fatalf("Run: %v", err) + } + + if string(seen[filepath.Join(outDir, "dir")+"::user.dir"]) != "d" { + t.Fatalf("directory xattr not replayed: %#v", seen) + } + if string(seen[filepath.Join(outDir, "dir", "file.txt")+"::user.file"]) != "f" { + t.Fatalf("file xattr not replayed: %#v", seen) + } + if b, err := os.ReadFile(filepath.Join(outDir, "dir", "file.txt")); err != nil || string(b) != "content" { + t.Fatalf("restored file mismatch: %q %v", string(b), err) + } +} From a47d512dac2eef81f2e98810aafd269c885cdb24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Tue, 17 Mar 2026 22:14:34 +0100 Subject: [PATCH 2/2] xattr e2e tests --- e2e/e2e.go | 4 ++++ e2e/e2e_test.go | 4 ++++ e2e/local.go | 4 ++++ e2e/portable_darwin.go | 4 ++++ e2e/portable_linux.go | 4 ++++ e2e/xattrs_stub.go | 15 ++++++++++++ e2e/xattrs_unix.go | 53 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 88 insertions(+) create mode 100644 e2e/xattrs_stub.go create mode 100644 e2e/xattrs_unix.go diff --git a/e2e/e2e.go b/e2e/e2e.go index 7421cfd..9b69ea0 100644 --- a/e2e/e2e.go +++ b/e2e/e2e.go @@ -37,6 +37,10 @@ type TestSource interface { WriteFile(t *testing.T, relPath, content string) } +type hostPathSource interface { + HostPath(relPath string) string +} + // TestStore encapsulates the content-addressable storage backend. type TestStore interface { Name() string diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index e1c08be..7f4cf57 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -197,6 +197,7 @@ func TestCLI_EndToEnd_Matrix(t *testing.T) { // 5. Incremental State src.WriteFile(t, "file2.txt", "new file") src.WriteFile(t, "secret.txt", "updated classified data") + xattrName, hasXattrValidation := maybeSetTestXattr(t, src, "secret.txt", "classified-xattr") // 6. Backup 2 (with tags) backup2Args := append([]string{"backup"}, append(srcArgs, baseEncArgs...)...) @@ -272,6 +273,9 @@ func TestCLI_EndToEnd_Matrix(t *testing.T) { t.Errorf("Direct restore content mismatch for %s: got %q, want %q", tc.path, got, tc.content) } } + if hasXattrValidation { + assertXattrValue(t, filepath.Join(dirOut, "secret.txt"), xattrName, "classified-xattr") + } // 8a. Partial Restore — single file partialFilePath := filepath.Join(restoreDir, "partial_file.zip") diff --git a/e2e/local.go b/e2e/local.go index 3640d84..443f826 100644 --- a/e2e/local.go +++ b/e2e/local.go @@ -30,6 +30,10 @@ func (s *localSource) WriteFile(t *testing.T, relPath, content string) { } } +func (s *localSource) HostPath(relPath string) string { + return filepath.Join(s.dir, relPath) +} + type localStore struct { dir string } diff --git a/e2e/portable_darwin.go b/e2e/portable_darwin.go index 26851fc..b3502c1 100644 --- a/e2e/portable_darwin.go +++ b/e2e/portable_darwin.go @@ -71,3 +71,7 @@ func (s *portableDriveSource) WriteFile(t *testing.T, relPath, content string) { t.Fatal(err) } } + +func (s *portableDriveSource) HostPath(relPath string) string { + return filepath.Join(s.mountPoint, relPath) +} diff --git a/e2e/portable_linux.go b/e2e/portable_linux.go index a633134..7e6e66d 100644 --- a/e2e/portable_linux.go +++ b/e2e/portable_linux.go @@ -151,3 +151,7 @@ func (s *portableDriveSource) WriteFile(t *testing.T, relPath, content string) { } } } + +func (s *portableDriveSource) HostPath(relPath string) string { + return filepath.Join(s.mountPoint, relPath) +} diff --git a/e2e/xattrs_stub.go b/e2e/xattrs_stub.go new file mode 100644 index 0000000..a486f1b --- /dev/null +++ b/e2e/xattrs_stub.go @@ -0,0 +1,15 @@ +//go:build !linux && !darwin + +package e2e + +import "testing" + +func maybeSetTestXattr(t *testing.T, src TestSource, relPath, value string) (string, bool) { + t.Helper() + return "", false +} + +func assertXattrValue(t *testing.T, path, name, want string) { + t.Helper() + t.Skip("xattr validation not supported on this platform") +} diff --git a/e2e/xattrs_unix.go b/e2e/xattrs_unix.go new file mode 100644 index 0000000..2d01d62 --- /dev/null +++ b/e2e/xattrs_unix.go @@ -0,0 +1,53 @@ +//go:build linux || darwin + +package e2e + +import ( + "errors" + "path/filepath" + "runtime" + "testing" + + "golang.org/x/sys/unix" +) + +func testXattrName() string { + if runtimeGOOS() == "darwin" { + return "com.cloudstic.e2e" + } + return "user.cloudstic.e2e" +} + +func maybeSetTestXattr(t *testing.T, src TestSource, relPath, value string) (string, bool) { + t.Helper() + host, ok := src.(hostPathSource) + if !ok { + return "", false + } + name := testXattrName() + fullPath := host.HostPath(filepath.FromSlash(relPath)) + if err := unix.Setxattr(fullPath, name, []byte(value), 0); err != nil { + if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EOPNOTSUPP) || errors.Is(err, unix.EPERM) || errors.Is(err, unix.EACCES) { + t.Logf("skipping xattr validation for %s: cannot set %s on %s: %v", src.Name(), name, fullPath, err) + return "", false + } + t.Fatalf("set xattr %s on %s: %v", name, fullPath, err) + } + return name, true +} + +func assertXattrValue(t *testing.T, path, name, want string) { + t.Helper() + buf := make([]byte, 1024) + n, err := unix.Getxattr(path, name, buf) + if err != nil { + t.Fatalf("get xattr %s on %s: %v", name, path, err) + } + if got := string(buf[:n]); got != want { + t.Fatalf("xattr %s on %s = %q, want %q", name, path, got, want) + } +} + +func runtimeGOOS() string { + return runtime.GOOS +}