432 lines
15 KiB
Go
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)
|
|
}
|