Files

522 lines
15 KiB
Go

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")
}
}