286 lines
11 KiB
Go
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)
|
|
})
|
|
}
|