//go:build !windows package backup import ( "context" "errors" "os" "path/filepath" "strings" "testing" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/safeio" ) func TestStageArchiveRejectsSymlinkedInstallationAncestor(t *testing.T) { root, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } realProject := filepath.Join(root, "real-project") linkedProject := filepath.Join(root, "linked-project") if err := os.Mkdir(realProject, 0o700); err != nil { t.Fatal(err) } if err := os.Symlink(realProject, linkedProject); err != nil { t.Fatal(err) } installation := config.Installation{ Path: filepath.Join(linkedProject, "deploy", "local-dev", "thothii-installation.yaml"), ProjectDirectory: linkedProject, } if err := os.MkdirAll(installation.ControlDirectory(), 0o700); err != nil { t.Fatal(err) } archive := filepath.Join(root, "valid.zip") writePreflightArchive(t, archive, preflightArchiveSpec{ entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("safe")}}, }) result, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies()) if err != nil { t.Fatal(err) } defer result.CloseArchive() if _, err := result.StageArchive(context.Background()); err == nil || !strings.Contains(err.Error(), "staging root") { t.Fatalf("StageArchive() error = %v, want unsafe symlinked staging-root rejection", err) } } func TestStageArchiveCreatesUnixPrivateRegularFile(t *testing.T) { installation := preflightTestInstallation(t) if err := os.MkdirAll(installation.ControlDirectory(), 0o700); err != nil { t.Fatal(err) } archive := filepath.Join(t.TempDir(), "valid.zip") writePreflightArchive(t, archive, preflightArchiveSpec{ entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("safe")}}, }) result, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies()) if err != nil { t.Fatal(err) } defer result.CloseArchive() staged, err := result.StageArchive(context.Background()) if err != nil { t.Fatal(err) } defer staged.Close() if err := safeio.ValidatePrivateRegular(staged.path); err != nil { t.Fatalf("staged archive privacy = %v, want owner-private regular file", err) } info, err := os.Lstat(staged.path) if err != nil { t.Fatal(err) } if got := info.Mode().Perm(); got != 0o600 { t.Fatalf("staged archive mode = %#o, want 0600", got) } } func TestStageArchiveCloseUsesPinnedRootAfterAncestorSwap(t *testing.T) { installation := preflightTestInstallation(t) if err := os.MkdirAll(installation.ControlDirectory(), 0o700); err != nil { t.Fatal(err) } archive := filepath.Join(t.TempDir(), "valid.zip") writePreflightArchive(t, archive, preflightArchiveSpec{ entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("safe")}}, }) result, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies()) if err != nil { t.Fatal(err) } defer result.CloseArchive() outsideParent, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } outside := filepath.Join(outsideParent, "outside") if err := os.Mkdir(outside, 0o700); err != nil { t.Fatal(err) } if err := safeio.ProtectPrivateDirectory(outside); err != nil { t.Fatal(err) } sentinelPath := filepath.Join(outside, "sentinel") sentinel := []byte("outside sentinel") if err := os.WriteFile(sentinelPath, sentinel, 0o600); err != nil { t.Fatal(err) } swapped := false restoreHook := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) { if stage != "before-stage-archive-remove" || swapped { return } swapped = true movedRoot := result.stagingRoot + "-original" if err := os.Rename(result.stagingRoot, movedRoot); err != nil { t.Fatal(err) } if err := os.Symlink(outside, result.stagingRoot); err != nil { t.Fatal(err) } }) defer restoreHook() staged, err := result.StageArchive(context.Background()) if err != nil { t.Fatal(err) } stagedName := staged.name movedRoot := result.stagingRoot + "-original" if filepath.Dir(staged.path) != result.stagingRoot { t.Fatalf("staged archive directory = %q, want %q", filepath.Dir(staged.path), result.stagingRoot) } if err := staged.Close(); err != nil { t.Fatal(err) } if !swapped { t.Fatal("stage archive cleanup hook did not run") } if _, err := os.Stat(filepath.Join(movedRoot, stagedName)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("moved staging archive = %v, want os.ErrNotExist", err) } gotSentinel, err := os.ReadFile(sentinelPath) if err != nil { t.Fatal(err) } if string(gotSentinel) != string(sentinel) { t.Fatalf("outside sentinel = %q, want %q", gotSentinel, sentinel) } }