fix: preserve snapshot symlinks and guard incomplete upgrade copies
This commit is contained in:
+3
-1
@@ -49,7 +49,9 @@
|
||||
|
||||
升级到本版本后应在维护窗口逐个访问现有实例,完成首次认证持久化。若在首次成功连接前,启动token已被Docker日志轮转删除,本版本无法凭空恢复;先保存工作,再由管理员安排重启相关实例并访问验证。不要为此删除或重建实例。初次日志扫描上限64 MiB,常规每文件10 MiB/3份保留在此范围内。
|
||||
|
||||
- 实例升级选择已经启动验证且准入的本地模板7镜像并提供当前登录密码,面板自动停机、创建密码配置备份与home快照、启动验证及失败回滚,无需先手动停止。home快照最大1 GiB;允许受约束的内部相对链接以支持pnpm,拒绝不安全链接/特殊文件。模板7的 `/workspace` 是实例home,包含在home回滚内;旧共享挂载布局会在升级前被拒绝。跨DSH版本历史验证范围见 ACCEPTANCE.md。
|
||||
- 实例升级选择已经启动验证且准入的本地模板7镜像并提供当前登录密码,面板自动停机、创建密码配置备份与home快照、启动验证及失败回滚,无需先手动停止。home快照最大1 GiB;软链接按链接本身保存,保留相对、绝对、失效及循环链接的目标文本,不读取或备份链接指向的外部内容;仍拒绝 socket、FIFO 等特殊文件。模板7的 `/workspace` 是实例home,包含在home回滚内;旧共享挂载布局会在升级前被拒绝。跨DSH版本历史验证范围见 ACCEPTANCE.md。
|
||||
- 升级快照先复制到 `.upgrade-<ID>-snapshot-partial`,完成后才发布正式目录并记录完成状态。复制失败会清理临时目录;清理失败会报告并记录 `upgrade.snapshot.cleanup_failed`,管理员核实后处理。进程中断也可能留下临时目录,不会作为可恢复快照列出。已发布但未记录完成的快照会显示不可恢复。旧版残留没有可靠的完成记录,本版本不会自动判定其完整或自动删除,建议保留证据并重新生成快照。
|
||||
- 完整快照恢复和下载同样保留软链接原文;失效链接不会自动修复,外部目标不属于恢复范围。下载的 tar.gz 可能包含绝对或目录外链接,应在隔离目录使用安全解压工具,不要跟随链接写入宿主机。独立的加密配置备份仍只接受白名单普通文件,配置路径和资源中的软链接仍会被拒绝。
|
||||
- Profile是例外:当前需先停止实例,再使用“保存并启动”;页面包含Monaco和脱敏配置预览。不要把备份/升级自动停启理解为Profile可在线改写。
|
||||
- 若实例进入 `error`,不要直接修改数据库状态强制启动。保留原容器、实例目录与 `upgrade_journal` 记录,检查实际容器与 journal 的旧/新容器 ID、阶段、快照路径是否一致。
|
||||
- 人工恢复前停止相关容器并另做完整备份。中断点不同,不能统一用一次目录覆盖或状态修改处理;当前无自动灾难恢复命令。
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ func TestRestoreSnapshotCopyFailureDoesNotConsumeRetention(t *testing.T) {
|
||||
}
|
||||
configWrite(t, filepath.Join(base, name, "keep"), name)
|
||||
}
|
||||
if err := os.Symlink("missing-target", filepath.Join(home, "dangling")); err != nil {
|
||||
if err := syscall.Mkfifo(filepath.Join(home, "dangling"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, result.ID)
|
||||
|
||||
@@ -62,13 +62,7 @@ func snapshotArchive(ctx context.Context, root *os.Root, out io.Writer, limit in
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), link)) {
|
||||
return errUnsafeSnapshotArchive
|
||||
}
|
||||
// Root resolution also catches escapes through chains of relative links.
|
||||
if _, err = root.Stat(rel); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return errUnsafeSnapshotArchive
|
||||
}
|
||||
// Archive link text only; do not read or traverse its target.
|
||||
} else if !st.IsDir() && !st.Mode().IsRegular() {
|
||||
return errUnsafeSnapshotArchive
|
||||
}
|
||||
|
||||
@@ -192,15 +192,39 @@ func TestSnapshotDownloadRejectsUnsafeAndForeign(t *testing.T) {
|
||||
if w.Code != 404 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
for _, target := range []string{"../home", "/etc/passwd", "../../elsewhere"} {
|
||||
for _, target := range []string{"../home", "/workspace/example", "../../elsewhere", "missing", "link"} {
|
||||
if err := os.Symlink(target, filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i)
|
||||
if w.Code != 400 || w.Header().Get("Content-Disposition") != "" {
|
||||
if w.Code != 200 || w.Header().Get("Content-Disposition") == "" {
|
||||
t.Fatal(target, w.Code)
|
||||
}
|
||||
gz, err := gzip.NewReader(w.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
found := false
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.Name == "link" {
|
||||
found = h.Typeflag == tar.TypeSymlink && h.Linkname == target && h.Size == 0
|
||||
} else if h.Name != "./" {
|
||||
t.Fatalf("unexpected archived entry: %s", h.Name)
|
||||
}
|
||||
}
|
||||
gz.Close()
|
||||
if !found {
|
||||
t.Fatal("link text was not preserved")
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,19 @@ func (a *API) recordSnapshot(i store.Instance, id, version, image string) error
|
||||
|
||||
func (a *API) snapshotCompatibility(i store.Instance, id string) (version, image, reason string) {
|
||||
var exists int
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='snapshot_copy_state'").Scan(&exists); err != nil {
|
||||
return "", "", "snapshot completion metadata unavailable"
|
||||
}
|
||||
if exists != 0 {
|
||||
var ready int
|
||||
err := a.s.DB.QueryRow("SELECT ready FROM snapshot_copy_state WHERE instance_id=? AND id=?", i.ID, id).Scan(&ready)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", "snapshot completion metadata unavailable"
|
||||
}
|
||||
if err == nil && ready != 1 {
|
||||
return "", "", "snapshot copy incomplete"
|
||||
}
|
||||
}
|
||||
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='snapshot_metadata'").Scan(&exists); err != nil {
|
||||
return "", "", "snapshot metadata unavailable"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -122,6 +123,12 @@ func TestSnapshotFullRestore(t *testing.T) {
|
||||
for _, running := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(running), func(t *testing.T) {
|
||||
a, i, home, id := snapshotRestoreFixture(t, running, "")
|
||||
links := map[string]string{"absolute": "/workspace/example", "dangling": "missing", "loop": "loop", "relative": "data"}
|
||||
for name, target := range links {
|
||||
if err := os.Symlink(target, filepath.Join(filepath.Dir(home), id, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -140,6 +147,12 @@ func TestSnapshotFullRestore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
current, _ := a.s.GetInstance(i.ID)
|
||||
for name, target := range links {
|
||||
got, err := os.Readlink(filepath.Join(home, name))
|
||||
if err != nil || got != target {
|
||||
t.Fatalf("restored link %s: %q %v", name, got, err)
|
||||
}
|
||||
}
|
||||
want := "exited"
|
||||
if running {
|
||||
want = "running"
|
||||
@@ -169,6 +182,40 @@ func TestSnapshotRestoreRollback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncompleteSnapshotNotRestorable(t *testing.T) {
|
||||
a, i, _, id := snapshotRestoreFixture(t, false, "")
|
||||
if _, err := a.s.DB.Exec("CREATE TABLE snapshot_copy_state (instance_id TEXT, id TEXT, ready INTEGER)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.s.DB.Exec("INSERT INTO snapshot_copy_state VALUES(?,?,0)", i.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := configCall(a, i, "GET", "snapshots", "")
|
||||
var list []snapshotInfo
|
||||
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &list) != nil || len(list) != 1 || list[0].Restorable || list[0].RestoreReason != "snapshot copy incomplete" {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
w = configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, true))
|
||||
if w.Code != 409 || !strings.Contains(w.Body.String(), "snapshot copy incomplete") {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.s.DB.Exec("UPDATE snapshot_copy_state SET ready=1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, reason := a.snapshotCompatibility(i, id); reason != "" {
|
||||
t.Fatal(reason)
|
||||
}
|
||||
if _, err := a.s.DB.Exec("ALTER TABLE snapshot_copy_state RENAME COLUMN ready TO unavailable"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, reason := a.snapshotCompatibility(i, id); reason != "snapshot completion metadata unavailable" {
|
||||
t.Fatal(reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotRestoreGuards(t *testing.T) {
|
||||
for _, test := range []string{"unknown", "mismatch", "password", "confirm", "reader", "quota", "error", "unsafe", "foreign", "oversized"} {
|
||||
t.Run(test, func(t *testing.T) {
|
||||
@@ -196,7 +243,7 @@ func TestSnapshotRestoreGuards(t *testing.T) {
|
||||
case "error":
|
||||
a.s.SetInstance(i.ID, "error")
|
||||
case "unsafe":
|
||||
if err := os.Symlink("/etc/passwd", filepath.Join(filepath.Dir(home), id, "unsafe")); err != nil {
|
||||
if err := syscall.Mkfifo(filepath.Join(filepath.Dir(home), id, "unsafe"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want = 400
|
||||
|
||||
+45
-20
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -100,9 +101,17 @@ func upgradeSync(r *os.Root) error {
|
||||
|
||||
func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit int64) error {
|
||||
remaining, entries := limit, 100000
|
||||
var links []string
|
||||
var copyTree func(string, string, int) error
|
||||
copyTree = func(src, dst string, depth int) error {
|
||||
copyTree = func(src, dst string, depth int) (copyErr error) {
|
||||
defer func() {
|
||||
if copyErr != nil {
|
||||
if pathErr, ok := copyErr.(*os.PathError); ok {
|
||||
copyErr = fmt.Errorf("snapshot copy: %w", &os.PathError{Op: pathErr.Op, Path: src, Err: pathErr.Err})
|
||||
return
|
||||
}
|
||||
copyErr = fmt.Errorf("%s: %w", src, copyErr)
|
||||
}
|
||||
}()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -155,14 +164,11 @@ func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(source, src)
|
||||
if err != nil || filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), link)) {
|
||||
return errors.New("unsafe snapshot link")
|
||||
}
|
||||
// Preserve the link itself, including dangling and container-absolute
|
||||
// targets. Never resolve it against the panel's filesystem.
|
||||
if err = r.Symlink(link, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
links = append(links, rel)
|
||||
return r.Lchown(dst, 1000, 1000)
|
||||
}
|
||||
if !st.Mode().IsRegular() || st.Size() > remaining {
|
||||
@@ -201,17 +207,6 @@ func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit i
|
||||
if err := copyTree(source, target, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot, err := r.OpenRoot(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer snapshot.Close()
|
||||
// Resolve only within the completed snapshot, including intermediate symlinks.
|
||||
for _, link := range links {
|
||||
if _, err := snapshot.Stat(link); err != nil {
|
||||
return errors.New("unsafe or unresolved snapshot link")
|
||||
}
|
||||
}
|
||||
return upgradeSync(r)
|
||||
}
|
||||
|
||||
@@ -460,8 +455,38 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance)
|
||||
fail(w, 413, "quota exceeded or snapshot size unavailable")
|
||||
return
|
||||
}
|
||||
if upgradeCopy(ctx, root, "home", snapshot, snapshotBytes) != nil {
|
||||
fail(w, 500, "home snapshot failed")
|
||||
// Only publish a completed copy under a name the snapshot API recognizes.
|
||||
staging := "." + snapshot + "-partial"
|
||||
if _, err := a.s.DB.Exec("CREATE TABLE IF NOT EXISTS snapshot_copy_state (instance_id TEXT NOT NULL, id TEXT NOT NULL, ready INTEGER NOT NULL, PRIMARY KEY(instance_id,id))"); err != nil {
|
||||
fail(w, 500, "snapshot completion metadata unavailable")
|
||||
return
|
||||
}
|
||||
if _, err := a.s.DB.Exec("INSERT INTO snapshot_copy_state VALUES(?,?,0)", i.ID, snapshot); err != nil {
|
||||
fail(w, 500, "snapshot completion metadata unavailable")
|
||||
return
|
||||
}
|
||||
if copyErr := upgradeCopy(ctx, root, "home", staging, snapshotBytes); copyErr != nil {
|
||||
if cleanupErr := root.RemoveAll(staging); cleanupErr != nil {
|
||||
a.s.AddAudit(i.UID, "upgrade.snapshot.cleanup_failed", i.ID, staging)
|
||||
fail(w, 500, "home snapshot failed: "+copyErr.Error()+"; partial snapshot cleanup required")
|
||||
return
|
||||
}
|
||||
fail(w, 500, "home snapshot failed: "+copyErr.Error())
|
||||
return
|
||||
}
|
||||
if err := root.Rename(staging, snapshot); err != nil {
|
||||
if root.RemoveAll(staging) != nil {
|
||||
a.s.AddAudit(i.UID, "upgrade.snapshot.cleanup_failed", i.ID, staging)
|
||||
}
|
||||
fail(w, 500, "home snapshot publication failed")
|
||||
return
|
||||
}
|
||||
if upgradeSync(root) != nil {
|
||||
fail(w, 500, "home snapshot publication sync failed")
|
||||
return
|
||||
}
|
||||
if _, err := a.s.DB.Exec("UPDATE snapshot_copy_state SET ready=1 WHERE instance_id=? AND id=?", i.ID, snapshot); err != nil {
|
||||
fail(w, 500, "snapshot completion metadata unavailable")
|
||||
return
|
||||
}
|
||||
snapReady = true
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user