Files
sanspie a46347e2c6
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m19s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Failing after 23m57s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: failure
GitHub / darwin amd64 native GitHub-hosted native CI: failure
GitHub / linux amd64 native GitHub-hosted native CI: success
editor: add art studio and stage 12.5 workflows
2026-11-08 18:47:57 +03:00

557 lines
18 KiB
Go

package remoteexample
import (
"archive/zip"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
)
func packTest(t *testing.T, files map[string][]byte, symlink string) []byte {
t.Helper()
var b bytes.Buffer
z := zip.NewWriter(&b)
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
h := &zip.FileHeader{Name: name, Method: zip.Deflate}
h.SetMode(0600)
if name == symlink {
h.SetMode(os.ModeSymlink | 0777)
}
w, e := z.CreateHeader(h)
if e != nil {
t.Fatal(e)
}
if _, e = w.Write(files[name]); e != nil {
t.Fatal(e)
}
}
if e := z.Close(); e != nil {
t.Fatal(e)
}
return b.Bytes()
}
func exampleManifest(origin string, packed []byte, files map[string][]byte) Manifest {
m := Manifest{Format: 1, ExampleID: "platformer-demo", Version: "1.0.0", Engine: Compatibility{"0.12.1", "0.13.0"}, EntryScene: "scenes/main.aescene", Modules: []string{"render2d"}, License: "MIT", Origin: origin, Archive: Archive{origin + "/example.zip", int64(len(packed)), hashBytes(packed)}}
for p, b := range files {
m.Files = append(m.Files, File{p, int64(len(b)), hashBytes(b)})
}
sort.Slice(m.Files, func(i, j int) bool { return m.Files[i].Path < m.Files[j].Path })
return m
}
type fixture struct {
mu sync.Mutex
m Manifest
packed []byte
server *httptest.Server
client *Client
store *Store
hits int
}
func newFixture(t *testing.T) *fixture {
t.Helper()
files := map[string][]byte{"scenes/main.aescene": []byte("scene { name=\"Main\" }"), "scripts/player.aes": []byte("func Ready() {}"), "project.aego": []byte("project {}")}
f := &fixture{packed: packTest(t, files, "")}
f.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.hits++
switch r.URL.Path {
case "/manifest.json":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(f.m)
case "/example.zip":
_, _ = w.Write(f.packed)
case "/redirect":
http.Redirect(w, r, "/manifest.json", http.StatusFound)
case "/bad-redirect":
http.Redirect(w, r, "https://example.invalid/manifest.json", http.StatusFound)
case "/loop":
http.Redirect(w, r, "/loop", http.StatusFound)
default:
http.NotFound(w, r)
}
}))
f.m = exampleManifest(f.server.URL, f.packed, files)
var e error
f.client, e = NewClient(Policy{EngineVersion: "0.12.1", InstalledModules: []string{"render2d"}})
if e != nil {
t.Fatal(e)
}
// Only this local TLS fixture replaces publicDial. Production has no
// exported option for disabling destination checks or TLS verification.
f.client.http = f.server.Client()
f.store, e = NewStore(filepath.Join(t.TempDir(), "examples"), Limits{})
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { f.client.Close(); f.server.Close() })
return f
}
func (f *fixture) url() string { return f.server.URL + "/manifest.json" }
func (f *fixture) clone(t *testing.T) Copy {
t.Helper()
c, e := f.store.Clone(context.Background(), f.client, f.url(), f.server.URL)
if e != nil {
t.Fatal(e)
}
return c
}
func (f *fixture) replaceArchive(t *testing.T, files map[string][]byte, link string) {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
f.packed = packTest(t, files, link)
f.m.Archive.Size = int64(len(f.packed))
f.m.Archive.SHA256 = hashBytes(f.packed)
}
func TestHTTPSCloneAndOfflineEditedCopy(t *testing.T) {
f := newFixture(t)
c := f.clone(t)
if b, e := os.ReadFile(c.Entry); e != nil || !strings.Contains(string(b), "Main") {
t.Fatal(string(b), e)
}
user := []byte("scene { name=\"User edited\" }")
if e := os.WriteFile(c.Entry, user, 0600); e != nil {
t.Fatal(e)
}
f.server.Close()
got, e := f.store.OpenRecent(f.url())
if e != nil {
t.Fatal(e)
}
b, e := os.ReadFile(got.Entry)
if e != nil || !bytes.Equal(b, user) {
t.Fatal("offline reopen destroyed edited source", e)
}
if got.Directory != c.Directory {
t.Fatal("reopen cloned instead of using local project")
}
}
func TestEngineVersionPrereleaseCompatibility(t *testing.T) {
m := Manifest{Engine: Compatibility{Min: "0.12.1", MaxExclusive: "0.13.0"}, Modules: []string{"render2d"}}
for _, tc := range []struct {
version string
valid bool
allowed bool
}{
{"0.12.1", true, true},
{"0.12.1+build.07", true, true},
{"0.12.1-rc.1", true, false}, // lower bound is a final release
{"0.12.5-dev", true, true},
{"0.12.5-rc.1+build.07", true, true},
{"0.13.0-rc.1", true, false}, // conservative exclusive upper bound
{"0.13.0", true, false},
{"0.12.0", true, false},
{"0.12.05-dev", false, false},
{"0.12.5-dev.01", false, false},
{"0.12.5-", false, false},
{"0.12.5+", false, false},
{"0.12.5-dev+build+extra", false, false},
{"0.12.5-dev/unsafe", false, false},
} {
t.Run(tc.version, func(t *testing.T) {
client, err := NewClient(Policy{EngineVersion: tc.version})
if client != nil {
client.Close()
}
if (err == nil) != tc.valid {
t.Fatalf("client version validation: %v; valid=%v", err, tc.valid)
}
err = m.CheckEngine(tc.version, []string{"render2d"})
if (err == nil) != tc.allowed {
t.Fatalf("compatibility: %v; allowed=%v", err, tc.allowed)
}
})
}
if err := m.CheckEngine("0.12.5-dev", nil); err == nil {
t.Fatal("prerelease engine bypassed required module checks")
}
if _, err := version("1.0.0-dev"); err == nil {
t.Fatal("example and manifest release versions must remain strict")
}
}
func TestUntrustedOriginRequiresExplicitApproval(t *testing.T) {
f := newFixture(t)
if _, e := f.client.FetchManifest(context.Background(), f.url(), ""); e == nil {
t.Fatal("unapproved source accepted")
}
if _, e := f.client.FetchManifest(context.Background(), f.url(), "https://other.invalid"); e == nil {
t.Fatal("wrong origin approved")
}
f.mu.Lock()
defer f.mu.Unlock()
if f.hits != 0 {
t.Fatal("unapproved request sent")
}
}
func TestTrustedOriginsAreExact(t *testing.T) {
for _, origin := range []string{"http://example.com", "https://example.com/catalog", "https://*.example.com"} {
c, e := NewClient(Policy{EngineVersion: "0.12.1", TrustedOrigins: []string{origin}})
if c != nil {
c.Close()
}
if e == nil {
t.Fatalf("invalid origin accepted: %s", origin)
}
}
}
func TestSameOriginRedirectAndRejectedCrossOrigin(t *testing.T) {
f := newFixture(t)
if _, e := f.client.FetchManifest(context.Background(), f.server.URL+"/redirect", f.server.URL); e != nil {
t.Fatal(e)
}
for _, route := range []string{"/bad-redirect", "/loop"} {
if _, e := f.client.FetchManifest(context.Background(), f.server.URL+route, f.server.URL); e == nil {
t.Fatal("bad redirect accepted")
}
}
}
func TestProductionRejectsPrivateDestinations(t *testing.T) {
c, e := NewClient(Policy{EngineVersion: "0.12.1"})
if e != nil {
t.Fatal(e)
}
defer c.Close()
if _, e = c.FetchManifest(context.Background(), "https://127.0.0.1:1/example.json", "https://127.0.0.1:1"); e == nil || !strings.Contains(e.Error(), "non-public") {
t.Fatalf("private destination not rejected before TLS: %v", e)
}
for _, s := range []string{"127.0.0.1", "10.1.2.3", "169.254.169.254", "100.64.0.1", "0.1.2.3", "192.0.2.5", "::1", "fc00::1", "fe80::1", "::ffff:127.0.0.1", "2001:db8::1", "64:ff9b::7f00:1", "2002:7f00:1::"} {
if publicIP(netip.MustParseAddr(s)) {
t.Fatalf("non-public IP accepted %s", s)
}
}
for _, s := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
if !publicIP(netip.MustParseAddr(s)) {
t.Fatalf("public IP rejected %s", s)
}
}
}
func TestArchiveHashFailureLeavesNoProject(t *testing.T) {
f := newFixture(t)
f.m.Archive.SHA256 = strings.Repeat("0", 64)
if _, e := f.store.Clone(context.Background(), f.client, f.url(), f.server.URL); e == nil {
t.Fatal("bad archive hash accepted")
}
entries, _ := os.ReadDir(filepath.Join(f.store.root, "projects"))
if len(entries) != 0 {
t.Fatal("partial project published")
}
}
func TestFileHashFailureLeavesNoProject(t *testing.T) {
f := newFixture(t)
f.m.Files[0].SHA256 = strings.Repeat("0", 64)
if _, e := f.store.Clone(context.Background(), f.client, f.url(), f.server.URL); e == nil {
t.Fatal("bad file hash accepted")
}
entries, _ := os.ReadDir(filepath.Join(f.store.root, "projects"))
if len(entries) != 0 {
t.Fatal("partial extraction leaked")
}
}
func TestManifestUnsafePaths(t *testing.T) {
for _, p := range []string{"../escape.png", "/tmp/a.png", "C:/a.png", "a\\b.png", "a/../b.png", "a//b.png", "a/CON.png", "a/x.png:ads", "a/ending. ", "a/../.git/config", ".env", "a/NUL", "a/../", "a/a\x00.png", "a/a?.png", "a/x<y.png", "a/рисунок.png"} {
if e := cleanPath(p); e == nil {
t.Fatalf("unsafe path accepted %q", p)
}
}
for _, p := range []string{"assets/player.png", "Scripts/Player.aes", "scenes/Main.aescene", "project.aego"} {
if e := cleanPath(p); e != nil {
t.Fatal(p, e)
}
}
}
func TestManifestDuplicatesNativeFilesAndMissingEntry(t *testing.T) {
f := newFixture(t)
tests := []struct {
name string
mutate func(*Manifest)
}{
{"duplicate", func(m *Manifest) { m.Files = append(m.Files, m.Files[0]) }},
{"case parent", func(m *Manifest) { m.Files = append(m.Files, File{"Scenes/other.aescene", 0, hashBytes(nil)}) }},
{"native", func(m *Manifest) { m.Files = append(m.Files, File{"plugin.so", 0, hashBytes(nil)}) }},
{"engine source", func(m *Manifest) { m.Files = append(m.Files, File{"engine.go", 0, hashBytes(nil)}) }},
{"missing entry", func(m *Manifest) { m.EntryScene = "missing.aescene" }},
{"license", func(m *Manifest) { m.License = "" }},
{"provenance", func(m *Manifest) { m.Origin = "https://evil.invalid" }},
{"archive origin", func(m *Manifest) { m.Archive.URL = "https://evil.invalid/x.zip" }},
{"bad engine interval", func(m *Manifest) { m.Engine.MaxExclusive = m.Engine.Min }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
m := f.m
m.Files = append([]File(nil), m.Files...)
test.mutate(&m)
if e := m.Validate(f.url(), Limits{}); e == nil {
t.Fatal("invalid manifest accepted")
}
})
}
}
func TestDuplicateAndUnknownJSONKeys(t *testing.T) {
f := newFixture(t)
data, _ := json.Marshal(f.m)
for _, bad := range [][]byte{
append([]byte(`{"format":1,`), data[1:]...),
append([]byte(`{"FORMAT":1,`), data[1:]...),
append([]byte(`{"shell":"rm -rf /",`), data[1:]...),
append(append([]byte(nil), data...), []byte(` {}`)...),
} {
if _, e := ParseManifest(bad, f.url(), Limits{}); e == nil {
t.Fatal("ambiguous JSON accepted")
}
}
}
func TestResourceLimitsAndEngineCompatibility(t *testing.T) {
f := newFixture(t)
if e := f.m.CheckEngine("0.12.0", []string{"render2d"}); e == nil {
t.Fatal("old engine accepted")
}
if e := f.m.CheckEngine("0.13.0", []string{"render2d"}); e == nil {
t.Fatal("exclusive version bound ignored")
}
if e := f.m.CheckEngine("0.12.1", nil); e == nil {
t.Fatal("missing module accepted")
}
for _, limits := range []Limits{{Files: 1}, {FileBytes: 1}, {ExpandedBytes: 1}, {ArchiveBytes: 1}} {
if e := f.m.Validate(f.url(), limits); e == nil {
t.Fatal("limit ignored", limits)
}
}
}
func TestTraversalUnexpectedFileAndSymlinkArchives(t *testing.T) {
for _, bad := range []string{"../outside.aescene", "extra.png", "assets/link.png"} {
t.Run(bad, func(t *testing.T) {
f := newFixture(t)
files := map[string][]byte{"scenes/main.aescene": []byte("scene {}"), bad: []byte("../../outside")}
f.replaceArchive(t, files, bad)
if _, e := f.store.Clone(context.Background(), f.client, f.url(), f.server.URL); e == nil {
t.Fatal("unsafe archive accepted")
}
if _, e := os.Stat(filepath.Join(f.store.root, "outside.aescene")); !os.IsNotExist(e) {
t.Fatal("escaped extraction")
}
entries, _ := os.ReadDir(filepath.Join(f.store.root, "projects"))
if len(entries) != 0 {
t.Fatal("partial project remains")
}
})
}
}
func TestDuplicateZipEntriesRejected(t *testing.T) {
f := newFixture(t)
var b bytes.Buffer
z := zip.NewWriter(&b)
for i := 0; i < 2; i++ {
w, _ := z.Create("scenes/main.aescene")
_, _ = w.Write([]byte("scene {}"))
}
_ = z.Close()
f.packed = b.Bytes()
f.m = exampleManifest(f.server.URL, f.packed, map[string][]byte{"scenes/main.aescene": []byte("scene {}")})
if _, e := f.store.Clone(context.Background(), f.client, f.url(), f.server.URL); e == nil {
t.Fatal("duplicate ZIP accepted")
}
}
func TestZipBombAndHeaderCountPreflight(t *testing.T) {
f := newFixture(t)
files := map[string][]byte{"scenes/main.aescene": bytes.Repeat([]byte("a"), 1<<20)}
f.packed = packTest(t, files, "")
f.m = exampleManifest(f.server.URL, f.packed, files)
if _, e := f.store.Clone(context.Background(), f.client, f.url(), f.server.URL); e == nil {
t.Fatal("compression bomb accepted")
}
b := append([]byte(nil), f.packed...)
i := bytes.LastIndex(b, []byte{'P', 'K', 5, 6})
binary.LittleEndian.PutUint16(b[i+10:i+12], 65535)
name := filepath.Join(t.TempDir(), "bad.zip")
_ = os.WriteFile(name, b, 0600)
if e := preflightZip(name, 10, 64<<20); e == nil {
t.Fatal("oversized central count accepted")
}
b = append([]byte("MZ"), f.packed...)
_ = os.WriteFile(name, b, 0600)
if e := preflightZip(name, 10, 64<<20); e == nil {
t.Fatal("prefixed archive accepted")
}
}
func TestStoreAndOfflineSymlinksRejected(t *testing.T) {
root := t.TempDir()
if e := os.Symlink(root, filepath.Join(root, "link")); e != nil {
t.Skip(e)
}
if _, e := NewStore(filepath.Join(root, "link", "store"), Limits{}); e == nil {
t.Fatal("symlinked store accepted")
}
f := newFixture(t)
c := f.clone(t)
_ = os.Remove(c.Entry)
_ = os.Symlink("/etc/passwd", c.Entry)
if _, e := f.store.Reopen(c.ID); e == nil {
t.Fatal("offline symlink entry accepted")
}
}
func TestOfflineProjectRejectsSymlinkedProjectOrAsset(t *testing.T) {
for _, target := range []string{"project.aego", "assets/nested-link", ".aego/cache-link"} {
t.Run(target, func(t *testing.T) {
f := newFixture(t)
c := f.clone(t)
link := filepath.Join(c.Directory, filepath.FromSlash(target))
if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(link); err == nil {
if err := os.Remove(link); err != nil {
t.Fatal(err)
}
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(t.TempDir(), "outside"), link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if _, err := f.store.Reopen(c.ID); err == nil {
t.Fatal("offline reopen accepted a symlink in the editable project tree")
}
})
}
}
func TestUpdateCheckNeverOverwritesEditedCopy(t *testing.T) {
f := newFixture(t)
local := f.clone(t)
edited := []byte("edited")
_ = os.WriteFile(local.Entry, edited, 0600)
f.mu.Lock()
f.m.Version = "1.1.0"
f.mu.Unlock()
u, e := f.client.CheckUpdate(context.Background(), local, f.server.URL)
if e != nil || !u.Available {
t.Fatal(u, e)
}
b, _ := os.ReadFile(local.Entry)
if !bytes.Equal(b, edited) {
t.Fatal("update check overwrote edit")
}
next := f.clone(t)
if next.Directory == local.Directory {
t.Fatal("clone reused modified directory")
}
b, _ = os.ReadFile(local.Entry)
if !bytes.Equal(b, edited) {
t.Fatal("clone overwrote existing project")
}
}
func TestMutatedPublishedVersionRejected(t *testing.T) {
f := newFixture(t)
local := f.clone(t)
f.mu.Lock()
f.m.Archive.SHA256 = strings.Repeat("1", 64)
f.mu.Unlock()
if _, e := f.client.CheckUpdate(context.Background(), local, f.server.URL); e == nil {
t.Fatal("same version changed content silently")
}
}
func TestCancellationAndStoreLock(t *testing.T) {
f := newFixture(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, e := f.store.Clone(ctx, f.client, f.url(), f.server.URL); e == nil {
t.Fatal("canceled install succeeded")
}
unlock, e := f.store.lock()
if e != nil {
t.Fatal(e)
}
defer unlock()
if _, e := f.store.Clone(context.Background(), f.client, f.url(), f.server.URL); e == nil {
t.Fatal("concurrent writer was not rejected")
}
}
func TestDeepLinksAreDataNotCommands(t *testing.T) {
source := "https://example.com/catalog/hero.json?v=1"
link, e := MakeLink(source, "scene")
if e != nil {
t.Fatal(e)
}
got, e := ParseLink(link)
if e != nil || got.ManifestURL != source || got.Mode != "scene" {
t.Fatal(got, e)
}
for _, bad := range []string{
link + "&exec=sh", link + "&path=/tmp/p", link + "&manifest=https://other.invalid",
"aego://example?manifest=file:///etc/passwd", "aego://example?manifest=http://example.com",
"aego://example?manifest=https://example.com&mode=play", "aego://example/path?manifest=https://example.com",
"aego://user@example?manifest=https://example.com", "aego://example?manifest=https://user:pass@example.com",
} {
if _, e := ParseLink(bad); e == nil {
t.Fatalf("unsafe deep link accepted: %s", bad)
}
}
}
func FuzzManifest(f *testing.F) {
f.Add([]byte(`{"format":1}`))
f.Add([]byte(`[]`))
f.Add([]byte(`{"a":{"a":1,"a":2}}`))
f.Fuzz(func(t *testing.T, b []byte) {
if len(b) > 1<<20 {
t.Skip()
}
_, _ = ParseManifest(b, "https://example.com/m.json", Limits{})
})
}
func FuzzDeepLink(f *testing.F) {
f.Add("aego://example?manifest=https://example.com/m.json")
f.Fuzz(func(t *testing.T, s string) { _, _ = ParseLink(s) })
}
func TestHTTPSizeBoundedEvenWithoutContentLength(t *testing.T) {
f := newFixture(t)
f.client.policy.Limits.ManifestBytes = 32
if _, e := f.client.FetchManifest(context.Background(), f.url(), f.server.URL); e == nil {
t.Fatal("oversized HTTP body accepted")
}
}
func TestCopyIDCannotEscapeStore(t *testing.T) {
f := newFixture(t)
for _, id := range []string{"..", "../project", "/tmp", "a\\b", strings.Repeat("x", 200)} {
if _, e := f.store.Reopen(id); e == nil {
t.Fatal("invalid local ID", fmt.Sprint(id))
}
}
}
func TestManifestMustIncludeTheProjectFile(t *testing.T) {
f := newFixture(t)
m := f.m
m.Files = nil
for _, file := range f.m.Files {
if file.Path != "project.aego" {
m.Files = append(m.Files, file)
}
}
if err := m.Validate(f.url(), Limits{}); err == nil {
t.Fatal("manifest without project.aego was accepted")
}
}