396 lines
14 KiB
Go
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)
|
|
}
|
|
}
|