Files

396 lines
14 KiB
Go

// 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)
}
}