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:
2026-07-13 09:47:10 +02:00
parent aa967fbf69
commit 753cd83456
6 changed files with 607 additions and 0 deletions
@@ -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")
}
}