Files

286 lines
11 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"bytes"
"context"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path"
"slices"
"strings"
"syscall"
"time"
"unicode"
"github.com/pelletier/go-toml/v2"
)
// Repository is a validated discovery snapshot, never an installed package.
// Catalog refresh does not run code or change installed versions or grants.
type Repository struct {
URL string `toml:"url"`
ID string `toml:"id"`
Name string `toml:"name"`
Description string `toml:"description"`
Revision string `toml:"revision"`
Tentacles []Manifest `toml:"tentacles"`
}
// Manifest is cached discovery metadata. Releases describe immutable packages;
// launch permissions are read and validated again from the selected package.
type Manifest struct {
FormatVersion int `toml:"format-version"`
ID string `toml:"id"`
Name string `toml:"name"`
Description string `toml:"description"`
Version string `toml:"version"`
Releases []Release `toml:"releases,omitempty"`
Capabilities []string `toml:"capabilities,omitempty"`
}
type Release struct {
Platform string `toml:"platform"`
Architecture string `toml:"architecture"`
URL string `toml:"url"`
SHA256 string `toml:"sha256"`
}
func RepositoryURL(text string) (string, error) {
value, err := url.Parse(strings.TrimSpace(text))
if err != nil || value.Scheme != "https" || value.Hostname() == "" || value.User != nil || value.ForceQuery || value.RawQuery != "" || strings.Contains(text, "#") || value.Opaque != "" {
return "", errors.New("Use an HTTPS Git repository URL without credentials, a query, or a fragment.")
}
// Preserve the server's repository path: ordinary Git hosting may require
// the .git suffix even when the display label omits it.
value.Path = strings.TrimRight(value.Path, "/")
if value.Path == "" || value.Path == "/" || path.Clean(value.Path) != value.Path || strings.ContainsAny(value.Path, "\\\x00\r\n") || len(text) > 2048 {
return "", errors.New("Use a URL with a valid Git repository path.")
}
value.Host = strings.ToLower(value.Host)
value.RawPath = ""
return value.String(), nil
}
func RepositoryLabel(origin string) string {
value, err := url.Parse(origin)
if err != nil {
return origin
}
return strings.TrimSuffix(strings.Trim(value.Path, "/"), ".git")
}
func CloneRepositories(repositories []Repository) []Repository {
result := slices.Clone(repositories)
for index := range result {
result[index].Tentacles = slices.Clone(result[index].Tentacles)
for item := range result[index].Tentacles {
result[index].Tentacles[item].Releases = slices.Clone(result[index].Tentacles[item].Releases)
result[index].Tentacles[item].Capabilities = slices.Clone(result[index].Tentacles[item].Capabilities)
}
}
return result
}
func metadata_text(value string, required bool) bool {
return (!required || strings.TrimSpace(value) != "") && len(value) <= 4096 && !strings.ContainsFunc(value, func(character rune) bool { return unicode.IsControl(character) })
}
func metadata_id(value string) bool {
if len(value) == 0 || len(value) > 128 || value[0] < 'a' || value[0] > 'z' {
return false
}
for _, character := range value {
if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-' || character == '.') {
return false
}
}
return true
}
func ValidateRepositories(repositories []Repository) error {
if len(repositories) > 32 {
return ErrLimit
}
seen := make(map[string]bool)
count := 0
for _, repository := range repositories {
origin, err := RepositoryURL(repository.URL)
if err != nil || origin != repository.URL || seen[origin] || !metadata_id(repository.ID) || !metadata_text(repository.Name, true) || !metadata_text(repository.Description, false) || len(repository.Revision) != 40 && len(repository.Revision) != 64 {
return errors.New("invalid or duplicate tentacle repository metadata")
}
for _, character := range repository.Revision {
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return errors.New("invalid repository revision")
}
}
seen[origin] = true
ids := make(map[string]bool)
for _, manifest := range repository.Tentacles {
count++
if count > 256 {
return ErrLimit
}
if manifest.FormatVersion != 1 || !metadata_id(manifest.ID) || ids[manifest.ID] || !metadata_text(manifest.Name, true) || !metadata_text(manifest.Description, false) || !metadata_text(manifest.Version, true) {
return errors.New("invalid or duplicate tentacle manifest metadata")
}
ids[manifest.ID] = true
if len(manifest.Capabilities) > 16 {
return ErrLimit
}
capabilities := make(map[string]bool)
for _, capability := range manifest.Capabilities {
if !metadata_id(capability) || capabilities[capability] {
return errors.New("invalid or duplicate tentacle capability metadata")
}
capabilities[capability] = true
}
if err := validate_releases(manifest.Releases); err != nil {
return err
}
}
}
return nil
}
// read_repository uses regular blobs from one immutable revision. Tests supply
// this reader directly; the public fetch path only permits HTTPS origins.
func read_repository(origin, revision string, read func(string) ([]byte, error)) (Repository, error) {
repository := Repository{URL: origin, Revision: revision}
var metadata struct {
FormatVersion int `toml:"format-version"`
ID string `toml:"id"`
Name string `toml:"name"`
Description string `toml:"description"`
}
var catalog struct {
FormatVersion int `toml:"format-version"`
Tentacles []struct {
ID string `toml:"id"`
Manifest string `toml:"manifest"`
Releases []Release `toml:"releases"`
} `toml:"tentacles"`
}
total := 0
decode := func(filename string, value any) error {
data, err := read(filename)
if err != nil {
return fmt.Errorf("Cannot read %s from the repository.", filename)
}
total += len(data)
if total > MaxFrameBytes {
return ErrLimit
}
if err := toml.Unmarshal(data, value); err != nil {
return fmt.Errorf("Invalid TOML in %s.", filename)
}
return nil
}
if err := decode("repository.toml", &metadata); err != nil {
return repository, err
}
if metadata.FormatVersion != 1 {
return repository, errors.New("Unsupported repository.toml format-version; expected 1.")
}
repository.ID, repository.Name, repository.Description = metadata.ID, metadata.Name, metadata.Description
if err := decode("catalog.toml", &catalog); err != nil {
return repository, err
}
if catalog.FormatVersion != 1 {
return repository, errors.New("Unsupported catalog.toml format-version; expected 1.")
}
if len(catalog.Tentacles) > 256 {
return repository, ErrLimit
}
for _, item := range catalog.Tentacles {
parts := strings.Split(item.Manifest, "/")
if len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." || parts[1] != "tentacle.toml" || strings.ContainsAny(item.Manifest, "\\\x00\r\n:") || !metadata_text(item.Manifest, true) {
return repository, errors.New("Catalog manifests must be inside direct top-level tentacle folders.")
}
var manifest Manifest
if err := decode(item.Manifest, &manifest); err != nil {
return repository, err
}
if manifest.ID != item.ID {
return repository, errors.New("Catalog and manifest tentacle IDs do not match.")
}
// Release locations belong to the catalog, not to a package manifest.
manifest.Releases = slices.Clone(item.Releases)
repository.Tentacles = append(repository.Tentacles, manifest)
}
return repository, ValidateRepositories([]Repository{repository})
}
type boundedGitOutput struct{ bytes.Buffer }
func (output *boundedGitOutput) Write(data []byte) (int, error) {
if output.Len()+len(data) > MaxFrameBytes {
return 0, ErrLimit
}
return output.Buffer.Write(data)
}
func repository_git(ctx context.Context, directory string, arguments ...string) ([]byte, error) {
command := exec.CommandContext(ctx, "git", append([]string{"--literal-pathspecs", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", "gc.auto=0"}, arguments...)...)
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) }
command.WaitDelay = time.Second
command.Dir = directory
for _, variable := range os.Environ() {
if !strings.HasPrefix(variable, "GIT_") && !strings.HasPrefix(variable, "HOME=") && !strings.HasPrefix(variable, "XDG_CONFIG_HOME=") {
command.Env = append(command.Env, variable)
}
}
command.Env = append(command.Env, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false", "HOME="+directory, "XDG_CONFIG_HOME="+directory)
var output boundedGitOutput
command.Stdout = &output
// No repository-controlled diagnostic text enters Preferences or the reader.
if err := command.Run(); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, errors.New("Git could not read this repository; check the URL and connection.")
}
return output.Bytes(), nil
}
func FetchRepository(ctx context.Context, origin string) (Repository, error) {
origin, err := RepositoryURL(origin)
if err != nil {
return Repository{}, err
}
directory, err := os.MkdirTemp("", "cthulhu-repository-")
if err != nil {
return Repository{}, err
}
defer os.RemoveAll(directory)
if _, err := repository_git(ctx, directory, "init", "--bare", "--template="); err != nil {
return Repository{}, err
}
if _, err := repository_git(ctx, directory, "fetch", "--depth=1", "--no-tags", "--no-recurse-submodules", "--", origin, "HEAD"); err != nil {
return Repository{}, err
}
return read_git_repository(ctx, directory, origin)
}
func read_git_repository(ctx context.Context, directory, origin string) (Repository, error) {
revision, err := repository_git(ctx, directory, "rev-parse", "--verify", "FETCH_HEAD^{commit}")
if err != nil {
return Repository{}, err
}
commit := strings.TrimSpace(string(revision))
return read_repository(origin, commit, func(filename string) ([]byte, error) {
// Reject symlinks, trees and submodules before reading any metadata.
mode, err := repository_git(ctx, directory, "ls-tree", commit, "--", filename)
if err != nil || !(bytes.HasPrefix(mode, []byte("100644 blob ")) || bytes.HasPrefix(mode, []byte("100755 blob "))) {
return nil, errors.New("metadata must be a regular blob")
}
return repository_git(ctx, directory, "show", commit+":"+filename)
})
}