controller: .fab browser download — the existing export pipeline staged under the data dir + a guarded streaming exit (estimate-first, io.Copy, post-stream cleanup, 1h TTL sweep); portability framing (decision 3); traversal guard red-proven, export→import round-trip + corrupt-bundle refusal unit-proven
Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package appexport
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// v0.124.0 Part 3 (§7D) — the .fab loop's proof at unit level: a real export through
|
||||
// executeExport produces a bundle that a real executeImport restores to identical content, and
|
||||
// a corrupted downloaded copy is REFUSED (the .fab's integrity is the gzip CRC + the manifest
|
||||
// segment validation — there is no per-file checksum; corruption breaks the extract, and the
|
||||
// import must fail loudly, not restore garbage).
|
||||
|
||||
// rtProvider is a filesystem-only fake: a config-only app (no HDD, no volumes, no DB) so the
|
||||
// whole loop runs without docker.
|
||||
type rtProvider struct {
|
||||
stackDir string
|
||||
stacksDir string
|
||||
deployed bool
|
||||
started bool
|
||||
savedEnv map[string]string
|
||||
}
|
||||
|
||||
func (p *rtProvider) GetStackDir(string) (string, bool) { return p.stackDir, true }
|
||||
func (p *rtProvider) GetStackComposePath(string) (string, bool) {
|
||||
return filepath.Join(p.stackDir, "docker-compose.yml"), true
|
||||
}
|
||||
func (p *rtProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (p *rtProvider) GetStackHDDPath(string) string { return "" }
|
||||
func (p *rtProvider) IsStackRunning(string) bool { return false }
|
||||
func (p *rtProvider) StopStack(string) error { return nil }
|
||||
func (p *rtProvider) StartStack(string) error { p.started = true; return nil }
|
||||
func (p *rtProvider) GetStackDisplayName(n string) string { return "RT " + n }
|
||||
func (p *rtProvider) GetStackNeedsHDD(string) bool { return false }
|
||||
func (p *rtProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *rtProvider) IsStackDeployed(string) bool { return p.deployed }
|
||||
func (p *rtProvider) GetDecryptedEnv(string) map[string]string { return nil }
|
||||
func (p *rtProvider) GetStacksBaseDir() string { return p.stacksDir }
|
||||
func (p *rtProvider) RefreshStacks() error { return nil }
|
||||
func (p *rtProvider) RemoveStackVolumes(string) error { return nil }
|
||||
func (p *rtProvider) SaveEncryptedAppConfig(stackDir string, env map[string]string) error {
|
||||
p.savedEnv = env
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitJob(t *testing.T, e *Exporter) *Job {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
job := e.GetActiveJob()
|
||||
if job != nil {
|
||||
job.mu.RLock()
|
||||
done := job.Done
|
||||
job.mu.RUnlock()
|
||||
if done {
|
||||
return job
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("job did not finish in time")
|
||||
return nil
|
||||
}
|
||||
|
||||
func jobErr(j *Job) string {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
if j.Error != "" {
|
||||
return j.Error
|
||||
}
|
||||
for _, s := range j.Steps {
|
||||
if s.Status == "failed" {
|
||||
return s.Error
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestFabRoundTrip_ExportImportContentEquality(t *testing.T) {
|
||||
const stack = "rt-app"
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
|
||||
// Source stack: a compose file + a marker config with known content.
|
||||
srcStack := t.TempDir()
|
||||
compose := "services:\n rt-app:\n image: alpine\n"
|
||||
marker := "MARKER-CONTENT-42\n"
|
||||
os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte(compose), 0644)
|
||||
os.WriteFile(filepath.Join(srcStack, "settings.conf"), []byte(marker), 0644)
|
||||
|
||||
drive := t.TempDir()
|
||||
prov := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true}
|
||||
e := NewExporter(prov, lg, "test")
|
||||
|
||||
// --- export (the REAL pipeline; same producer as a drive export) ---
|
||||
if err := e.StartExport(ExportRequest{StackName: stack, DestDrive: drive}); err != nil {
|
||||
t.Fatalf("StartExport: %v", err)
|
||||
}
|
||||
job := waitJob(t, e)
|
||||
if msg := jobErr(job); msg != "" {
|
||||
t.Fatalf("export failed: %s", msg)
|
||||
}
|
||||
entries, _ := os.ReadDir(ExportDir(drive))
|
||||
var fabPath string
|
||||
for _, en := range entries {
|
||||
if strings.HasSuffix(en.Name(), ".fab") {
|
||||
fabPath = filepath.Join(ExportDir(drive), en.Name())
|
||||
}
|
||||
}
|
||||
if fabPath == "" {
|
||||
t.Fatal("no .fab produced")
|
||||
}
|
||||
|
||||
// The manifest is readable and names the app (what /api/export/manifest shows pre-import).
|
||||
man, err := ReadManifestFromFAB(fabPath)
|
||||
if err != nil {
|
||||
t.Fatalf("manifest: %v", err)
|
||||
}
|
||||
if man.AppName != stack {
|
||||
t.Fatalf("manifest app = %q", man.AppName)
|
||||
}
|
||||
|
||||
// --- corrupted copy must be REFUSED (assert the refusal, §10 red-proof of the loop) ---
|
||||
corrupt := filepath.Join(t.TempDir(), "corrupt.fab")
|
||||
raw, _ := os.ReadFile(fabPath)
|
||||
mid := len(raw) / 2
|
||||
raw[mid] ^= 0xFF
|
||||
raw[mid+1] ^= 0xFF
|
||||
os.WriteFile(corrupt, raw, 0644)
|
||||
|
||||
prov2 := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: false}
|
||||
e2 := NewExporter(prov2, lg, "test")
|
||||
if err := e2.StartImport(ImportRequest{FABPath: corrupt}); err != nil {
|
||||
t.Fatalf("StartImport(corrupt) should start (refusal is async): %v", err)
|
||||
}
|
||||
job = waitJob(t, e2)
|
||||
if msg := jobErr(job); msg == "" {
|
||||
t.Fatal("a corrupted bundle must FAIL the import (gzip CRC), got success")
|
||||
}
|
||||
if prov2.started {
|
||||
t.Fatal("a refused import must not start the app")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(prov2.stacksDir, stack, "settings.conf")); !os.IsNotExist(err) {
|
||||
t.Fatal("a refused import must not restore content")
|
||||
}
|
||||
|
||||
// --- the clean bundle round-trips: restored content is byte-identical ---
|
||||
prov3 := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: false}
|
||||
e3 := NewExporter(prov3, lg, "test")
|
||||
if err := e3.StartImport(ImportRequest{FABPath: fabPath}); err != nil {
|
||||
t.Fatalf("StartImport: %v", err)
|
||||
}
|
||||
job = waitJob(t, e3)
|
||||
if msg := jobErr(job); msg != "" {
|
||||
t.Fatalf("import failed: %s", msg)
|
||||
}
|
||||
restoredStack := filepath.Join(prov3.stacksDir, stack)
|
||||
for name, want := range map[string]string{
|
||||
"docker-compose.yml": compose,
|
||||
"settings.conf": marker,
|
||||
} {
|
||||
got, err := os.ReadFile(filepath.Join(restoredStack, name))
|
||||
if err != nil {
|
||||
t.Fatalf("restored %s missing: %v", name, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("restored %s differs:\n got %q\nwant %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if !prov3.started {
|
||||
t.Error("import must start the restored app")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user