Add opt-in Tentacle protocol 1.1, explicit remote grants, lifecycle IPC and expiring core leases in both SDKs. Revoke access before update preparation, including failed updates. Add transient secret entries, shared Braille cell export and isolated Xorg recovery experiments with focused tests and documented limitations. Production consent, desktop services and live interoperability remain pending.
503 lines
18 KiB
Go
503 lines
18 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)
|
|
}
|
|
}
|
|
|
|
func TestInstallReaderSettingsPackageGrantAndDependencies(t *testing.T) {
|
|
for _, interactive := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("interaction=%t", interactive), func(t *testing.T) {
|
|
capabilities := "'presentation','reader-settings'"
|
|
apis := "'commands','presentation','reader-settings'"
|
|
granted := []string{"presentation", "reader-settings"}
|
|
if interactive {
|
|
capabilities = "'presentation','interaction','reader-settings'"
|
|
apis = "'commands','presentation','interaction','reader-settings'"
|
|
granted = []string{"presentation", "interaction", "reader-settings"}
|
|
}
|
|
members := native_members(t)
|
|
manifest := strings.ReplaceAll(package_manifest(), "apis=['commands','presentation']", "apis=["+apis+"]")
|
|
manifest = strings.ReplaceAll(manifest, "capabilities=['presentation']", "capabilities=["+capabilities+"]")
|
|
members[0].data = []byte(manifest)
|
|
repository, client := package_fixture(t, package_archive(t, members))
|
|
repository.Tentacles[0].Capabilities = granted
|
|
prepared, err := prepare_package(t.Context(), t.TempDir(), repository, "echo", client)
|
|
if !interactive {
|
|
if err == nil {
|
|
prepared.Discard()
|
|
t.Fatal("reader settings installed without interaction permission")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer prepared.Discard()
|
|
if !prepared.Entry.ReaderSettings || !prepared.Entry.Spec().ReaderSettings {
|
|
t.Fatal("reader grant not carried into runtime spec")
|
|
}
|
|
registry := &Registry{Path: filepath.Join(t.TempDir(), "tentacles.toml")}
|
|
if _, err := registry.Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := registry.Save([]Entry{prepared.Entry}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loaded, err := (&Registry{Path: registry.Path}).Load()
|
|
if err != nil || len(loaded) != 1 || !loaded[0].ReaderSettings {
|
|
t.Fatal("reader grant lost across restart", loaded, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSessionPackageGrantsPersistAndRejectMismatch(t *testing.T) {
|
|
for _, variant := range []string{"valid", "missing API", "unknown grant", "duplicate grant", "old version"} {
|
|
t.Run(variant, func(t *testing.T) {
|
|
members := native_members(t)
|
|
manifest := strings.Replace(package_manifest(), "minor=0", "minor=1", 1)
|
|
manifest = strings.Replace(manifest, "apis=['commands','presentation']", "apis=['remote-access','session-events','presentation','commands']", 1)
|
|
capabilities := []string{"presentation", "output-observe"}
|
|
grantText := "capabilities=['presentation','output-observe']"
|
|
switch variant {
|
|
case "missing API":
|
|
manifest = strings.Replace(manifest, "'remote-access',", "", 1)
|
|
case "unknown grant":
|
|
capabilities = append(capabilities, "execute")
|
|
grantText = "capabilities=['presentation','output-observe','execute']"
|
|
case "duplicate grant":
|
|
capabilities = append(capabilities, "output-observe")
|
|
grantText = "capabilities=['presentation','output-observe','output-observe']"
|
|
case "old version":
|
|
manifest = strings.Replace(manifest, "minor=1", "minor=0", 1)
|
|
}
|
|
manifest = strings.Replace(manifest, "capabilities=['presentation']", grantText, 1)
|
|
members[0].data = []byte(manifest)
|
|
repository, client := package_fixture(t, package_archive(t, members))
|
|
repository.Tentacles[0].Capabilities = capabilities
|
|
prepared, err := prepare_package(t.Context(), filepath.Join(t.TempDir(), "packages"), repository, "echo", client)
|
|
if variant != "valid" {
|
|
if err == nil {
|
|
prepared.Discard()
|
|
t.Fatal("unsupported access installed")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer prepared.Discard()
|
|
entry := prepared.Entry
|
|
if !entry.SessionEvents || len(entry.RemoteGrants) != 1 || entry.RemoteGrants[0] != "output-observe" {
|
|
t.Fatal("lost grants", entry)
|
|
}
|
|
registry := &Registry{Path: filepath.Join(t.TempDir(), "registry.toml")}
|
|
if _, err := registry.Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := registry.Save([]Entry{entry}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, err := registry.Load()
|
|
if err != nil || len(entries) != 1 || !entries[0].Spec().SessionEvents || len(entries[0].Spec().RemoteGrants) != 1 {
|
|
t.Fatal("restart lost grants", err)
|
|
}
|
|
clone := CloneEntries(entries)
|
|
clone[0].RemoteGrants[0] = "keyboard-inject"
|
|
if entries[0].RemoteGrants[0] != "output-observe" {
|
|
t.Fatal("shared grant slice")
|
|
}
|
|
})
|
|
}
|
|
}
|