Files

356 lines
10 KiB
Go

package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"aego/art"
)
const manifestVersion = 1
const embedProtocolVersion = 1
const maxArtifactBytes int64 = 96 << 20
const maxFileBytes int64 = 64 << 20
var identifier = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$`)
var shaPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
var commitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`)
var hashedFile = regexp.MustCompile(`\.[0-9a-f]{16}\.`)
type resource struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
SHA256 string `json:"sha256"`
MIME string `json:"mime"`
}
type example struct {
ID string `json:"id"`
Version int `json:"version"`
Title map[string]string `json:"title"`
Description map[string]string `json:"description"`
File string `json:"file"`
Thumbnail string `json:"thumbnail"`
SHA256 string `json:"sha256"`
Bytes int `json:"bytes"`
}
type effectTemplate struct {
example
ParticlePreset string `json:"particlePreset"`
}
type buildManifest struct {
Schema int `json:"schema"`
AegoVersion string `json:"aegoVersion"`
EngineSourceVersion string `json:"engineSourceVersion,omitempty"`
StudioVersion string `json:"studioVersion"`
DocumentVersion int `json:"documentVersion"`
ProtocolVersion int `json:"protocolVersion"`
BuildID string `json:"buildID"`
Commit string `json:"commit,omitempty"`
SourceSHA256 string `json:"sourceSHA256"`
BuiltAt string `json:"builtAt"`
Toolchain string `json:"toolchain"`
Release bool `json:"release"`
AppOrigin string `json:"appOrigin"`
DocsOrigins []string `json:"docsOrigins"`
WASM resource `json:"wasm"`
PreviewWASM *resource `json:"previewWasm,omitempty"`
Examples []example `json:"examples"`
Templates []effectTemplate `json:"templates,omitempty"`
Files []resource `json:"files"`
}
func digest(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
func mimeFor(p string) string {
switch path.Ext(p) {
case ".wasm":
return "application/wasm"
case ".js":
return "text/javascript; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".html":
return "text/html; charset=utf-8"
case ".json":
return "application/json; charset=utf-8"
case ".png":
return "image/png"
case ".svg":
return "image/svg+xml"
case ".txt":
return "text/plain; charset=utf-8"
default:
return "application/octet-stream"
}
}
func validPath(p string) bool {
return p != "" && !strings.ContainsAny(p, "\\\x00:\n\r?#%") && !strings.HasPrefix(p, "/") && path.Clean(p) == p && p != "." && p != ".." && !strings.HasPrefix(p, "../")
}
func validOrigin(raw string, release bool) bool {
u, e := url.Parse(raw)
if e != nil || u.User != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.Path != "" || u.String() != raw {
return false
}
if u.Scheme == "https" {
return true
}
return !release && u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" || u.Hostname() == "::1")
}
func decodeManifest(b []byte) (buildManifest, error) {
var m buildManifest
if len(b) > 2<<20 {
return m, errors.New("manifest size limit")
}
d := json.NewDecoder(strings.NewReader(string(b)))
d.DisallowUnknownFields()
if err := d.Decode(&m); err != nil {
return m, err
}
if err := d.Decode(new(any)); err != io.EOF {
return m, errors.New("trailing manifest data")
}
if m.Schema != manifestVersion || m.ProtocolVersion != embedProtocolVersion || m.DocumentVersion != 1 || !identifier.MatchString(m.BuildID) || !shaPattern.MatchString(m.SourceSHA256) || m.BuiltAt == "" || m.Toolchain == "" {
return m, errors.New("unsupported or incomplete manifest")
}
if m.Release && !commitPattern.MatchString(m.Commit) {
return m, errors.New("release requires exact Git commit")
}
if m.EngineSourceVersion != "" && !engineSourceVersionPattern.MatchString(m.EngineSourceVersion) {
return m, errors.New("invalid engine source version")
}
if !validOrigin(m.AppOrigin, m.Release) || len(m.DocsOrigins) == 0 || len(m.DocsOrigins) > 8 {
return m, errors.New("invalid origins")
}
for _, o := range m.DocsOrigins {
if !validOrigin(o, m.Release) {
return m, errors.New("invalid documentation origin")
}
}
if len(m.Files) < 6 || len(m.Files) > 4096 {
return m, errors.New("invalid file count")
}
seen := map[string]resource{}
var total int64
for _, f := range m.Files {
if !validPath(f.Path) || f.Path == "manifest.json" || f.Bytes < 0 || f.Bytes > maxFileBytes || !shaPattern.MatchString(f.SHA256) || f.MIME != mimeFor(f.Path) {
return m, fmt.Errorf("invalid file record %q", f.Path)
}
if _, ok := seen[f.Path]; ok {
return m, fmt.Errorf("duplicate file %s", f.Path)
}
seen[f.Path] = f
total += f.Bytes
}
if total > maxArtifactBytes {
return m, errors.New("artifact size limit")
}
for _, p := range []string{"index.html", "editor/index.html", "embed/index.html"} {
if _, ok := seen[p]; !ok {
return m, fmt.Errorf("missing route %s", p)
}
}
if f, ok := seen[m.WASM.Path]; !ok || f != m.WASM || !strings.HasSuffix(f.Path, ".wasm") {
return m, errors.New("WASM manifest mismatch")
}
// Optional for previously published artifacts, so rollback verification
// remains compatible. Every current build includes the lazy GPU runtime.
if p := m.PreviewWASM; p != nil {
if f, ok := seen[p.Path]; !ok || f != *p || !strings.HasSuffix(f.Path, ".wasm") || p.Path == m.WASM.Path {
return m, errors.New("preview WASM manifest mismatch")
}
}
ex := map[string]bool{}
for _, e := range m.Examples {
f, ok := seen[e.File]
if !identifier.MatchString(e.ID) || ex[e.ID] || e.Version < 1 || !ok || f.SHA256 != e.SHA256 || f.Bytes != int64(e.Bytes) || e.Title["en"] == "" || e.Title["ru"] == "" {
return m, errors.New("invalid example")
}
ex[e.ID] = true
if _, ok := seen[e.Thumbnail]; !ok {
return m, errors.New("missing thumbnail")
}
}
// Older manifests intentionally omit this additive catalog for rollback.
// A present catalog must cover the same shared templates as the Go host.
if len(m.Templates) != 0 && len(m.Templates) != len(art.EffectTemplates()) {
return m, errors.New("incomplete effect template catalog")
}
templates := map[string]bool{}
for _, e := range m.Templates {
known, ok := art.EffectTemplateOf(e.ID)
f, found := seen[e.File]
if !ok || templates[e.ID] || e.ParticlePreset != known.ParticlePreset || e.Version < 1 || !found || f.SHA256 != e.SHA256 || f.Bytes != int64(e.Bytes) || e.Title["en"] == "" || e.Title["ru"] == "" {
return m, errors.New("invalid effect template")
}
templates[e.ID] = true
if _, ok := seen[e.Thumbnail]; !ok {
return m, errors.New("missing effect template thumbnail")
}
}
return m, nil
}
func noSymlinkPath(p string) error {
p, err := filepath.Abs(p)
if err != nil {
return err
}
for {
st, e := os.Lstat(p)
if e != nil {
return e
}
if st.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink path refused: %s", p)
}
parent := filepath.Dir(p)
if parent == p {
return nil
}
p = parent
}
}
func readBounded(file string, max int64) ([]byte, error) {
st, err := os.Lstat(file)
if err != nil {
return nil, err
}
if !st.Mode().IsRegular() || st.Size() > max {
return nil, fmt.Errorf("non-regular or oversized file: %s", file)
}
f, err := os.Open(file)
if err != nil {
return nil, err
}
defer f.Close()
b, err := io.ReadAll(io.LimitReader(f, max+1))
if err != nil {
return nil, err
}
if int64(len(b)) > max {
return nil, errors.New("file grew beyond limit")
}
return b, nil
}
func verify(dir, expected string) (buildManifest, string, error) {
var empty buildManifest
if err := noSymlinkPath(dir); err != nil {
return empty, "", err
}
b, err := readBounded(filepath.Join(dir, "manifest.json"), 2<<20)
if err != nil {
return empty, "", err
}
hash := digest(b)
if expected != "" && hash != expected {
return empty, hash, errors.New("manifest SHA-256 mismatch")
}
m, err := decodeManifest(b)
if err != nil {
return m, hash, err
}
files := map[string]resource{}
for _, f := range m.Files {
files[f.Path] = f
}
count := 0
err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink refused: %s", p)
}
if d.IsDir() {
return nil
}
rel, e := filepath.Rel(dir, p)
if e != nil {
return e
}
rel = filepath.ToSlash(rel)
if rel == "manifest.json" {
return nil
}
f, ok := files[rel]
if !ok {
return fmt.Errorf("unlisted artifact file: %s", rel)
}
b, e := readBounded(p, maxFileBytes)
if e != nil {
return e
}
if int64(len(b)) != f.Bytes || digest(b) != f.SHA256 {
return fmt.Errorf("file integrity mismatch: %s", rel)
}
count++
return nil
})
if err == nil && count != len(files) {
err = errors.New("artifact is incomplete")
}
return m, hash, err
}
func inventory(dir string) ([]resource, error) {
var files []resource
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
if d.Type()&os.ModeSymlink != 0 {
return errors.New("symlink in output")
}
if d.IsDir() {
return nil
}
rel, _ := filepath.Rel(dir, p)
rel = filepath.ToSlash(rel)
if rel == "manifest.json" {
return nil
}
b, e := readBounded(p, maxFileBytes)
if e != nil {
return e
}
files = append(files, resource{rel, int64(len(b)), digest(b), mimeFor(rel)})
return nil
})
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
return files, err
}
func writeNew(p string, b []byte) error {
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
return err
}
f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
return err
}
_, err = f.Write(b)
if err == nil {
err = f.Sync()
}
ce := f.Close()
if err != nil {
return err
}
return ce
}
func jsonBytes(v any) []byte {
b, e := json.MarshalIndent(v, "", " ")
if e != nil {
panic(e)
}
return append(b, '\n')
}