Files

431 lines
11 KiB
Go

// Package remoteexample installs data-only, versioned example projects.
// No operation executes code, installs modules, launches the engine, or mutates
// an existing local copy. A caller must request those actions separately.
package remoteexample
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/netip"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"aego/core/ids"
"aego/project"
)
const FormatVersion = 1
type Limits struct {
ManifestBytes int64
ArchiveBytes int64
ExpandedBytes int64
FileBytes int64
Files int
ExpansionRatio uint64
}
func DefaultLimits() Limits { return Limits{1 << 20, 64 << 20, 256 << 20, 32 << 20, 8192, 500} }
func normalizeLimits(l Limits) Limits {
d := DefaultLimits()
if l.ManifestBytes > 0 {
d.ManifestBytes = min(d.ManifestBytes, l.ManifestBytes)
}
if l.ArchiveBytes > 0 {
d.ArchiveBytes = min(d.ArchiveBytes, l.ArchiveBytes)
}
if l.ExpandedBytes > 0 {
d.ExpandedBytes = min(d.ExpandedBytes, l.ExpandedBytes)
}
if l.FileBytes > 0 {
d.FileBytes = min(d.FileBytes, l.FileBytes)
}
if l.Files > 0 {
d.Files = min(d.Files, l.Files)
}
if l.ExpansionRatio > 0 {
d.ExpansionRatio = min(d.ExpansionRatio, l.ExpansionRatio)
}
return d
}
type File struct {
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type Archive struct {
URL string `json:"url"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type Compatibility struct {
Min string `json:"min"`
MaxExclusive string `json:"maxExclusive"`
}
type Manifest struct {
Format int `json:"format"`
ExampleID string `json:"exampleID"`
Version string `json:"version"`
Engine Compatibility `json:"engine"`
EntryScene string `json:"entryScene"`
Modules []string `json:"requiredModules"`
// AssetRefs inventories source dependencies, including stable built-ins.
// Older manifests can omit it; files and hashes remain authoritative.
AssetRefs []string `json:"assetRefs,omitempty"`
Files []File `json:"files"`
Archive Archive `json:"archive"`
License string `json:"license"`
Origin string `json:"origin"`
}
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,79}$`)
var versionRE = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
var versionIdentifierRE = regexp.MustCompile(`^[0-9A-Za-z-]+$`)
func version(v string) ([3]uint64, error) {
var out [3]uint64
if !versionRE.MatchString(v) {
return out, fmt.Errorf("invalid release version %q", v)
}
for i, p := range strings.Split(v, ".") {
n, e := strconv.ParseUint(p, 10, 32)
if e != nil {
return out, e
}
out[i] = n
}
return out, nil
}
// engineVersion accepts a running engine's SemVer prerelease/build suffixes.
// Published example versions and manifest compatibility bounds remain strict
// release triplets and continue to be validated by version above.
func engineVersion(v string) ([3]uint64, bool, error) {
invalid := func() ([3]uint64, bool, error) {
return [3]uint64{}, false, fmt.Errorf("invalid engine version %q", v)
}
if len(v) > 128 {
return invalid()
}
coreAndPre, build, hasBuild := strings.Cut(v, "+")
core, pre, hasPre := strings.Cut(coreAndPre, "-")
validIdentifiers := func(value string, numericNoLeadingZero bool) bool {
for _, id := range strings.Split(value, ".") {
if !versionIdentifierRE.MatchString(id) {
return false
}
if numericNoLeadingZero && len(id) > 1 && id[0] == '0' {
numeric := true
for _, c := range id {
if c < '0' || c > '9' {
numeric = false
break
}
}
if numeric {
return false
}
}
}
return true
}
if hasBuild && !validIdentifiers(build, false) || hasPre && !validIdentifiers(pre, true) {
return invalid()
}
numbers, err := version(core)
if err != nil {
return invalid()
}
return numbers, hasPre, nil
}
func less(a, b [3]uint64) bool {
for i := range a {
if a[i] != b[i] {
return a[i] < b[i]
}
}
return false
}
func (m Manifest) CheckEngine(current string, installed []string) error {
v, prerelease, e := engineVersion(current)
if e != nil {
return e
}
lo, e := version(m.Engine.Min)
if e != nil {
return e
}
hi, e := version(m.Engine.MaxExclusive)
if e != nil {
return e
}
// A prerelease is below its matching release, so it cannot meet that
// release's minimum. For the exclusive upper bound, conservatively reject
// prereleases of the next version as well as that version's release.
if less(v, lo) || prerelease && v == lo || !less(v, hi) {
return fmt.Errorf("example needs engine [%s,%s), got %s", m.Engine.Min, m.Engine.MaxExclusive, current)
}
have := map[string]bool{}
for _, id := range installed {
have[id] = true
}
for _, id := range m.Modules {
if !have[id] {
return fmt.Errorf("required installed module is unavailable: %s", id)
}
}
return nil
}
func httpsURL(raw string) (*url.URL, error) {
if len(raw) > 8192 {
return nil, fmt.Errorf("URL too long")
}
u, err := url.Parse(raw)
if err != nil || u == nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" {
return nil, fmt.Errorf("an absolute HTTPS URL without credentials or fragment is required")
}
if strings.ContainsAny(u.Host, "\\% \t\r\n") || strings.HasSuffix(u.Hostname(), ".") {
return nil, fmt.Errorf("ambiguous HTTPS host")
}
host := u.Hostname()
if _, err := netip.ParseAddr(host); err != nil {
// Accept DNS names in their explicit ASCII/IDNA form, never patterns,
// escaped hosts, underscores or ambiguous empty labels.
if len(host) > 253 || strings.HasSuffix(u.Host, ":") {
return nil, fmt.Errorf("invalid HTTPS hostname")
}
for _, label := range strings.Split(host, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return nil, fmt.Errorf("invalid HTTPS hostname")
}
for _, c := range label {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {
return nil, fmt.Errorf("invalid HTTPS hostname")
}
}
}
}
if strings.HasSuffix(u.Host, ":") {
return nil, fmt.Errorf("empty HTTPS port")
}
if p := u.Port(); p != "" && p != "443" {
n, e := strconv.Atoi(p)
if e != nil || n < 1 || n > 65535 {
return nil, fmt.Errorf("invalid HTTPS port")
}
}
return u, nil
}
func originOf(u *url.URL) string {
host := strings.ToLower(u.Hostname())
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
if u.Port() != "" && u.Port() != "443" {
host += ":" + u.Port()
}
return "https://" + host
}
func Origin(raw string) (string, error) {
u, e := httpsURL(raw)
if e != nil {
return "", e
}
return originOf(u), nil
}
func digestOK(s string) bool {
b, e := hex.DecodeString(s)
return e == nil && len(b) == 32 && s == strings.ToLower(s)
}
func (m Manifest) Validate(source string, lim Limits) error {
l := normalizeLimits(lim)
if m.Format != FormatVersion || !slugRE.MatchString(m.ExampleID) {
return fmt.Errorf("unsupported manifest or invalid example ID")
}
if _, err := version(m.Version); err != nil {
return err
}
lo, e := version(m.Engine.Min)
if e != nil {
return e
}
hi, e := version(m.Engine.MaxExclusive)
if e != nil {
return e
}
if !less(lo, hi) {
return fmt.Errorf("invalid engine interval")
}
so, e := Origin(source)
if e != nil {
return e
}
mo, e := Origin(m.Origin)
if e != nil || m.Origin != mo || mo != so {
return fmt.Errorf("manifest provenance does not match its HTTPS origin")
}
au, e := httpsURL(m.Archive.URL)
if e != nil || originOf(au) != so {
return fmt.Errorf("archive must stay on the approved HTTPS origin")
}
if m.Archive.Size < 1 || m.Archive.Size > l.ArchiveBytes || !digestOK(m.Archive.SHA256) {
return fmt.Errorf("invalid archive size or SHA-256")
}
if strings.TrimSpace(m.License) == "" || len(m.License) > 2048 {
return fmt.Errorf("license is required")
}
if len(m.Files) == 0 || len(m.Files) > l.Files {
return fmt.Errorf("file count exceeds limits")
}
mods := map[string]bool{}
for _, id := range m.Modules {
if !slugRE.MatchString(id) || mods[id] {
return fmt.Errorf("invalid or duplicate module ID")
}
mods[id] = true
}
if len(m.Modules) > 256 {
return fmt.Errorf("too many required modules")
}
if len(m.AssetRefs) > l.Files*8 {
return fmt.Errorf("too many asset references")
}
assetIDs := map[string]bool{}
for _, raw := range m.AssetRefs {
id, err := ids.ParseAssetID(raw)
if err != nil || raw != id.String() || id == (ids.AssetID{}) || assetIDs[raw] {
return fmt.Errorf("invalid or duplicate asset reference")
}
assetIDs[raw] = true
}
names := map[string]bool{}
spelling := map[string]string{}
var total int64
for _, f := range m.Files {
if e := validateResourcePath(f.Path); e != nil {
return e
}
for p := f.Path; p != "."; p = path.Dir(p) {
key := strings.ToLower(p)
if prior, exists := spelling[key]; exists && prior != p {
return fmt.Errorf("case-colliding resource path: %s", p)
}
spelling[key] = p
}
low := strings.ToLower(f.Path)
if names[low] {
return fmt.Errorf("duplicate or case-colliding file: %s", f.Path)
}
names[low] = true
if f.Size < 0 || f.Size > l.FileBytes || f.Size > l.ExpandedBytes-total || !digestOK(f.SHA256) {
return fmt.Errorf("invalid file size or digest: %s", f.Path)
}
total += f.Size
}
for _, f := range m.Files {
for parent := path.Dir(f.Path); parent != "."; parent = path.Dir(parent) {
if names[strings.ToLower(parent)] {
return fmt.Errorf("file/directory collision: %s", parent)
}
}
}
entry, projectFile := false, false
for _, f := range m.Files {
if f.Path == m.EntryScene {
entry = true
}
if f.Path == project.FileName {
projectFile = true
}
}
if !entry || !strings.HasSuffix(m.EntryScene, ".aescene") {
return fmt.Errorf("entry scene is not a listed .aescene")
}
if !projectFile {
return fmt.Errorf("project.aego is not a listed project resource")
}
return nil
}
func ParseManifest(data []byte, source string, lim Limits) (Manifest, error) {
var m Manifest
l := normalizeLimits(lim)
if int64(len(data)) > l.ManifestBytes {
return m, fmt.Errorf("manifest exceeds limits")
}
// Reject duplicate keys too; encoding/json alone accepts last-wins fields.
if err := uniqueJSON(data); err != nil {
return m, err
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&m); err != nil {
return m, err
}
if _, err := dec.Token(); err != io.EOF {
return m, fmt.Errorf("trailing manifest data")
}
return m, m.Validate(source, l)
}
func uniqueJSON(data []byte) error {
d := json.NewDecoder(bytes.NewReader(data))
var walk func(int) error
walk = func(depth int) error {
if depth > 32 {
return fmt.Errorf("JSON nesting exceeds limits")
}
t, e := d.Token()
if e != nil {
return e
}
v, ok := t.(json.Delim)
if !ok {
return nil
}
switch v {
case '{':
seen := map[string]bool{}
for d.More() {
k, e := d.Token()
if e != nil {
return e
}
s, ok := k.(string)
if !ok || seen[strings.ToLower(s)] {
return fmt.Errorf("duplicate JSON key")
}
seen[strings.ToLower(s)] = true
if e := walk(depth + 1); e != nil {
return e
}
}
case '[':
for d.More() {
if e := walk(depth + 1); e != nil {
return e
}
}
default:
return fmt.Errorf("invalid JSON delimiter")
}
_, e = d.Token()
return e
}
if e := walk(0); e != nil {
return e
}
if _, e := d.Token(); e != io.EOF {
return fmt.Errorf("trailing JSON")
}
return nil
}