// 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) }) }