Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,285 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user