fix: preserve snapshot symlinks and guard incomplete upgrade copies

This commit is contained in:
2026-10-18 10:12:53 +08:00
parent 1c244e135f
commit ed72985e7b
8 changed files with 192 additions and 45 deletions
+54 -13
View File
@@ -100,7 +100,7 @@ func TestUpgradeLifecycle(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
for _, mode := range []string{"success", "legacy-layout", "shared-layout", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} {
for _, mode := range []string{"snapshot-fifo", "snapshot-dangling-link", "success", "legacy-layout", "shared-layout", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} {
t.Run(mode, func(t *testing.T) {
image := "sha256:" + strings.Repeat("b", 64)
var a *API
@@ -260,6 +260,16 @@ func TestUpgradeLifecycle(t *testing.T) {
if err := os.WriteFile(filepath.Join(home, ".config", "state"), []byte("original"), 0600); err != nil {
t.Fatal(err)
}
if mode == "snapshot-dangling-link" {
if err := os.Symlink("missing", filepath.Join(home, "dangling")); err != nil {
t.Fatal(err)
}
}
if mode == "snapshot-fifo" {
if err := syscall.Mkfifo(filepath.Join(home, "pipe"), 0600); err != nil {
t.Fatal(err)
}
}
i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", ImageID: "old-image", Version: "v1", Status: "exited", Created: time.Now()}
if err := a.s.AddInstance(i); err != nil {
t.Fatal(err)
@@ -297,6 +307,38 @@ func TestUpgradeLifecycle(t *testing.T) {
if err := a.s.DB.QueryRow("SELECT phase,snapshot,failed_home FROM upgrade_journal WHERE instance_id='one'").Scan(&phase, &snapshot, &failed); err != nil {
t.Fatal(err)
}
if mode == "snapshot-fifo" {
if w.Code != 500 || !strings.Contains(w.Body.String(), "home/pipe") || phase != "rolled_back" || got.ContainerID != "old" {
t.Fatalf("%d %s phase=%s", w.Code, w.Body.String(), phase)
}
entries, err := os.ReadDir(base)
if err != nil || len(entries) != 1 || entries[0].Name() != "home" {
t.Fatalf("partial snapshot leaked: %v %v", entries, err)
}
listed := configCall(a, got, "GET", "snapshots", "")
if listed.Code != 200 || strings.TrimSpace(listed.Body.String()) != "[]" {
t.Fatal(listed.Code, listed.Body.String())
}
return
}
if mode == "snapshot-dangling-link" {
if w.Code != 200 || phase != "complete" || got.ContainerID != "new" {
t.Fatalf("unexpected result: %d %s phase=%s instance=%+v", w.Code, w.Body.String(), phase, got)
}
saved, err := os.ReadFile(filepath.Join(base, snapshot, ".config", "state"))
if err != nil || string(saved) != "original" {
t.Fatalf("copied data missing: %q %v", saved, err)
}
link, err := os.Readlink(filepath.Join(base, snapshot, "dangling"))
if err != nil || link != "missing" {
t.Fatalf("copied link missing: %q %v", link, err)
}
listed := configCall(a, got, "GET", "snapshots", "")
if listed.Code != 200 || !strings.Contains(listed.Body.String(), snapshot) {
t.Fatalf("snapshot not listed: %d %s", listed.Code, listed.Body.String())
}
return
}
data, err := os.ReadFile(filepath.Join(home, ".config", "state"))
if err != nil {
t.Fatal(err)
@@ -375,7 +417,7 @@ func TestUpgradeLifecycle(t *testing.T) {
}
func TestUpgradeSnapshotRejectsUnsafeFiles(t *testing.T) {
for _, kind := range []string{"symlink", "fifo", "oversize", "directory-symlink"} {
for _, kind := range []string{"fifo", "oversize"} {
t.Run(kind, func(t *testing.T) {
base := t.TempDir()
if err := os.Mkdir(filepath.Join(base, "home"), 0700); err != nil {
@@ -384,14 +426,10 @@ func TestUpgradeSnapshotRejectsUnsafeFiles(t *testing.T) {
p := filepath.Join(base, "home", "bad")
var err error
switch kind {
case "symlink":
err = os.Symlink("/etc/passwd", p)
case "fifo":
err = syscall.Mkfifo(p, 0600)
case "oversize":
err = os.WriteFile(p, []byte("12345"), 0600)
case "directory-symlink":
err = os.Symlink("/tmp", p)
}
if err != nil {
t.Fatal(err)
@@ -412,7 +450,7 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
for _, mode := range []string{"pnpm", "escape", "chain-escape", "loop", "dangling"} {
for _, mode := range []string{"pnpm", "absolute", "escape", "chain-escape", "loop", "dangling"} {
t.Run(mode, func(t *testing.T) {
base := t.TempDir()
home := filepath.Join(base, "home")
@@ -425,6 +463,8 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
}
link := ".pnpm/pkg/node_modules/pkg"
switch mode {
case "absolute":
link = "/workspace/example"
case "escape":
link = "../../outside"
case "chain-escape":
@@ -446,12 +486,6 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
}
defer root.Close()
err = upgradeCopy(context.Background(), root, "home", "snapshot", 1024)
if mode != "pnpm" {
if err == nil {
t.Fatal("unsafe link accepted")
}
return
}
if err != nil {
t.Fatal(err)
}
@@ -465,6 +499,13 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
if err := root.Rename("snapshot", "home"); err != nil {
t.Fatal(err)
}
if mode != "pnpm" {
got, err := root.Readlink("home/node_modules/pkg")
if err != nil || got != link {
t.Fatalf("restored link changed: %q %v", got, err)
}
return
}
data, err := os.ReadFile(filepath.Join(home, "node_modules/pkg/index.js"))
if err != nil || string(data) != "original" {
t.Fatalf("rollback link broken: %q %v", data, err)