mirror of
https://github.com/langgenius/dify.git
synced 2026-08-24 12:32:54 -04:00
210 lines
6.3 KiB
Go
210 lines
6.3 KiB
Go
package snapshot
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/klauspost/compress/zstd"
|
|
)
|
|
|
|
// buildFixtureHome creates a Home tree exercising files, modes, nesting,
|
|
// symlinks, an empty dir, and the excluded runtime state dir.
|
|
func buildFixtureHome(t *testing.T) string {
|
|
t.Helper()
|
|
home := t.TempDir()
|
|
mustWrite := func(rel, content string, mode os.FileMode) {
|
|
t.Helper()
|
|
p := filepath.Join(home, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(content), mode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
mustWrite(".bashrc", "export PS1='$ '\n", 0o644)
|
|
mustWrite("bin/tool.sh", "#!/bin/sh\necho hi\n", 0o755)
|
|
mustWrite("workspace/notes.txt", "ordinary home content", 0o644)
|
|
mustWrite(".local/share/shellctl/shellctl.db", "live server state", 0o644)
|
|
mustWrite(".local/bin/agent-tool", "#!/bin/sh\necho tool\n", 0o755)
|
|
if err := os.MkdirAll(filepath.Join(home, "emptydir"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("bin/tool.sh", filepath.Join(home, "tool-link")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return home
|
|
}
|
|
|
|
// decodeArchive reads a tar+zstd stream into name -> header/content maps.
|
|
func decodeArchive(t *testing.T, data []byte) (map[string]*tar.Header, map[string][]byte) {
|
|
t.Helper()
|
|
zr, err := zstd.NewReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
t.Fatalf("not a zstd stream: %v", err)
|
|
}
|
|
defer zr.Close()
|
|
headers := map[string]*tar.Header{}
|
|
contents := map[string][]byte{}
|
|
tr := tar.NewReader(zr)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
return headers, contents
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("tar decode: %v", err)
|
|
}
|
|
headers[hdr.Name] = hdr
|
|
if hdr.Typeflag == tar.TypeReg {
|
|
body, err := io.ReadAll(tr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
contents[hdr.Name] = body
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSaveHomeArchivesTreeWithoutExcludes(t *testing.T) {
|
|
home := buildFixtureHome(t)
|
|
var buf bytes.Buffer
|
|
if err := SaveHome(context.Background(), &buf, home, nil); err != nil {
|
|
t.Fatalf("SaveHome: %v", err)
|
|
}
|
|
headers, contents := decodeArchive(t, buf.Bytes())
|
|
|
|
if _, ok := headers["workspace/notes.txt"]; !ok {
|
|
t.Error("a Home directory named workspace is ordinary content and must be archived")
|
|
}
|
|
if _, ok := headers[RuntimeStateDir+"/shellctl.db"]; ok {
|
|
t.Error("runtime state must not be archived")
|
|
}
|
|
if hdr := headers[".local/bin/agent-tool"]; hdr == nil {
|
|
t.Error("user-installed tooling under .local must be archived")
|
|
}
|
|
if got := string(contents[".bashrc"]); got != "export PS1='$ '\n" {
|
|
t.Errorf(".bashrc content = %q", got)
|
|
}
|
|
if hdr := headers["bin/tool.sh"]; hdr == nil || hdr.FileInfo().Mode().Perm() != 0o755 {
|
|
t.Errorf("bin/tool.sh mode not preserved: %+v", hdr)
|
|
}
|
|
if hdr := headers["emptydir/"]; hdr == nil || hdr.Typeflag != tar.TypeDir {
|
|
t.Error("empty dir entry missing")
|
|
}
|
|
link := headers["tool-link"]
|
|
if link == nil || link.Typeflag != tar.TypeSymlink || link.Linkname != "bin/tool.sh" {
|
|
t.Errorf("symlink entry wrong: %+v", link)
|
|
}
|
|
for name, hdr := range headers {
|
|
if hdr.Uid != 0 || hdr.Gid != 0 || hdr.Uname != "" || hdr.Gname != "" {
|
|
t.Errorf("ownership leaked into entry %q", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSaveHomeSkipsIrregularFiles(t *testing.T) {
|
|
home := buildFixtureHome(t)
|
|
fifo := filepath.Join(home, "pipe")
|
|
if err := mkfifo(fifo); err != nil {
|
|
t.Skipf("cannot create fifo: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := SaveHome(context.Background(), &buf, home, nil); err != nil {
|
|
t.Fatalf("SaveHome with fifo present: %v", err)
|
|
}
|
|
headers, _ := decodeArchive(t, buf.Bytes())
|
|
if _, ok := headers["pipe"]; ok {
|
|
t.Error("fifo must be skipped, not archived")
|
|
}
|
|
}
|
|
|
|
func TestSaveHomeCancelledContext(t *testing.T) {
|
|
home := buildFixtureHome(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
var buf bytes.Buffer
|
|
if err := SaveHome(ctx, &buf, home, nil); err == nil {
|
|
t.Fatal("expected error from cancelled context")
|
|
}
|
|
}
|
|
|
|
func TestSaveHomeUnreadableFile(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("running as root: permission checks are bypassed")
|
|
}
|
|
home := buildFixtureHome(t)
|
|
locked := filepath.Join(home, "locked.txt")
|
|
if err := os.WriteFile(locked, []byte("secret"), 0o000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := SaveHome(context.Background(), &buf, home, nil); err == nil {
|
|
t.Fatal("expected error for unreadable file")
|
|
}
|
|
}
|
|
|
|
// The runtime state dir is not logically part of a Home Snapshot, so no
|
|
// configuration may put it into one. Excludes add to that rule; they cannot
|
|
// subtract from it.
|
|
func TestSaveHomeAlwaysSkipsDefaultExcludes(t *testing.T) {
|
|
for name, excludes := range map[string][]string{
|
|
"nil excludes": nil,
|
|
"empty excludes": {},
|
|
"unrelated excludes": {".cache"},
|
|
// A caller must not be able to negotiate a default back in, whether
|
|
// by re-including it outright or by anchoring the attempt.
|
|
"re-inclusion": {"!" + RuntimeStateDir},
|
|
"anchored re-inclusion": {"!/" + RuntimeStateDir + "/**"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
home := buildFixtureHome(t)
|
|
var buf bytes.Buffer
|
|
if err := SaveHome(context.Background(), &buf, home, excludes); err != nil {
|
|
t.Fatalf("SaveHome: %v", err)
|
|
}
|
|
headers, _ := decodeArchive(t, buf.Bytes())
|
|
for entry := range headers {
|
|
for _, dir := range defaultExcludes {
|
|
if entry == dir+"/" || strings.HasPrefix(entry, dir+"/") {
|
|
t.Errorf("%s entry %q archived", dir, entry)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A nested path that merely repeats a default-exclude name is ordinary Home
|
|
// content and must survive.
|
|
func TestSaveHomeSkipsOnlyExactDefaultExcludePaths(t *testing.T) {
|
|
for _, dir := range defaultExcludes {
|
|
t.Run(dir, func(t *testing.T) {
|
|
home := buildFixtureHome(t)
|
|
nested := filepath.Join(home, "bin", dir)
|
|
if err := os.MkdirAll(nested, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(nested, "keep.txt"), []byte("keep"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := SaveHome(context.Background(), &buf, home, nil); err != nil {
|
|
t.Fatalf("SaveHome: %v", err)
|
|
}
|
|
headers, _ := decodeArchive(t, buf.Bytes())
|
|
want := path.Join("bin", dir, "keep.txt")
|
|
if _, ok := headers[want]; !ok {
|
|
t.Errorf("%s dropped; only the exact %s path is excluded", want, dir)
|
|
}
|
|
})
|
|
}
|
|
}
|