Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions e2e/e2e.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)...)
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions e2e/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions e2e/portable_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
4 changes: 4 additions & 0 deletions e2e/portable_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
15 changes: 15 additions & 0 deletions e2e/xattrs_stub.go
Original file line number Diff line number Diff line change
@@ -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")
}
53 changes: 53 additions & 0 deletions e2e/xattrs_unix.go
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 6 additions & 0 deletions internal/engine/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions internal/engine/restore_xattrs_stub.go
Original file line number Diff line number Diff line change
@@ -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
}
32 changes: 32 additions & 0 deletions internal/engine/restore_xattrs_unix.go
Original file line number Diff line number Diff line change
@@ -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)
}
116 changes: 116 additions & 0 deletions internal/engine/restore_xattrs_unix_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading