package backup import ( "archive/tar" "bytes" "compress/gzip" "encoding/json" "fmt" "io" "net" "os" "path/filepath" "testing" "time" ) var testKey = bytes.Repeat([]byte{42}, 32) func put(t *testing.T, name string, data []byte) { t.Helper() if err := os.MkdirAll(filepath.Dir(name), 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(name, data, 0600); err != nil { t.Fatal(err) } } func sealed(t *testing.T, p payload) string { t.Helper() plain, err := json.Marshal(p) if err != nil { t.Fatal(err) } return sealPlain(t, magic, plain) } func sealPlain(t *testing.T, header string, plain []byte) string { t.Helper() gcm, err := newGCM(testKey) if err != nil { t.Fatal(err) } nonce := make([]byte, gcm.NonceSize()) data := append([]byte(header), nonce...) data = gcm.Seal(data, nonce, plain, []byte(header)) name := filepath.Join(t.TempDir(), "archive") put(t, name, data) return name } func TestResourcesAndManifest(t *testing.T) { home, dest := t.TempDir(), t.TempDir() for _, dir := range resources { put(t, filepath.Join(home, dir, "nested/资源.bin"), []byte{0, 1, 255}) } for _, name := range []string{"node_modules/pkg", "worktrees/x", "sessions/x", "logs/x", "manifest.json"} { put(t, filepath.Join(home, name), []byte("excluded")) } metadata := Metadata{DSHVersion: "1.2.3", CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)} archive := filepath.Join(t.TempDir(), "archive") if err := CreateWithMetadata(home, archive, testKey, metadata); err != nil { t.Fatal(err) } data, err := os.ReadFile(archive) if err != nil { t.Fatal(err) } if !bytes.HasPrefix(data, []byte(magicV2)) { t.Fatal("not v2") } gcm, _ := newGCM(testKey) nonce := data[len(magicV2) : len(magicV2)+gcm.NonceSize()] plain, err := gcm.Open(nil, nonce, data[len(magicV2)+gcm.NonceSize():], []byte(magicV2)) if err != nil { t.Fatal(err) } gz, err := gzip.NewReader(bytes.NewReader(plain)) if err != nil { t.Fatal(err) } defer gz.Close() tr := tar.NewReader(gz) h, err := tr.Next() if err != nil || h.Name != "manifest.json" { t.Fatal("missing manifest", err) } b, err := io.ReadAll(tr) if err != nil { t.Fatal(err) } var m manifest if err := json.Unmarshal(b, &m); err != nil { t.Fatal(err) } if m.SchemaVersion != SchemaVersion || m.Metadata != metadata { t.Fatalf("manifest: %+v", m) } if err := Restore(archive, dest, testKey); err != nil { t.Fatal(err) } for _, dir := range resources { b, err := os.ReadFile(filepath.Join(dest, dir, "nested/资源.bin")) if err != nil || !bytes.Equal(b, []byte{0, 1, 255}) { t.Fatal(dir, err, b) } } if _, err := os.Stat(filepath.Join(dest, "manifest.json")); !os.IsNotExist(err) { t.Fatal("manifest restored into home") } } func TestLegacyRestore(t *testing.T) { home := t.TempDir() if err := Restore(sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("legacy")}}), home, testKey); err != nil { t.Fatal(err) } b, err := os.ReadFile(filepath.Join(home, allowed[0])) if err != nil || string(b) != "legacy" { t.Fatal(err, string(b)) } } type tarEntry struct { name string kind byte data []byte } func makeTar(t *testing.T, entries []tarEntry) []byte { t.Helper() var b bytes.Buffer gz := gzip.NewWriter(&b) tw := tar.NewWriter(gz) for _, e := range entries { h := &tar.Header{Name: e.name, Typeflag: e.kind, Mode: 0600, Size: int64(len(e.data))} if e.kind == tar.TypeSymlink || e.kind == tar.TypeLink { h.Linkname = "target" } if err := tw.WriteHeader(h); err != nil { t.Fatal(err) } if _, err := tw.Write(e.data); err != nil { t.Fatal(err) } } if err := tw.Close(); err != nil { t.Fatal(err) } if err := gz.Close(); err != nil { t.Fatal(err) } return b.Bytes() } func TestV2RejectsInvalidArchivesBeforeWrites(t *testing.T) { m, _ := json.Marshal(manifest{SchemaVersion, Metadata{"test", time.Now().UTC()}}) base := []tarEntry{{"manifest.json", tar.TypeReg, m}, {allowed[0], tar.TypeReg, []byte("new")}} cases := map[string][]tarEntry{ "missing manifest": base[1:], "duplicate manifest": append(append([]tarEntry{}, base...), base[0]), "duplicate file": append(append([]tarEntry{}, base...), base[1]), "invalid manifest": {{"manifest.json", tar.TypeReg, []byte(`{"schema_version":3}`)}}, "conflict": append(append([]tarEntry{}, base...), tarEntry{"pets/a", tar.TypeReg, nil}, tarEntry{"pets/a/b", tar.TypeReg, nil}), } for _, name := range []string{"pets/../escape", "pets//a", "pets/./a", "/pets/a", "pets/a/../../escape", "pets/a\\b", "pets/C:bad", "pets", "other/a"} { cases[name] = append(append([]tarEntry{}, base...), tarEntry{name, tar.TypeReg, nil}) } for _, kind := range []byte{tar.TypeSymlink, tar.TypeLink, tar.TypeFifo, tar.TypeChar, tar.TypeBlock, tar.TypeDir} { cases[fmt.Sprint(kind)] = append(append([]tarEntry{}, base...), tarEntry{"pets/unsafe", kind, nil}) } for name, entries := range cases { t.Run(name, func(t *testing.T) { home := t.TempDir() put(t, filepath.Join(home, allowed[0]), []byte("old")) if err := Restore(sealPlain(t, magicV2, makeTar(t, entries)), home, testKey); err == nil { t.Fatal("accepted invalid archive") } b, _ := os.ReadFile(filepath.Join(home, allowed[0])) if string(b) != "old" { t.Fatal("partial write") } }) } } func TestResourceFilesystemRejections(t *testing.T) { for _, name := range []string{"pets", "pets/nested", "pets/nested/file"} { t.Run(name, func(t *testing.T) { home := t.TempDir() if err := os.MkdirAll(filepath.Dir(filepath.Join(home, name)), 0700); err != nil { t.Fatal(err) } if err := os.Symlink(t.TempDir(), filepath.Join(home, name)); err != nil { t.Fatal(err) } if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil { t.Fatal("followed resource symlink") } p, err := encodeV2(map[string][]byte{"pets/nested/file": []byte("bad")}, Metadata{}) if err != nil { t.Fatal(err) } if err := Restore(sealPlain(t, magicV2, p), home, testKey); err == nil { t.Fatal("restore followed symlink") } }) } home := t.TempDir() if err := os.Mkdir(filepath.Join(home, "pets"), 0700); err != nil { t.Fatal(err) } l, err := net.Listen("unix", filepath.Join(home, "pets/socket")) if err != nil { t.Fatal(err) } defer l.Close() if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil { t.Fatal("special file accepted") } } func TestV2LimitsAndCorruption(t *testing.T) { files := make(map[string][]byte) home := t.TempDir() for i := 0; i < MaxFiles; i++ { name := fmt.Sprintf("pets/%d", i) files[name] = nil put(t, filepath.Join(home, name), nil) } archive := filepath.Join(t.TempDir(), "archive") if err := Create(home, archive, testKey); err != nil { t.Fatal(err) } if err := Restore(archive, t.TempDir(), testKey); err != nil { t.Fatal(err) } put(t, filepath.Join(home, "pets/extra"), nil) if err := Create(home, archive, testKey); err == nil { t.Fatal("file count accepted") } files["pets/extra"] = nil p, err := encodeV2(files, Metadata{}) if err != nil { t.Fatal(err) } if _, err := decodeV2(p); err == nil { t.Fatal("archive file count accepted") } p, err = encodeV2(map[string][]byte{"pets/large": make([]byte, MaxTotalBytes+1)}, Metadata{}) if err != nil { t.Fatal(err) } if _, err := decodeV2(p); err == nil { t.Fatal("expanded file limit accepted") } p, err = encodeV2(nil, Metadata{}) if err != nil { t.Fatal(err) } badChecksum := bytes.Clone(p) badChecksum[len(badChecksum)-8] ^= 1 for _, bad := range [][]byte{p[:len(p)-1], append(bytes.Clone(p), 0), append(bytes.Clone(p), p...), badChecksum} { if _, err := decodeV2(bad); err == nil { t.Fatal("corrupt gzip accepted") } } if _, err := encodeV2(nil, Metadata{DSHVersion: string(make([]byte, maxManifestBytes))}); err == nil { t.Fatal("oversized manifest accepted") } } func TestRoundTrip(t *testing.T) { home, dest := t.TempDir(), t.TempDir() for _, name := range allowed { put(t, filepath.Join(home, name), []byte("secret:"+name)) } put(t, filepath.Join(home, "not-allowed"), []byte("ignore")) archive := filepath.Join(t.TempDir(), "archive") if err := Create(home, archive, testKey); err != nil { t.Fatal(err) } first, err := os.ReadFile(archive) if err != nil { t.Fatal(err) } if bytes.Contains(first, []byte("secret:")) { t.Fatal("plaintext leaked") } if err := Create(home, archive, testKey); err != nil { t.Fatal(err) } second, err := os.ReadFile(archive) if err != nil { t.Fatal(err) } if bytes.Equal(first, second) { t.Fatal("nonce reused") } put(t, filepath.Join(dest, allowed[0]), []byte("old")) if err := os.Chmod(filepath.Join(dest, allowed[0]), 0644); err != nil { t.Fatal(err) } if err := Restore(archive, dest, testKey); err != nil { t.Fatal(err) } for _, name := range allowed { data, err := os.ReadFile(filepath.Join(dest, name)) if err != nil { t.Fatal(err) } if string(data) != "secret:"+name { t.Fatalf("wrong data: %s", name) } info, err := os.Stat(filepath.Join(dest, name)) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0600 { t.Fatalf("mode: %v", info.Mode()) } } if _, err := os.Stat(filepath.Join(dest, "not-allowed")); !os.IsNotExist(err) { t.Fatal("unlisted file restored") } info, err := os.Stat(archive) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0600 { t.Fatal("archive permissions") } } func TestAuthentication(t *testing.T) { archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new")}}) original, err := os.ReadFile(archive) if err != nil { t.Fatal(err) } for _, offset := range []int{0, len(magic), len(original) - 1} { t.Run(fmt.Sprint(offset), func(t *testing.T) { data := bytes.Clone(original) data[offset] ^= 1 put(t, archive, data) home := t.TempDir() put(t, filepath.Join(home, allowed[0]), []byte("old")) if err := Restore(archive, home, testKey); err == nil { t.Fatal("tampering accepted") } got, _ := os.ReadFile(filepath.Join(home, allowed[0])) if string(got) != "old" { t.Fatal("modified on failure") } }) } put(t, archive, original) if err := Restore(archive, t.TempDir(), bytes.Repeat([]byte{1}, 32)); err == nil { t.Fatal("wrong key accepted") } put(t, archive, original[:len(original)-1]) if err := Restore(archive, t.TempDir(), testKey); err == nil { t.Fatal("truncation accepted") } } func TestInvalidPathsAndVersion(t *testing.T) { for _, name := range []string{"../escape", "/tmp/escape", "profiles/web/../../../escape", "profiles//web/package.json", "other", "profiles\\web\\package.json"} { t.Run(name, func(t *testing.T) { home := t.TempDir() put(t, filepath.Join(home, allowed[0]), []byte("old")) archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new"), name: []byte("bad")}}) if err := Restore(archive, home, testKey); err == nil { t.Fatal("invalid path accepted") } got, _ := os.ReadFile(filepath.Join(home, allowed[0])) if string(got) != "old" { t.Fatal("partial restore before validation") } }) } if err := Restore(sealed(t, payload{2, map[string][]byte{}}), t.TempDir(), testKey); err == nil { t.Fatal("version accepted") } files := map[string][]byte{} for i := 0; i < MaxFiles+1; i++ { files[fmt.Sprint(i)] = nil } if err := Restore(sealed(t, payload{1, files}), t.TempDir(), testKey); err == nil { t.Fatal("file count accepted") } } func TestSymlinks(t *testing.T) { for _, outside := range []bool{false, true} { for _, parent := range []bool{false, true} { t.Run(fmt.Sprintf("outside=%v/parent=%v", outside, parent), func(t *testing.T) { home := t.TempDir() target := filepath.Join(home, "target") linkTarget := "target" if outside { target = filepath.Join(t.TempDir(), "target") linkTarget = target } name := allowed[0] link := filepath.Join(home, name) if parent { name = "profiles/web/package.json" link = filepath.Join(home, "profiles") put(t, filepath.Join(target, "web/package.json"), []byte("safe")) } else { put(t, target, []byte("safe")) } if err := os.Symlink(linkTarget, link); err != nil { t.Fatal(err) } if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil { t.Fatal("create followed symlink") } archive := sealed(t, payload{1, map[string][]byte{name: []byte("bad")}}) if err := Restore(archive, home, testKey); err == nil { t.Fatal("restore followed symlink") } }) } } } func TestSymlinkPreflightAndArchive(t *testing.T) { home := t.TempDir() put(t, filepath.Join(home, allowed[0]), []byte("old")) target := filepath.Join(t.TempDir(), "target") put(t, target, []byte("safe")) if err := os.Symlink(target, filepath.Join(home, "dsh-ssh.json")); err != nil { t.Fatal(err) } archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new"), "dsh-ssh.json": []byte("bad")}}) if err := Restore(archive, home, testKey); err == nil { t.Fatal("symlink accepted") } got, err := os.ReadFile(filepath.Join(home, allowed[0])) if err != nil { t.Fatal(err) } if string(got) != "old" { t.Fatal("preflight modified earlier file") } got, err = os.ReadFile(target) if err != nil { t.Fatal(err) } if string(got) != "safe" { t.Fatal("outside target modified") } link := filepath.Join(t.TempDir(), "archive-link") if err := os.Symlink(archive, link); err != nil { t.Fatal(err) } if err := Restore(link, t.TempDir(), testKey); err == nil { t.Fatal("archive symlink accepted") } if err := Create(t.TempDir(), link, testKey); err == nil { t.Fatal("destination symlink accepted") } } func TestSizeLimits(t *testing.T) { home := t.TempDir() name := filepath.Join(home, allowed[0]) put(t, name, nil) if err := os.Truncate(name, MaxTotalBytes+1); err != nil { t.Fatal(err) } archive := filepath.Join(t.TempDir(), "archive") if err := Create(home, archive, testKey); err == nil { t.Fatal("oversized file accepted") } if err := os.Truncate(name, MaxTotalBytes); err != nil { t.Fatal(err) } if err := Create(home, archive, testKey); err != nil { t.Fatal("exact limit:", err) } if err := Restore(archive, t.TempDir(), testKey); err != nil { t.Fatal("exact limit restore:", err) } put(t, filepath.Join(home, allowed[1]), []byte{1}) if err := Create(home, archive, testKey); err == nil { t.Fatal("aggregate limit accepted") } archive = sealed(t, payload{1, map[string][]byte{allowed[0]: make([]byte, MaxTotalBytes), allowed[1]: {1}}}) if err := Restore(archive, t.TempDir(), testKey); err == nil { t.Fatal("restore aggregate limit accepted") } put(t, archive, nil) if err := os.Truncate(archive, maxArchiveBytes+1); err != nil { t.Fatal(err) } if err := Restore(archive, t.TempDir(), testKey); err == nil { t.Fatal("oversized archive accepted") } } func TestMissingFilesAndInvalidKey(t *testing.T) { archive := filepath.Join(t.TempDir(), "archive") if err := Create(t.TempDir(), archive, nil); err == nil { t.Fatal("invalid key accepted") } if err := Create(t.TempDir(), archive, testKey); err != nil { t.Fatal(err) } home := t.TempDir() put(t, filepath.Join(home, allowed[0]), []byte("keep")) if err := Restore(archive, home, testKey); err != nil { t.Fatal(err) } got, _ := os.ReadFile(filepath.Join(home, allowed[0])) if string(got) != "keep" { t.Fatal("missing file removed") } }