// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package tentacle import ( "archive/tar" "bytes" "compress/gzip" "context" "crypto/sha256" "fmt" "git.stormux.org/storm/cthulhu/internal/tentaclewire" "io" "net/http" "os" "path/filepath" "runtime" "strings" "testing" "time" ) type packageTransport func(*http.Request) (*http.Response, error) func (transport packageTransport) RoundTrip(request *http.Request) (*http.Response, error) { return transport(request) } type archiveMember struct { name string data []byte kind byte mode int64 } func package_archive(t *testing.T, members []archiveMember) []byte { t.Helper() var output bytes.Buffer compressed := gzip.NewWriter(&output) archive := tar.NewWriter(compressed) for _, member := range members { if member.kind == 0 { member.kind = tar.TypeReg } header := &tar.Header{Name: member.name, Mode: member.mode, Size: int64(len(member.data)), Typeflag: member.kind} if member.kind == tar.TypeSymlink || member.kind == tar.TypeLink { header.Linkname = "../../outside" } if err := archive.WriteHeader(header); err != nil { t.Fatal(err) } if _, err := archive.Write(member.data); err != nil { t.Fatal(err) } } if err := archive.Close(); err != nil { t.Fatal(err) } if err := compressed.Close(); err != nil { t.Fatal(err) } return output.Bytes() } func package_manifest() string { return "format-version=1\nid='echo'\nname='Echo'\nversion='0.1'\nruntime='native'\nplatforms=['linux']\nentry-point=['bin/echo']\napis=['commands','presentation']\ncapabilities=['presentation']\n[protocol]\nmajor=1\nminor=0\n" } func package_fixture(t *testing.T, data []byte) (Repository, *http.Client) { t.Helper() digest := sha256.Sum256(data) repository := Repository{URL: "https://example.org/team/tentacles", ID: "example.tentacles", Name: "Example", Revision: strings.Repeat("a", 40), Tentacles: []Manifest{{FormatVersion: 1, ID: "echo", Name: "Echo", Version: "0.1", Capabilities: []string{"presentation"}, Releases: []Release{{Platform: runtime.GOOS, Architecture: runtime.GOARCH, URL: "https://example.org/releases/echo.tar.gz", SHA256: fmt.Sprintf("%x", digest)}}}}} client := &http.Client{Transport: packageTransport(func(request *http.Request) (*http.Response, error) { if err := request.Context().Err(); err != nil { return nil, err } return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(data)), ContentLength: int64(len(data)), Header: make(http.Header)}, nil })} return repository, client } func native_members(t *testing.T) []archiveMember { t.Helper() filename, err := os.Executable() if err != nil { t.Fatal(err) } data, err := os.ReadFile(filename) if err != nil { t.Fatal(err) } return []archiveMember{{name: "tentacle.toml", data: []byte(package_manifest()), mode: 0644}, {name: "bin/echo", data: data, mode: 0755}} } func TestPackageInstallPinsIdentityPermissionsAndSurvivesRestart(t *testing.T) { data := package_archive(t, native_members(t)) repository, client := package_fixture(t, data) root := filepath.Join(t.TempDir(), "packages") prepared, err := prepare_package(context.Background(), root, repository, "echo", client) if err != nil { t.Fatal(err) } defer prepared.Discard() entry := prepared.Entry if entry.Enabled || !entry.Presentation || entry.ID != PackageID(repository, "echo") || entry.RepositoryRevision != repository.Revision || entry.PackageSHA256 != repository.Tentacles[0].Releases[0].SHA256 || filepath.Dir(entry.Program[0]) != filepath.Join(entry.PackageDirectory, "bin") { t.Fatal(entry) } other := repository other.URL = "https://other.example.org/team/tentacles" if PackageID(other, "echo") == entry.ID { t.Fatal("different origins share identity") } registry := &Registry{Path: filepath.Join(t.TempDir(), "tentacles.toml")} if _, err := registry.Load(); err != nil { t.Fatal(err) } if err := registry.SaveState([]Entry{entry}, []Repository{repository}); err != nil { t.Fatal(err) } prepared.Retain() reopened := &Registry{Path: registry.Path} entries, err := reopened.Load() if err != nil || len(entries) != 1 || entries[0].PackageSHA256 != entry.PackageSHA256 { t.Fatalf("restart %v %v", entries, err) } if err := RemovePackage(root, entries[0]); err != nil { t.Fatal(err) } if _, err := os.Stat(entry.PackageDirectory); !os.IsNotExist(err) { t.Fatal("package files retained", err) } } func TestPackageRejectsBadDownloadsAndManifestsWithoutFiles(t *testing.T) { for _, problem := range []string{"digest", "status", "unsupported architecture", "version", "grant", "runtime", "protocol", "api", "entry traversal", "missing executable", "not executable", "not ELF", "cancelled", "corrupt gzip"} { t.Run(problem, func(t *testing.T) { members := native_members(t) switch problem { case "version": members[0].data = []byte(strings.Replace(package_manifest(), "version='0.1'", "version='0.2'", 1)) case "grant": members[0].data = []byte(strings.Replace(package_manifest(), "capabilities=['presentation']", "capabilities=[]", 1)) case "runtime": members[0].data = []byte(strings.Replace(package_manifest(), "runtime='native'", "runtime='python'", 1)) case "protocol": members[0].data = []byte(strings.Replace(package_manifest(), "major=1", "major=2", 1)) case "api": members[0].data = []byte(strings.Replace(package_manifest(), "'commands','presentation'", "'commands','focused-text'", 1)) case "entry traversal": members[0].data = []byte(strings.Replace(package_manifest(), "bin/echo", "../echo", 1)) case "missing executable": members = members[:1] case "not executable": members[1].mode = 0644 case "not ELF": members[1].data = []byte("#!/bin/sh\nexit 0\n") } data := package_archive(t, members) if problem == "corrupt gzip" { data[len(data)-8] ^= 0xff } repository, client := package_fixture(t, data) if problem == "digest" { repository.Tentacles[0].Releases[0].SHA256 = strings.Repeat("0", 64) } if problem == "unsupported architecture" { repository.Tentacles[0].Releases[0].Architecture = "unknown" } if problem == "status" { client.Transport = packageTransport(func(*http.Request) (*http.Response, error) { return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("missing"))}, nil }) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() if problem == "cancelled" { cancel() } root := filepath.Join(t.TempDir(), "packages") if prepared, err := prepare_package(ctx, root, repository, "echo", client); err == nil { prepared.Discard() t.Fatal("accepted", problem) } files, err := os.ReadDir(root) if !os.IsNotExist(err) && err != nil { t.Fatal(err) } if len(files) != 0 { t.Fatal("failed install left files", files) } }) } } func TestPackageExtractionRejectsEscapesLinksConflictsAndExpansion(t *testing.T) { cases := [][]archiveMember{ {{name: "../outside", data: []byte("bad")}}, {{name: "/absolute", data: []byte("bad")}}, {{name: "bin/link", kind: tar.TypeSymlink}}, {{name: "bin/link", kind: tar.TypeLink}}, {{name: "device", kind: tar.TypeFifo}}, {{name: "same"}, {name: "same"}}, {{name: "bin", data: []byte("file")}, {name: "bin/child"}}, {{name: "bin\\escape"}}, {{name: "./relative"}}, {{name: "huge", data: bytes.Repeat([]byte{0}, maxExpandedBytes+1)}}, } for index, members := range cases { t.Run(fmt.Sprint(index), func(t *testing.T) { if err := extract_package(context.Background(), bytes.NewReader(package_archive(t, members)), t.TempDir()); err == nil { t.Fatal("unsafe archive accepted") } }) } } func TestPackageRemovalPreservesLocalFilesAndRejectsForeignDirectories(t *testing.T) { root := t.TempDir() outside := t.TempDir() if err := RemovePackage(root, Entry{Program: []string{filepath.Join(outside, "local")}}); err != nil { t.Fatal(err) } if err := RemovePackage(root, Entry{PackageDirectory: outside}); err == nil { t.Fatal("outside path accepted") } link := filepath.Join(root, "package-link") if err := os.Symlink(outside, link); err != nil { t.Fatal(err) } if err := RemovePackage(root, Entry{PackageDirectory: link}); err == nil { t.Fatal("symlink accepted") } if _, err := os.Stat(outside); err != nil { t.Fatal("outside removed", err) } } func TestReleaseCatalogValidationAndDeepClone(t *testing.T) { files := repository_files() files["catalog.toml"] += "[[tentacles.releases]]\nplatform='linux'\narchitecture='amd64'\nurl='https://example.org/release.tar.gz'\nsha256='" + strings.Repeat("a", 64) + "'\n" repository, err := read_repository_fixture(files) if err != nil { t.Fatal(err) } clone := CloneRepositories([]Repository{repository}) clone[0].Tentacles[0].Releases[0].SHA256 = "changed" clone[0].Tentacles[0].Capabilities[0] = "changed" if repository.Tentacles[0].Releases[0].SHA256 == "changed" || repository.Tentacles[0].Capabilities[0] == "changed" { t.Fatal("shared catalog storage") } for _, value := range []string{"http://example.org/archive", "https://Username@example.org/archive", "https://example.org/archive#fragment"} { repository.Tentacles[0].Releases[0].URL = value if ValidateRepositories([]Repository{repository}) == nil { t.Fatal("unsafe URL accepted", value) } } } func TestPackageRecoveryKeepsSelectedFilesAndLocalReferences(t *testing.T) { root := t.TempDir() for _, name := range []string{"package-123", "package-456", ".staging-789", "package-user", "package-321"} { if err := os.Mkdir(filepath.Join(root, name), 0700); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(root, ".download-567"), []byte("partial"), 0600); err != nil { t.Fatal(err) } entries := []Entry{{PackageDirectory: filepath.Join(root, "package-123")}, {Program: []string{filepath.Join(root, "package-321", "bin", "local")}}} if err := RecoverPackages(root, entries); err != nil { t.Fatal(err) } for _, name := range []string{"package-123", "package-user", "package-321"} { if _, err := os.Stat(filepath.Join(root, name)); err != nil { t.Fatal("selected/local files lost", err) } } for _, name := range []string{"package-456", ".staging-789", ".download-567"} { if _, err := os.Stat(filepath.Join(root, name)); !os.IsNotExist(err) { t.Fatal("orphan retained", name, err) } } } func TestPackageRedirectsRejectHTTPAndCredentials(t *testing.T) { for _, location := range []string{"http://example.org/archive", "https://Username@example.org/archive"} { t.Run(location, func(t *testing.T) { client := new_package_client() calls := 0 client.Transport = packageTransport(func(request *http.Request) (*http.Response, error) { calls++ return &http.Response{StatusCode: 302, Header: http.Header{"Location": []string{location}}, Body: io.NopCloser(strings.NewReader("")), Request: request}, nil }) if response, err := client.Get("https://example.org/release"); err == nil { response.Body.Close() t.Fatal("unsafe redirect accepted") } if calls != 1 { t.Fatal("followed unsafe redirect", calls) } }) } } func TestBuiltHelloWorldPackageThroughHost(t *testing.T) { root := os.Getenv("CTHULHU_TENTACLE_RELEASE_ROOT") if root == "" { t.Skip("set CTHULHU_TENTACLE_RELEASE_ROOT to validate prepared release artifacts") } repository, err := read_repository("https://example.org/team/tentacles", strings.Repeat("a", 40), func(filename string) ([]byte, error) { return os.ReadFile(filepath.Join(root, filename)) }) if err != nil { t.Fatal(err) } var manifest Manifest for _, value := range repository.Tentacles { if value.ID == "hello-world" { manifest = value } } index := -1 for item, release := range manifest.Releases { if release.Architecture == runtime.GOARCH && release.Platform == runtime.GOOS { index = item } } if index < 0 { t.Fatal("no release for host") } data, err := os.ReadFile(filepath.Join(root, "dist", "hello-world-"+manifest.Version+"-linux-"+runtime.GOARCH+".tar.gz")) if err != nil { t.Fatal(err) } client := new_package_client() client.Transport = packageTransport(func(*http.Request) (*http.Response, error) { return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(data)), Header: make(http.Header)}, nil }) prepared, err := prepare_package(context.Background(), filepath.Join(t.TempDir(), "packages"), repository, "hello-world", client) if err != nil { t.Fatal(err) } defer prepared.Discard() manager, err := NewManager(DefaultLimits(), nil) if err != nil { t.Fatal(err) } defer manager.Shutdown(context.Background()) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() session, err := manager.Start(ctx, prepared.Entry.Spec()) if err != nil { t.Fatal(err) } select { case <-session.Ready(): case <-ctx.Done(): t.Fatal(ctx.Err()) } if err := session.Activate(); err != nil { t.Fatal(err) } pending, err := session.Invoke(ctx, "hello-world", nil) if err != nil { t.Fatal(err) } select { case result := <-pending: if result.Err != nil { t.Fatal(result.Err) } presentation, err := tentaclewire.DecodePresentation(result.Value) if err != nil || presentation.Speech != "Hello world!" || presentation.Braille != "Hello world!" || !presentation.Uninterruptible { t.Fatal(presentation, err) } case <-ctx.Done(): t.Fatal(ctx.Err()) } } func TestInstallInteractivePackageGrant(t *testing.T) { members := native_members(t) for index := range members { if members[index].name == "tentacle.toml" { members[index].data = []byte(strings.ReplaceAll(strings.ReplaceAll(package_manifest(), "apis=['commands','presentation']", "apis=['commands','presentation','interaction']"), "capabilities=['presentation']", "capabilities=['presentation','interaction']")) } } data := package_archive(t, members) repository, client := package_fixture(t, data) repository.Tentacles[0].Capabilities = []string{"presentation", "interaction"} prepared, err := prepare_package(context.Background(), t.TempDir(), repository, "echo", client) if err != nil { t.Fatal(err) } defer prepared.Discard() if !prepared.Entry.Interaction || !prepared.Entry.Spec().Interaction || !prepared.Entry.Presentation { t.Fatal(prepared.Entry) } }