// 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" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) 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 && packageManifest.Protocol.Minor != 1) { 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") } valid := false if packageManifest.Protocol.Minor == 0 { valid = !(readerSettings && !interactive || interactive && !presentation) && len(packageManifest.Capabilities) == len(expectedAPIs)-1 && slices.Equal(packageManifest.APIs, expectedAPIs) } else { valid = tentaclewire.ValidNegotiation(1, packageManifest.APIs, packageManifest.Capabilities) } if !valid { 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])) remoteGrants := []string{} for _, grant := range tentaclewire.RemoteGrants() { if slices.Contains(packageManifest.Capabilities, grant) { remoteGrants = append(remoteGrants, grant) } } prepared.Entry = Entry{SessionEvents: packageManifest.Protocol.Minor == 1, RemoteGrants: remoteGrants, 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) }