package updater import ( "archive/tar" "bytes" "context" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/ulikunitz/xz" modulepkg "github.com/dbledeez/panel/pkg/module" ) // makeFactorioTarXz builds a tiny tar.xz shaped like factorio's // headless tarball (factorio/bin/x64/factorio). func makeFactorioTarXz(t *testing.T) []byte { t.Helper() var buf bytes.Buffer xw, err := xz.NewWriter(&buf) if err != nil { t.Fatal(err) } tw := tar.NewWriter(xw) body := []byte("ELF-not-really") if err := tw.WriteHeader(&tar.Header{Name: "factorio/bin/x64/factorio", Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))}); err != nil { t.Fatal(err) } if _, err := tw.Write(body); err != nil { t.Fatal(err) } if err := tw.Close(); err != nil { t.Fatal(err) } if err := xw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func testCtx(t *testing.T, dataPath string) *Context { t.Helper() return &Context{ InstanceID: "test", DataPath: dataPath, Log: func(string) {}, } } // The factorio case: archive content + extensionless URL + extract:true // must land the extracted tree under TargetPath, not a raw blob. func TestDirectExtractsArchiveIntoTargetDir(t *testing.T) { blob := makeFactorioTarXz(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(blob) })) defer srv.Close() dir := t.TempDir() spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/get-download/stable/headless/linux64", TargetPath: "/game", Extract: true} if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil { t.Fatal(err) } bin := filepath.Join(dir, "game", "factorio", "bin", "x64", "factorio") data, err := os.ReadFile(bin) if err != nil { t.Fatalf("expected extracted binary at %s: %v", bin, err) } if string(data) != "ELF-not-really" { t.Fatalf("binary content mismatch: %q", data) } // The old bug: raw blob written AS the target path. if fi, err := os.Stat(filepath.Join(dir, "game")); err != nil || !fi.IsDir() { t.Fatalf("target path should be a directory, got err=%v", err) } } // Same archive, no explicit extract flag: extensionless TargetPath // still triggers extraction (directory heuristic). func TestDirectHeuristicExtractsWithoutFlag(t *testing.T) { blob := makeFactorioTarXz(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(blob) })) defer srv.Close() dir := t.TempDir() spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/linux64", TargetPath: "/game"} if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(dir, "game", "factorio", "bin", "x64", "factorio")); err != nil { t.Fatalf("expected extraction: %v", err) } } // The terraria/minecraft guard: a TargetPath WITH a file extension is // written verbatim even when the content has archive magic (jars are // zips; terraria's entrypoint unzips its own .zip). func TestDirectWritesArchiveVerbatimWhenTargetHasExtension(t *testing.T) { // A zip blob (like a .jar or terraria-server zip). var zipBuf bytes.Buffer zipBuf.Write([]byte{0x50, 0x4b, 0x03, 0x04}) // zip local-header magic zipBuf.WriteString("rest-of-zip-not-parsed") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(zipBuf.Bytes()) })) defer srv.Close() dir := t.TempDir() spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/terraria-server-1449.zip", TargetPath: "/terraria-server.zip"} if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(dir, "terraria-server.zip")) if err != nil { t.Fatal(err) } if !bytes.Equal(data, zipBuf.Bytes()) { t.Fatal("zip should have been written verbatim") } } // extract:true on non-archive content is a hard error, not a silent // verbatim write. func TestDirectExtractFlagOnNonArchiveErrors(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("just text")) })) defer srv.Close() dir := t.TempDir() spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/blob", TargetPath: "/game", Extract: true} if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err == nil { t.Fatal("expected error for extract on non-archive content") } } // containerTargetAbs: absolute targets matching a declared root or // volume mount stay container-absolute (factorio's /game volume); // anything else keeps the historical BrowseableRoot join (terraria's // /terraria-server.zip → /game-saves/terraria-server.zip). func TestContainerTargetAbs(t *testing.T) { uc := &Context{ BrowseableRoot: "/game-saves", Manifest: &modulepkg.Manifest{ Runtime: modulepkg.Runtime{Docker: &modulepkg.DockerSpec{ BrowseableRoots: []modulepkg.BrowseableRoot{{Name: "Saves", Path: "/game-saves"}}, Volumes: []modulepkg.Volume{ {Type: "volume", Name: "panel-$INSTANCE_ID-saves", Container: "/game-saves"}, {Type: "volume", Name: "panel-$INSTANCE_ID-game", Container: "/game"}, }, }}, }, } cases := map[string]string{ "/game": "/game", "/game/sub": "/game/sub", "/game-saves/mods": "/game-saves/mods", "/terraria-server.zip": "/game-saves/terraria-server.zip", "relative/file": "/game-saves/relative/file", } for in, want := range cases { if got := containerTargetAbs(uc, in); got != want { t.Errorf("containerTargetAbs(%q) = %q, want %q", in, got, want) } } } // Non-archive content with an extensionless target keeps the old // single-file write behavior. func TestDirectNonArchiveStillWritesFile(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("binary-ish payload")) })) defer srv.Close() dir := t.TempDir() spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/blob", TargetPath: "/some/file"} if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(dir, "some", "file")) if err != nil { t.Fatal(err) } if string(data) != "binary-ish payload" { t.Fatalf("content mismatch: %q", data) } }