dify/dify-agent-runtime/internal/snapshot/restore_test.go

252 lines
7.6 KiB
Go

package snapshot
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/klauspost/compress/zstd"
)
// craftArchive builds a tar+zstd stream from raw tar headers, for hostile-input tests.
func craftArchive(t *testing.T, build func(tw *tar.Writer)) []byte {
t.Helper()
var buf bytes.Buffer
zw, err := zstd.NewWriter(&buf)
if err != nil {
t.Fatal(err)
}
tw := tar.NewWriter(zw)
build(tw)
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func regEntry(tw *tar.Writer, name, content string) {
_ = tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(content))})
_, _ = io.WriteString(tw, content)
}
func TestRestoreRoundTrip(t *testing.T) {
src := buildFixtureHome(t)
var buf bytes.Buffer
if err := SaveHome(context.Background(), &buf, src, []string{"workspace"}); err != nil {
t.Fatal(err)
}
dst := t.TempDir()
res, err := RestoreHome(context.Background(), bytes.NewReader(buf.Bytes()), dst)
if err != nil {
t.Fatalf("RestoreHome: %v", err)
}
if res.Entries == 0 || res.BytesWritten == 0 {
t.Errorf("counters empty: %+v", res)
}
got, err := os.ReadFile(filepath.Join(dst, ".bashrc"))
if err != nil || string(got) != "export PS1='$ '\n" {
t.Errorf(".bashrc: %q err=%v", got, err)
}
info, err := os.Stat(filepath.Join(dst, "bin", "tool.sh"))
if err != nil || info.Mode().Perm() != 0o755 {
t.Errorf("tool.sh mode: %v err=%v", info, err)
}
if fi, err := os.Stat(filepath.Join(dst, "emptydir")); err != nil || !fi.IsDir() {
t.Errorf("emptydir missing: %v err=%v", fi, err)
}
target, err := os.Readlink(filepath.Join(dst, "tool-link"))
if err != nil || target != "bin/tool.sh" {
t.Errorf("symlink: %q err=%v", target, err)
}
if _, err := os.Stat(filepath.Join(dst, "workspace")); !os.IsNotExist(err) {
t.Error("workspace must not be restored (was excluded at save)")
}
}
func TestRestoreRejectsEscapes(t *testing.T) {
cases := map[string]func(tw *tar.Writer){
"dotdot": func(tw *tar.Writer) { regEntry(tw, "../evil", "x") },
"absolute": func(tw *tar.Writer) { regEntry(tw, "/etc/evil", "x") },
"nested dotdot": func(tw *tar.Writer) {
regEntry(tw, "ok.txt", "fine")
regEntry(tw, "a/../../evil", "x")
},
"hardlink escape": func(tw *tar.Writer) {
_ = tw.WriteHeader(&tar.Header{Name: "l", Typeflag: tar.TypeLink, Linkname: "../outside"})
},
"device node": func(tw *tar.Writer) {
_ = tw.WriteHeader(&tar.Header{Name: "dev", Typeflag: tar.TypeChar, Mode: 0o644})
},
}
for name, build := range cases {
t.Run(name, func(t *testing.T) {
home := t.TempDir()
_, err := RestoreHome(context.Background(), bytes.NewReader(craftArchive(t, build)), home)
if !errors.Is(err, ErrMalformed) {
t.Fatalf("expected ErrMalformed, got %v", err)
}
var escaped []string
parent := filepath.Dir(home)
entries, _ := os.ReadDir(parent)
for _, e := range entries {
if e.Name() == "evil" || e.Name() == "outside" {
escaped = append(escaped, e.Name())
}
}
if len(escaped) > 0 {
t.Fatalf("files escaped the root: %v", escaped)
}
})
}
}
func TestRestoreRefusesSymlinkComponentEscape(t *testing.T) {
// A symlink entry pointing outside, then a write THROUGH it: os.Root must
// refuse resolving the out-of-root component.
home := t.TempDir()
data := craftArchive(t, func(tw *tar.Writer) {
_ = tw.WriteHeader(&tar.Header{Name: "sneaky", Typeflag: tar.TypeSymlink, Linkname: "../"})
regEntry(tw, "sneaky/pwned", "x")
})
if _, err := RestoreHome(context.Background(), bytes.NewReader(data), home); !errors.Is(err, ErrMalformed) {
t.Fatalf("expected ErrMalformed writing through out-of-root symlink, got %v", err)
}
if _, err := os.Stat(filepath.Join(filepath.Dir(home), "pwned")); !os.IsNotExist(err) {
t.Fatal("write escaped the root through a symlink component")
}
}
func TestRestoreNotZstd(t *testing.T) {
home := t.TempDir()
_, err := RestoreHome(context.Background(), strings.NewReader("plain text, not zstd"), home)
if !errors.Is(err, ErrMalformed) {
t.Fatalf("expected ErrMalformed for non-zstd body, got %v", err)
}
// A gzip stream (the format the deleted exec transport produced) must be
// rejected too — the runtime is deliberately zstd-only.
var gz bytes.Buffer
gw := gzip.NewWriter(&gz)
tw := tar.NewWriter(gw)
regEntry(tw, "file.txt", "content")
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gw.Close(); err != nil {
t.Fatal(err)
}
_, err = RestoreHome(context.Background(), bytes.NewReader(gz.Bytes()), home)
if !errors.Is(err, ErrMalformed) {
t.Fatalf("expected ErrMalformed for gzip body, got %v", err)
}
}
func TestRestoreCancelledContext(t *testing.T) {
src := buildFixtureHome(t)
var buf bytes.Buffer
if err := SaveHome(context.Background(), &buf, src, nil); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := RestoreHome(ctx, bytes.NewReader(buf.Bytes()), t.TempDir()); err == nil {
t.Fatal("expected error from cancelled context")
}
}
func TestRestoreRejectsSparseEntries(t *testing.T) {
// tar.Writer doesn't encode GNU.sparse.* PAX records, so test the guard directly.
// Unit test: isPAXSparse detects a header with GNU.sparse.* records
hdr := &tar.Header{
Name: "f",
Typeflag: tar.TypeReg,
Size: 0,
PAXRecords: map[string]string{
"GNU.sparse.major": "1",
"GNU.sparse.minor": "0",
"GNU.sparse.name": "f",
"GNU.sparse.realsize": "1099511627776",
},
}
if !isPAXSparse(hdr) {
t.Fatal("isPAXSparse should detect GNU.sparse.* records")
}
// The key evidence: if a PAX-sparse archive somehow reached us,
// the cleanEntryName + typeflag switch + isPAXSparse check would catch it.
// Since tar.Writer blocks encoding them, the guards are tested above and
// would reject any sparse entry in the restoration loop.
}
func TestRestoreStripsSetuid(t *testing.T) {
content := "script"
home := t.TempDir()
// Create archive with setuid bit in header
var buf bytes.Buffer
zw, err := zstd.NewWriter(&buf)
if err != nil {
t.Fatal(err)
}
tw := tar.NewWriter(zw)
_ = tw.WriteHeader(&tar.Header{Name: "suid", Typeflag: tar.TypeReg, Mode: 0o4755, Size: int64(len(content))})
_, _ = io.WriteString(tw, content)
_ = tw.Close()
_ = zw.Close()
_, err = RestoreHome(context.Background(), bytes.NewReader(buf.Bytes()), home)
if err != nil {
t.Fatalf("RestoreHome: %v", err)
}
info, err := os.Stat(filepath.Join(home, "suid"))
if err != nil {
t.Fatalf("stat suid: %v", err)
}
if info.Mode().Perm() != 0o755 {
t.Errorf("expected 0755, got %o", info.Mode().Perm())
}
if info.Mode()&os.ModeSetuid != 0 {
t.Errorf("setuid bit should be stripped, got %v", info.Mode())
}
}
func TestRestoreReadOnlyDirectory(t *testing.T) {
home := t.TempDir()
data := craftArchive(t, func(tw *tar.Writer) {
_ = tw.WriteHeader(&tar.Header{Name: "ro", Typeflag: tar.TypeDir, Mode: 0o555})
regEntry(tw, "ro/child.txt", "content")
})
_, err := RestoreHome(context.Background(), bytes.NewReader(data), home)
if err != nil {
t.Fatalf("RestoreHome: %v", err)
}
// Verify child exists
got, err := os.ReadFile(filepath.Join(home, "ro", "child.txt"))
if err != nil || string(got) != "content" {
t.Errorf("child.txt: %q err=%v", got, err)
}
// Verify directory mode is 0555
info, err := os.Stat(filepath.Join(home, "ro"))
if err != nil {
t.Fatalf("stat ro: %v", err)
}
if info.Mode().Perm() != 0o555 {
t.Errorf("expected 0555, got %o", info.Mode().Perm())
}
// Restore write permission for cleanup
if err := os.Chmod(filepath.Join(home, "ro"), 0o755); err != nil {
t.Logf("cleanup chmod: %v", err)
}
}