Files

432 lines
15 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"debug/elf"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
)
const maxPackageBytes = 32 << 20
const maxExpandedBytes = 64 << 20
func valid_digest(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value)
}
func valid_revision(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && (len(decoded) == 20 || len(decoded) == 32) && value == strings.ToLower(value)
}
func artifact_url(value string) bool {
parsed, err := url.Parse(value)
return err == nil && parsed.Scheme == "https" && parsed.Hostname() != "" && parsed.User == nil && parsed.Opaque == "" && parsed.Fragment == "" && !strings.Contains(value, "#") && len(value) <= 2048 && metadata_text(value, true)
}
func validate_releases(releases []Release) error {
if len(releases) > 16 {
return ErrLimit
}
seen := make(map[string]bool)
for _, release := range releases {
key := release.Platform + "/" + release.Architecture
if !metadata_id(release.Platform) || !metadata_id(release.Architecture) || !artifact_url(release.URL) || !valid_digest(release.SHA256) || seen[key] {
return errors.New("Invalid or duplicate package release metadata.")
}
seen[key] = true
}
return nil
}
func PackageRoot(registryPath string) string {
return filepath.Join(filepath.Dir(registryPath), "tentacle-packages")
}
func PackageID(repository Repository, localID string) string {
digest := sha256.Sum256([]byte(repository.URL + "\x00" + repository.ID + "\x00" + localID))
return "pkg-" + hex.EncodeToString(digest[:])
}
// PreparedPackage owns unpublished files until the lifecycle owner persists its
// Entry. Once queued, the caller waits for that owner's decision before Discard;
// successful persistence calls Retain. Validation never runs package code.
type PreparedPackage struct {
Entry Entry
directory string
}
func (prepared *PreparedPackage) Discard() error {
if prepared == nil || prepared.directory == "" {
return nil
}
err := os.RemoveAll(prepared.directory)
if err == nil {
prepared.directory = ""
}
return err
}
func (prepared *PreparedPackage) Retain() { prepared.directory = "" }
func PreparePackage(ctx context.Context, root string, repository Repository, localID string) (*PreparedPackage, error) {
return prepare_package(ctx, root, repository, localID, new_package_client())
}
func new_package_client() *http.Client {
return &http.Client{Timeout: 45 * time.Second, CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 5 || !artifact_url(request.URL.String()) {
return errors.New("Package redirect must use HTTPS without credentials.")
}
return nil
}}
}
func prepare_package(ctx context.Context, root string, repository Repository, localID string, client *http.Client) (_ *PreparedPackage, resultError error) {
if err := ValidateRepositories([]Repository{repository}); err != nil {
return nil, err
}
index := slices.IndexFunc(repository.Tentacles, func(value Manifest) bool { return value.ID == localID })
if index < 0 {
return nil, errors.New("Tentacle is no longer listed in this repository.")
}
manifest := repository.Tentacles[index]
releaseIndex := slices.IndexFunc(manifest.Releases, func(value Release) bool {
return value.Platform == runtime.GOOS && value.Architecture == runtime.GOARCH
})
if releaseIndex < 0 {
return nil, fmt.Errorf("No package is available for %s/%s. Refresh the repository after a release is published.", runtime.GOOS, runtime.GOARCH)
}
release := manifest.Releases[releaseIndex]
if !filepath.IsAbs(root) {
return nil, errors.New("Package root must be absolute.")
}
if err := os.MkdirAll(root, 0700); err != nil {
return nil, err
}
info, err := os.Lstat(root)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("Package root must be a directory, not a symlink.")
}
stage, err := os.MkdirTemp(root, ".staging-")
if err != nil {
return nil, err
}
prepared := &PreparedPackage{directory: stage}
defer func() {
if resultError != nil {
_ = prepared.Discard()
}
}()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, release.URL, nil)
if err != nil {
return nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, errors.New("Package download failed; check the connection or retry.")
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, errors.New("Package download failed; the server did not return a package.")
}
if response.ContentLength > maxPackageBytes {
return nil, ErrLimit
}
archive, err := os.CreateTemp(root, ".download-")
if err != nil {
return nil, err
}
defer os.Remove(archive.Name())
defer archive.Close()
digest := sha256.New()
count, err := io.Copy(io.MultiWriter(archive, digest), io.LimitReader(response.Body, maxPackageBytes+1))
if err != nil {
return nil, errors.New("Package download was interrupted.")
}
if count > maxPackageBytes {
return nil, ErrLimit
}
if hex.EncodeToString(digest.Sum(nil)) != release.SHA256 {
return nil, errors.New("Package checksum does not match the catalog; nothing was installed.")
}
if _, err := archive.Seek(0, io.SeekStart); err != nil {
return nil, err
}
if err := extract_package(ctx, archive, stage); err != nil {
return nil, err
}
data, err := read_registry(filepath.Join(stage, "tentacle.toml"))
if err != nil || data == nil {
return nil, errors.New("Package must contain a regular tentacle.toml.")
}
var packageManifest struct {
Manifest
Runtime string `toml:"runtime"`
Platforms []string `toml:"platforms"`
EntryPoint []string `toml:"entry-point"`
APIs []string `toml:"apis"`
Protocol struct {
Major int `toml:"major"`
Minor int `toml:"minor"`
} `toml:"protocol"`
}
if err := toml.Unmarshal(data, &packageManifest); err != nil {
return nil, errors.New("Package manifest is invalid TOML.")
}
if packageManifest.FormatVersion != manifest.FormatVersion || packageManifest.ID != manifest.ID || packageManifest.Version != manifest.Version || packageManifest.Name != manifest.Name || !slices.Equal(packageManifest.Capabilities, manifest.Capabilities) {
return nil, errors.New("Package identity, version or permissions disagree with the catalog; refresh the repository.")
}
if packageManifest.Runtime != "native" || !slices.Contains(packageManifest.Platforms, runtime.GOOS) || packageManifest.Protocol.Major != 1 || packageManifest.Protocol.Minor != 0 {
return nil, errors.New("Package runtime, platform or protocol is unsupported by this reader.")
}
presentation := slices.Contains(packageManifest.Capabilities, "presentation")
interactive := slices.Contains(packageManifest.Capabilities, "interaction")
readerSettings := slices.Contains(packageManifest.Capabilities, "reader-settings")
expectedAPIs := []string{"commands"}
if presentation {
expectedAPIs = append(expectedAPIs, "presentation")
}
if interactive {
expectedAPIs = append(expectedAPIs, "interaction")
}
if readerSettings {
expectedAPIs = append(expectedAPIs, "reader-settings")
}
if readerSettings && !interactive || interactive && !presentation || len(packageManifest.Capabilities) != len(expectedAPIs)-1 || !slices.Equal(packageManifest.APIs, expectedAPIs) {
return nil, errors.New("Package requires unsupported APIs or permissions.")
}
if len(packageManifest.EntryPoint) == 0 || len(packageManifest.EntryPoint) > 32 || !package_path(packageManifest.EntryPoint[0]) {
return nil, errors.New("Package entry point must be a relative file path inside the package.")
}
executable := filepath.Join(stage, filepath.FromSlash(packageManifest.EntryPoint[0]))
if err := validate_native_program(executable); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
if err := filepath.WalkDir(stage, func(filename string, entry os.DirEntry, walkError error) error {
if walkError != nil {
return walkError
}
if entry.IsDir() {
return sync_directory(filename)
}
return nil
}); err != nil {
return nil, err
}
// Every installation gets a private immutable directory. Never overwrite an
// existing version; a crash before registry commit leaves only inert files.
final := filepath.Join(root, "package-"+strings.TrimPrefix(filepath.Base(stage), ".staging-"))
if err := os.Rename(stage, final); err != nil {
return nil, err
}
prepared.directory = final
program := slices.Clone(packageManifest.EntryPoint)
program[0] = filepath.Join(final, filepath.FromSlash(program[0]))
prepared.Entry = Entry{ID: PackageID(repository, localID), LocalID: localID, Name: manifest.Name, Version: manifest.Version,
Program: program, Directory: final, Presentation: presentation, Interaction: interactive, ReaderSettings: readerSettings, Repository: repository.URL, RepositoryID: repository.ID,
RepositoryRevision: repository.Revision, PackageDirectory: final, PackageSHA256: release.SHA256}
if err := ValidateEntries([]Entry{prepared.Entry}); err != nil {
return nil, err
}
if err := sync_directory(final); err != nil {
return nil, err
}
if err := sync_directory(root); err != nil {
return nil, err
}
return prepared, nil
}
func package_path(filename string) bool {
return filename != "" && filename != "." && !strings.HasPrefix(filename, "/") && path.Clean(filename) == filename && filename != ".." && !strings.HasPrefix(filename, "../") && !strings.ContainsAny(filename, "\\\x00\r\n:") && len(filename) <= 4096
}
func extract_package(ctx context.Context, archive io.Reader, directory string) error {
compressed, err := gzip.NewReader(archive)
if err != nil {
return errors.New("Package must be a gzip tar archive.")
}
defer compressed.Close()
limited := &io.LimitedReader{R: compressed, N: maxExpandedBytes + 1}
reader := tar.NewReader(limited)
seen := make(map[string]bool)
for count := 0; ; count++ {
if err := ctx.Err(); err != nil {
return err
}
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
return errors.New("Invalid or oversized package archive.")
}
if count >= 1024 || header.Size < 0 || header.Size > maxExpandedBytes {
return ErrLimit
}
filename := strings.TrimSuffix(header.Name, "/")
if !package_path(filename) || seen[filename] {
return errors.New("Package contains unsafe or duplicate paths.")
}
seen[filename] = true
target := filepath.Join(directory, filepath.FromSlash(filename))
switch header.Typeflag {
case tar.TypeDir:
if header.Size != 0 {
return errors.New("Invalid package directory.")
}
if err := os.MkdirAll(target, 0700); err != nil {
return err
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
return err
}
mode := os.FileMode(0600)
if header.Mode&0111 != 0 {
mode = 0700
}
file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return errors.New("Package contains conflicting paths.")
}
_, copyError := io.CopyN(file, reader, header.Size)
syncError := file.Sync()
closeError := file.Close()
if err := errors.Join(copyError, syncError, closeError); err != nil {
return err
}
default:
return errors.New("Packages may contain only regular files and directories, without links or devices.")
}
}
// Read through the gzip trailer to detect corruption and expansion bombs.
if _, err := io.Copy(io.Discard, limited); err != nil {
return errors.New("Package compression is corrupt.")
}
if limited.N == 0 {
return ErrLimit
}
return nil
}
func validate_native_program(filename string) error {
info, err := os.Stat(filename)
if err != nil || !info.Mode().IsRegular() || info.Mode()&0100 == 0 {
return errors.New("Package entry point is missing or not executable.")
}
binary, err := elf.Open(filename)
if err != nil {
return errors.New("Native package entry point must be an ELF executable.")
}
defer binary.Close()
machines := map[string]elf.Machine{"amd64": elf.EM_X86_64, "arm64": elf.EM_AARCH64, "386": elf.EM_386, "arm": elf.EM_ARM, "riscv64": elf.EM_RISCV, "ppc64": elf.EM_PPC64, "ppc64le": elf.EM_PPC64, "s390x": elf.EM_S390}
machine, supported := machines[runtime.GOARCH]
if !supported || binary.Machine != machine || (binary.Type != elf.ET_EXEC && binary.Type != elf.ET_DYN) {
return errors.New("Package executable does not match this platform and architecture.")
}
return nil
}
func sync_directory(directory string) error {
file, err := os.Open(directory)
if err != nil {
return err
}
defer file.Close()
return file.Sync()
}
// RemovePackage never deletes explicitly added local programs or paths outside
// the managed root. Call only after a saved removal and after the session joins.
func RemovePackage(root string, entry Entry) error {
directory := entry.PackageDirectory
if directory == "" {
return nil
}
rootInfo, rootError := os.Lstat(root)
if rootError != nil || !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 {
return errors.New("Managed package root is unsafe.")
}
if filepath.Dir(directory) != filepath.Clean(root) || !strings.HasPrefix(filepath.Base(directory), "package-") || filepath.Clean(directory) != directory {
return errors.New("Refusing to remove a package outside the managed directory.")
}
info, err := os.Lstat(directory)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("Installed package directory is unsafe.")
}
if err := os.RemoveAll(directory); err != nil {
return err
}
return sync_directory(root)
}
// RecoverPackages runs once, after a successful registry load, before accepting
// management requests. The registry is the commit point; orphaned directories
// from interrupted installs/removals contain no live selected code.
func RecoverPackages(root string, entries []Entry) error {
info, err := os.Lstat(root)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("Managed package root is unsafe.")
}
retained := make(map[string]bool)
for _, entry := range entries {
for _, filename := range append([]string{entry.PackageDirectory, entry.Directory}, entry.Program...) {
relative, err := filepath.Rel(root, filename)
if err == nil && package_path(filepath.ToSlash(relative)) {
retained[strings.Split(filepath.ToSlash(relative), "/")[0]] = true
}
}
}
files, err := os.ReadDir(root)
if err != nil {
return err
}
for _, file := range files {
name := file.Name()
owned := false
for _, prefix := range []string{".staging-", ".download-", "package-"} {
if suffix, found := strings.CutPrefix(name, prefix); found && suffix != "" && len(suffix) <= 10 && !strings.ContainsFunc(suffix, func(value rune) bool { return value < '0' || value > '9' }) {
owned = true
}
}
if !owned || retained[name] || file.Type()&os.ModeSymlink != 0 {
continue
}
if err := os.RemoveAll(filepath.Join(root, name)); err != nil {
return err
}
}
return sync_directory(root)
}