Bound native container reads and decoded allocation, make publication immutable and durable, enforce reproducible authoring budgets and legal receipts, serialize desktop intake, and publish releases transactionally with packaged acceptance.
- commit
- a32d5f24e7a752ef2faecf5887f095a1e89edb07
- parent
- d0a1b390b78aa588f91745918c4bc584d7f1989c
- author
- Dami <dami.etoile@gmail.com>
@@ -57,6 +57,28 @@ env:
5757 GOFLAGS: -trimpath -buildvcs=false5858 5959 jobs:60+ verify:61+ name: release acceptance62+ runs-on: ubuntu-24.0463+ steps:64+ - uses: actions/checkout@v565+ - uses: actions/setup-go@v566+ with:67+ go-version-file: go.mod68+ - uses: actions/setup-node@v469+ with:70+ node-version: "24"71+ cache: npm72+ cache-dependency-path: package-lock.json73+ - name: Install test dependencies74+ run: |75+ npm ci --ignore-scripts76+ npx playwright install --with-deps chromium77+ - name: Required correctness gates78+ run: |79+ make test80+ make test-e2e81+6082 cli:6183 name: atlas ${{ matrix.goos }}/${{ matrix.goarch }}6284 runs-on: ubuntu-24.04@@ -97,7 +119,7 @@ jobs:
97119 BINARY="atlas"98120 if [[ "${GOOS}" == "windows" ]]; then BINARY="atlas.exe"; fi99121 mkdir -p "dist/${NAME}"100- go build -ldflags "-s -w" -o "dist/${NAME}/${BINARY}" ./cmd/atlas122+ go build -ldflags "-s -w -X main.version=${VERSION}" -o "dist/${NAME}/${BINARY}" ./cmd/atlas101123 cp README.md LICENSE "dist/${NAME}/"102124 cd dist103125 if [[ "${GOOS}" == "windows" ]]; then@@ -180,7 +202,7 @@ jobs:
180202 BINARY="Atlas"181203 if [[ "${{ matrix.target }}" == windows-* ]]; then BINARY="Atlas.exe"; fi182204 go build -tags "${{ matrix.tags }}" \183- -ldflags "-s -w ${{ matrix.ldflags }}" -o "${BINARY}" .205+ -ldflags "-s -w -X main.version=${VERSION} ${{ matrix.ldflags }}" -o "${BINARY}" .184206 185207 mkdir -p dist186208 case "${{ matrix.target }}" in@@ -235,7 +257,7 @@ jobs:
235257 236258 release:237259 if: startsWith(github.ref, 'refs/tags/')238- needs: [cli, desktop]260+ needs: [verify, cli, desktop]239261 runs-on: ubuntu-24.04240262 permissions:241263 contents: write@@ -245,7 +267,29 @@ jobs:
245267 with:246268 path: dist247269 merge-multiple: true248- - name: Create the GitHub release270+ - name: Verify the exact asset set and smoke the packaged CLI271+ run: |272+ mapfile -t ASSETS < <(find dist -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) -printf '%f\n' | sort)273+ [[ "${#ASSETS[@]}" -eq 8 ]] || { printf 'wanted 8 release archives, found %s\n' "${#ASSETS[@]}"; printf '%s\n' "${ASSETS[@]}"; exit 1; }274+ [[ "$(printf '%s\n' "${ASSETS[@]}" | sort -u | wc -l)" -eq 8 ]]275+276+ CLI_ARCHIVE="$(find dist -maxdepth 1 -type f -name 'atlas-cli-*-linux-amd64.tar.gz' -print -quit)"277+ [[ -n "${CLI_ARCHIVE}" ]]278+ mkdir -p smoke279+ tar -xzf "${CLI_ARCHIVE}" -C smoke280+ CLI="$(find smoke -type f -name atlas -perm -u+x -print -quit)"281+ [[ -n "${CLI}" ]]282+ "${CLI}" --help >/dev/null283+ "${CLI}" --version | grep -F "${GITHUB_REF_NAME#atlas-}"284+ CACHE="$(mktemp -d)"285+ LIBRARY="$(mktemp -d)"286+ "${CLI}" build -cache "${CACHE}" -bundles "${LIBRARY}" examples/sample-region.atlas-project287+ [[ "$(find "${LIBRARY}" -maxdepth 1 -type f -name '*.atlas' | wc -l)" -eq 1 ]]288+289+ (cd dist && sha256sum "${ASSETS[@]}" > SHA256SUMS)290+ [[ "$(wc -l < dist/SHA256SUMS)" -eq 8 ]]291+292+ - name: Publish a complete release transaction249293 env:250294 GH_TOKEN: ${{ github.token }}251295 run: |@@ -253,6 +297,13 @@ jobs:
253297 VERSION="${GITHUB_REF_NAME#atlas-}"254298 PRERELEASE=""255299 if [[ "${VERSION#v}" == *-* ]]; then PRERELEASE="--prerelease"; fi256- gh release create "${GITHUB_REF_NAME}" dist/* \300+ gh release create "${GITHUB_REF_NAME}" \257301 --title "Atlas ${VERSION}" \258- --generate-notes --verify-tag ${PRERELEASE}302+ --generate-notes --verify-tag --draft ${PRERELEASE}303+ gh release upload "${GITHUB_REF_NAME}" dist/*304+305+ mapfile -t LOCAL < <(find dist -maxdepth 1 -type f -printf '%f\n' | sort)306+ mapfile -t REMOTE < <(gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets[].name' | sort)307+ [[ "${#LOCAL[@]}" -eq 9 && "${#REMOTE[@]}" -eq 9 ]]308+ diff -u <(printf '%s\n' "${LOCAL[@]}") <(printf '%s\n' "${REMOTE[@]}")309+ gh release edit "${GITHUB_REF_NAME}" --draft=false@@ -26,6 +26,9 @@ import (
2626 "strings"2727 )2828 29+// version is replaced from the release tag by the packaging workflow.30+var version = "dev"31+2932 // A command is one subcommand: its name, one line about it, and how to run it.3033 // Run is handed the arguments after the subcommand name and owns its own flag3134 // set, which is what keeps the flags of one lane out of another's help text.@@ -68,6 +71,9 @@ func run(args []string) error {
6871 case "-h", "--help", "help":6972 usage(os.Stdout)7073 return nil74+ case "-v", "--version", "version":75+ fmt.Fprintln(os.Stdout, "atlas "+version)76+ return nil7177 }7278 for _, c := range commands() {7379 if c.name == args[0] {@@ -37,6 +37,9 @@ func TestCommandTable(t *testing.T) {
3737 }3838 3939 func TestRunRefusals(t *testing.T) {40+ if err := run([]string{"--version"}); err != nil {41+ t.Fatalf("version query: %v", err)42+ }4043 tests := []struct {4144 name string4245 args []string@@ -60,6 +60,11 @@ Their defaults are the application-owned cache and library.
6060 adapter version, portable locator, media type, and tile coordinates. The plan6161 reports validated cache presence, estimated bytes, and obligations; identical6262 acquisitions may be shared across projects without merging their semantics.63+- Every plan resolves stable refusal budgets for request bytes, total evidence,64+ request count, raster tile count, and raster pixel count. The manifest may65+ state smaller or deliberately larger positive limits. Planning rejects tile66+ explosions before enumeration; capture rejects at `limit + 1`; offline and67+ exact replay apply the same limits. Atlas never truncates to fit a budget.6368 - Capture envelopes retain request identity, body hash, media type, timestamp,6469 licence, and attribution. Changed bodies append history instead of replacing6570 evidence. Cache reads verify the envelope, body length, and body SHA-256;@@ -78,10 +83,15 @@ Their defaults are the application-owned cache and library.
7883 - Presentation is compiled directly into native styles and layers. There is no7984 legacy JSON document or enrichment projection between authored intent and the8085 packed schema.81-- Publishing stages, syncs, reopens, validates, and atomically renames the82- content-versioned `.atlas`. Rebuilding the same project from the same evidence83- produces the same bytes and returns the existing file.84-- Every build embeds an executable v2 evidence receipt. `-replay` selects those86+- Publishing writes from the already validated file handle, syncs the staged87+ file, commits without replacement, syncs the library directory, and fully88+ validates the installed result. An existing stamp-named file must itself be89+ valid and byte-identical. Rebuilding the same project from the same evidence90+ produces the same bytes and returns the existing file; corrupt or colliding91+ targets fail loudly.92+- Every build embeds an executable v3 evidence receipt. Each selected capture93+ carries the adapter and version, exact hashes and media metadata, and its94+ licence/attribution obligation. `-replay` selects those8595 exact immutable capture hashes even when newer evidence exists. Mapping and8696 presentation changes may deliberately replay the same acquisitions, while a8797 changed locator, query, media type, or adapter version is refused as drift.@@ -98,6 +108,28 @@ do not cancel it. When it completes, **Open in Atlas** performs a native file
98108 handoff; the artifact card also supports dragging the `.atlas` onto an Atlas99109 window.100110 111+The compiled inspector is derived only from the validated `.atlas`, never from112+the manifest. It shows the carried/runtime schema boundary, worlds, coordinate113+space, explicit FeatureSet contracts (including empty sets), paged feature114+facts, geometry, relationships, provenance, raster/presentation semantics,115+assets, and the integrity-protected evidence/legal receipt.116+117+## Production envelope118+119+The current native container is intentionally bounded: a strict entry/reference120+budget, exact ZIP-entry role bijection, Store-only table/blob references,121+bounded bootstrap/schema/table/blob reads, deterministic validation order, and122+streamed opaque-blob hashing. These are refusal boundaries, not claims that the123+current single feature pack and per-tile ZIP layout scale without limit. The124+next storage-generation break for genuinely massive worlds is partitioned125+feature blocks plus deterministic raster shard packs and range/page APIs.126+127+Release publication runs correctness and browser acceptance first, smokes a128+packaged CLI by building Sample Region, generates checksums for the exact asset129+set, uploads to a draft, verifies every remote asset name, and only then makes130+the release public. Both CLI and desktop binaries answer `--version` from the131+release tag.132+101133 The former `crawl`, `tiles`, `compose`, `enrich`, and `translate` commands and102134 the Sources/Operations workbench pages are no longer public interfaces. Their103135 code remains only as migration and corpus-test material while existing fixtures@@ -14,7 +14,9 @@ const (
1414 blockHeaderSize = 801515 directorySize = 641616 columnFlagOptional = uint16(1)17- maxBlockRows = 100_000_00017+ maxBlockRows = 5_000_00018+ maxBlockColumns = 4_09619+ maxBlockValues = 10_000_0001820 )1921 2022 // Block is a decoded typed block. SchemaHash identifies the embedded schema@@ -36,7 +38,7 @@ type encodedColumn struct {
3638 // EncodeBlock writes a deterministic, self-framed hybrid column block.3739 func EncodeBlock(schemaHash [32]byte, table Table) ([]byte, error) {3840 table = canonicalTable(table)39- if table.Rows > maxBlockRows {41+ if table.Rows > maxBlockRows || len(table.Columns) > maxBlockColumns || table.Rows > 0 && len(table.Columns) > maxBlockValues/table.Rows {4042 return nil, fmt.Errorf("table has %d rows; maximum is %d", table.Rows, maxBlockRows)4143 }4244 if err := table.validate(); err != nil {@@ -240,7 +242,8 @@ func readBlockHeader(data []byte) (decodedHeader, error) {
240242 return decodedHeader{}, fmt.Errorf("typed block directory framing is invalid")241243 }242244 header := decodedHeader{rows: int(binary.LittleEndian.Uint32(data[12:16])), columns: columns}243- if header.rows > maxBlockRows || header.columns == 0 && header.rows != 0 {245+ if header.rows > maxBlockRows || header.columns > maxBlockColumns || header.columns == 0 && header.rows != 0 ||246+ header.rows > 0 && header.columns > maxBlockValues/header.rows {244247 return decodedHeader{}, fmt.Errorf("typed block row count is invalid")245248 }246249 copy(header.schemaHash[:], data[20:52])@@ -292,6 +295,24 @@ func checksum(parts ...[]byte) uint32 {
292295 }293296 294297 func decodeValues(kind Kind, rows int, present, payload []byte) ([]Value, error) {298+ width := 0299+ switch kind {300+ case KindBool:301+ width = 1302+ case KindInt64, KindFloat64:303+ width = 8304+ case KindID:305+ width = 16306+ case KindString, KindBytes:307+ if rows == math.MaxInt || len(payload) < (rows+1)*4 {308+ return nil, fmt.Errorf("variable column offsets are truncated")309+ }310+ default:311+ return nil, fmt.Errorf("unsupported wire kind %d", kind)312+ }313+ if width != 0 && (rows > math.MaxInt/width || len(payload) != rows*width) {314+ return nil, fmt.Errorf("fixed-width column has wrong length")315+ }295316 values := make([]Value, rows)296317 switch kind {297318 case KindBool:@@ -2,9 +2,28 @@ package vnext
22 33 import (44 "bytes"5+ "encoding/binary"6+ "math"57 "testing"68 )79 10+func TestPackedBlockRefusesAttackerCountsBeforeAllocation(t *testing.T) {11+ field := Field{ID: CoreID("feature.title"), Name: "title", Kind: KindString}12+ table := Table{TypeID: CoreID("type.feature"), Rows: 1, Columns: []Column{{Field: field, Values: []Value{StringValue("Sample Place")}}}}13+ data, err := EncodeBlock([32]byte{}, table)14+ if err != nil {15+ t.Fatal(err)16+ }17+ binary.LittleEndian.PutUint32(data[12:16], math.MaxUint32)18+ if _, err := DecodeBlock(data); err == nil {19+ t.Fatal("attacker-sized row count was accepted")20+ }21+ geometry := []byte{byte(GeometryPoint), 0xff, 0xff, 0xff, 0xff}22+ if _, err := decodeGeometry(geometry); err == nil {23+ t.Fatal("attacker-sized geometry part count was accepted")24+ }25+}26+827 func TestPackedBlockRoundTripsEveryWireKind(t *testing.T) {928 t.Parallel()1029 @@ -8,6 +8,7 @@ import (
88 "encoding/json"99 "fmt"1010 "io"11+ "math"1112 "os"1213 "slices"1314 "strings"@@ -56,6 +57,50 @@ type preparedEntry struct {
5657 method uint165758 }5859 60+// Limits bounds the native container before any referenced entry is61+// materialized. Zero fields take the stable application defaults; negative62+// fields are invalid. Manifests may describe less, never silently more.63+type Limits struct {64+ MaxEntries int65+ MaxTables int66+ MaxBlobs int67+ MaxTableBytes int6468+ MaxBlobBytes int6469+ MaxTotalBytes int6470+}71+72+func DefaultLimits() Limits {73+ return Limits{74+ MaxEntries: 100_000, MaxTables: 4_096, MaxBlobs: 90_000,75+ MaxTableBytes: 512 << 20, MaxBlobBytes: 512 << 20, MaxTotalBytes: 1 << 40,76+ }77+}78+79+func (limits Limits) resolved() (Limits, error) {80+ defaults := DefaultLimits()81+ values := []*int{&limits.MaxEntries, &limits.MaxTables, &limits.MaxBlobs}82+ defaultInts := []int{defaults.MaxEntries, defaults.MaxTables, defaults.MaxBlobs}83+ for index, value := range values {84+ if *value < 0 {85+ return Limits{}, fmt.Errorf("native container limit is negative")86+ }87+ if *value == 0 {88+ *value = defaultInts[index]89+ }90+ }91+ byteValues := []*int64{&limits.MaxTableBytes, &limits.MaxBlobBytes, &limits.MaxTotalBytes}92+ defaultBytes := []int64{defaults.MaxTableBytes, defaults.MaxBlobBytes, defaults.MaxTotalBytes}93+ for index, value := range byteValues {94+ if *value < 0 {95+ return Limits{}, fmt.Errorf("native container byte limit is negative")96+ }97+ if *value == 0 {98+ *value = defaultBytes[index]99+ }100+ }101+ return limits, nil102+}103+59104 // Write produces one deterministic ZIP container with a small JSON bootstrap,60105 // an embedded canonical schema, stored typed blocks, and stored opaque blobs.61106 func Write(out io.Writer, bundle Bundle) error {@@ -209,16 +254,27 @@ type Reader struct {
209254 entries map[string]*zip.File210255 tables map[string]tableReference211256 blobs map[string]blobReference257+ limits Limits212258 }213259 214260 // Open reads the bootstrap and embedded schema. Only container framing is a215261 // hard gate; schema differences are merged by identity.216262 func Open(source io.ReaderAt, size int64, applicationSchema Schema) (*Reader, error) {263+ return OpenWithLimits(source, size, applicationSchema, DefaultLimits())264+}265+266+// OpenWithLimits opens a native Atlas under an explicit reproducible resource267+// policy. Open is the compatibility facade using DefaultLimits.268+func OpenWithLimits(source io.ReaderAt, size int64, applicationSchema Schema, limits Limits) (*Reader, error) {269+ limits, err := limits.resolved()270+ if err != nil {271+ return nil, err272+ }217273 archive, err := zip.NewReader(source, size)218274 if err != nil {219275 return nil, fmt.Errorf("open Atlas archive: %w", err)220276 }221- entries, err := indexEntries(archive.File)277+ entries, err := indexEntries(archive.File, limits)222278 if err != nil {223279 return nil, err224280 }@@ -237,7 +293,7 @@ func Open(source io.ReaderAt, size int64, applicationSchema Schema) (*Reader, er
237293 reader := &Reader{238294 VolumeID: manifest.Volume, Release: manifest.Release, FileSchema: fileSchema, RuntimeSchema: runtimeSchema,239295 entries: entries, tables: make(map[string]tableReference, len(manifest.Tables)),240- blobs: make(map[string]blobReference, len(manifest.Blobs)),296+ blobs: make(map[string]blobReference, len(manifest.Blobs)), limits: limits,241297 }242298 if err := reader.indexReferences(manifest); err != nil {243299 return nil, err@@ -245,8 +301,12 @@ func Open(source io.ReaderAt, size int64, applicationSchema Schema) (*Reader, er
245301 return reader, nil246302 }247303 248-func indexEntries(files []*zip.File) (map[string]*zip.File, error) {304+func indexEntries(files []*zip.File, limits Limits) (map[string]*zip.File, error) {305+ if len(files) > limits.MaxEntries {306+ return nil, fmt.Errorf("archive carries %d entries, limit is %d", len(files), limits.MaxEntries)307+ }249308 entries := make(map[string]*zip.File, len(files))309+ var total uint64250310 for _, file := range files {251311 if err := validEntryName(file.Name); err != nil {252312 return nil, err@@ -254,6 +314,13 @@ func indexEntries(files []*zip.File) (map[string]*zip.File, error) {
254314 if _, held := entries[file.Name]; held {255315 return nil, fmt.Errorf("archive carries %s twice", file.Name)256316 }317+ if file.UncompressedSize64 > math.MaxInt64 || total > math.MaxUint64-file.UncompressedSize64 {318+ return nil, fmt.Errorf("archive entry sizes overflow")319+ }320+ total += file.UncompressedSize64321+ if total > uint64(limits.MaxTotalBytes) {322+ return nil, fmt.Errorf("archive expands beyond %d bytes", limits.MaxTotalBytes)323+ }257324 entries[file.Name] = file258325 }259326 return entries, nil@@ -265,9 +332,14 @@ func readBootstrap(entries map[string]*zip.File) (bootstrap, error) {
265332 return bootstrap{}, err266333 }267334 var manifest bootstrap268- if err := json.Unmarshal(data, &manifest); err != nil {335+ decoder := json.NewDecoder(bytes.NewReader(data))336+ decoder.DisallowUnknownFields()337+ if err := decoder.Decode(&manifest); err != nil {269338 return bootstrap{}, fmt.Errorf("decode bootstrap: %w", err)270339 }340+ if err := decoder.Decode(&struct{}{}); err != io.EOF {341+ return bootstrap{}, fmt.Errorf("bootstrap carries trailing data")342+ }271343 if manifest.Format != containerFormat || manifest.Framing != containerFraming {272344 return bootstrap{}, fmt.Errorf("unsupported Atlas container identity or framing")273345 }@@ -315,17 +387,50 @@ func readSchema(entries map[string]*zip.File, reference schemaReference) (Schema
315387 }316388 317389 func (reader *Reader) indexReferences(manifest bootstrap) error {390+ if len(manifest.Tables) > reader.limits.MaxTables || len(manifest.Blobs) > reader.limits.MaxBlobs {391+ return fmt.Errorf("native container reference count exceeds policy")392+ }393+ allowed := map[string]bool{manifestName: true, manifest.Schema.Name: true}318394 for _, reference := range manifest.Tables {319- if _, held := reader.tables[reference.Name]; held || reader.entries[reference.Name] == nil {395+ entry := reader.entries[reference.Name]396+ if allowed[reference.Name] || entry == nil {320397 return fmt.Errorf("table reference %s is duplicate or missing", reference.Name)321398 }399+ if err := validateReference(reference.Name, reference.Hash, reference.Length, reader.limits.MaxTableBytes, entry); err != nil {400+ return fmt.Errorf("table reference %s: %w", reference.Name, err)401+ }322402 reader.tables[reference.Name] = reference403+ allowed[reference.Name] = true323404 }324405 for _, reference := range manifest.Blobs {325- if _, held := reader.blobs[reference.Name]; held || reader.entries[reference.Name] == nil {406+ entry := reader.entries[reference.Name]407+ if allowed[reference.Name] || entry == nil {326408 return fmt.Errorf("blob reference %s is duplicate or missing", reference.Name)327409 }410+ if err := validateReference(reference.Name, reference.Hash, reference.Length, reader.limits.MaxBlobBytes, entry); err != nil {411+ return fmt.Errorf("blob reference %s: %w", reference.Name, err)412+ }328413 reader.blobs[reference.Name] = reference414+ allowed[reference.Name] = true415+ }416+ for name := range reader.entries {417+ if !allowed[name] {418+ return fmt.Errorf("archive carries unreferenced entry %s", name)419+ }420+ }421+ return nil422+}423+424+func validateReference(name, hash string, length, maximum int64, entry *zip.File) error {425+ if name == manifestName || length < 0 || length > maximum || int64(entry.UncompressedSize64) != length {426+ return fmt.Errorf("length is invalid or exceeds %d bytes", maximum)427+ }428+ decoded, err := hex.DecodeString(hash)429+ if err != nil || len(decoded) != sha256.Size || hash != strings.ToLower(hash) {430+ return fmt.Errorf("hash is not lowercase SHA-256")431+ }432+ if entry.Method != zip.Store {433+ return fmt.Errorf("referenced packed data is not stored")329434 }330435 return nil331436 }@@ -336,7 +441,7 @@ func (reader *Reader) Table(name string) (Table, error) {
336441 if !held {337442 return Table{}, fmt.Errorf("bundle has no table %s", name)338443 }339- data, err := readReferencedEntry(reader.entries[name], reference.Length, reference.Hash)444+ data, err := readReferencedEntry(reader.entries[name], reference.Length, reference.Hash, reader.limits.MaxTableBytes)340445 if err != nil {341446 return Table{}, err342447 }@@ -380,7 +485,7 @@ func (reader *Reader) TableBlock(name string) ([]byte, error) {
380485 if !held {381486 return nil, fmt.Errorf("bundle has no table %s", name)382487 }383- return readReferencedEntry(reader.entries[name], reference.Length, reference.Hash)488+ return readReferencedEntry(reader.entries[name], reference.Length, reference.Hash, reader.limits.MaxTableBytes)384489 }385490 386491 func tableHasColumn(table Table, fieldID ID) bool {@@ -397,7 +502,7 @@ func (reader *Reader) Blob(name string) ([]byte, error) {
397502 if !held {398503 return nil, fmt.Errorf("bundle has no blob %s", name)399504 }400- return readReferencedEntry(reader.entries[name], reference.Length, reference.Hash)505+ return readReferencedEntry(reader.entries[name], reference.Length, reference.Hash, reader.limits.MaxBlobBytes)401506 }402507 403508 func (reader *Reader) TableNames() []string {@@ -421,13 +526,14 @@ func (reader *Reader) BlobNames() []string {
421526 // Validate reads and verifies every typed table and opaque blob, then restores422527 // the semantic root. Installers call it before and after committing a file.423528 func (reader *Reader) Validate() error {424- for name := range reader.tables {529+ for _, name := range reader.TableNames() {425530 if _, err := reader.Table(name); err != nil {426531 return fmt.Errorf("validate %s: %w", name, err)427532 }428533 }429- for name := range reader.blobs {430- if _, err := reader.Blob(name); err != nil {534+ for _, name := range reader.BlobNames() {535+ reference := reader.blobs[name]536+ if err := validateReferencedEntry(reader.entries[name], reference.Length, reference.Hash, reader.limits.MaxBlobBytes); err != nil {431537 return fmt.Errorf("validate %s: %w", name, err)432538 }433539 }@@ -442,14 +548,14 @@ func readLimitedEntry(entries map[string]*zip.File, name string, maximum int64)
442548 if entry == nil || int64(entry.UncompressedSize64) > maximum {443549 return nil, fmt.Errorf("entry %s is missing or implausibly large", name)444550 }445- return readEntry(entry)551+ return readEntryLimited(entry, maximum)446552 }447553 448-func readReferencedEntry(entry *zip.File, length int64, hash string) ([]byte, error) {449- if entry == nil || int64(entry.UncompressedSize64) != length {554+func readReferencedEntry(entry *zip.File, length int64, hash string, maximum int64) ([]byte, error) {555+ if entry == nil || length < 0 || length > maximum || int64(entry.UncompressedSize64) != length {450556 return nil, fmt.Errorf("referenced entry length does not match")451557 }452- data, err := readEntry(entry)558+ data, err := readEntryLimited(entry, maximum)453559 if err != nil {454560 return nil, err455561 }@@ -460,19 +566,53 @@ func readReferencedEntry(entry *zip.File, length int64, hash string) ([]byte, er
460566 return data, nil461567 }462568 569+func validateReferencedEntry(entry *zip.File, length int64, hash string, maximum int64) error {570+ if entry == nil || length < 0 || length > maximum || int64(entry.UncompressedSize64) != length {571+ return fmt.Errorf("referenced entry length does not match")572+ }573+ source, err := entry.Open()574+ if err != nil {575+ return fmt.Errorf("open %s: %w", entry.Name, err)576+ }577+ defer source.Close()578+ digest := sha256.New()579+ written, err := io.Copy(digest, io.LimitReader(source, plusOne(maximum)))580+ if err != nil {581+ return fmt.Errorf("read %s: %w", entry.Name, err)582+ }583+ if written != length || hex.EncodeToString(digest.Sum(nil)) != hash {584+ return fmt.Errorf("referenced entry length or hash does not match")585+ }586+ return nil587+}588+463589 func readEntry(entry *zip.File) ([]byte, error) {590+ return readEntryLimited(entry, math.MaxInt64-1)591+}592+593+func readEntryLimited(entry *zip.File, maximum int64) ([]byte, error) {464594 source, err := entry.Open()465595 if err != nil {466596 return nil, fmt.Errorf("open %s: %w", entry.Name, err)467597 }468598 defer source.Close()469- data, err := io.ReadAll(source)599+ data, err := io.ReadAll(io.LimitReader(source, plusOne(maximum)))470600 if err != nil {471601 return nil, fmt.Errorf("read %s: %w", entry.Name, err)472602 }603+ if int64(len(data)) > maximum {604+ return nil, fmt.Errorf("entry %s expands beyond %d bytes", entry.Name, maximum)605+ }473606 return data, nil474607 }475608 609+func plusOne(value int64) int64 {610+ if value == math.MaxInt64 {611+ return value612+ }613+ return value + 1614+}615+476616 func validEntryName(name string) error {477617 if name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {478618 return fmt.Errorf("entry name %q is not relative", name)@@ -3,6 +3,8 @@ package vnext
33 import (44 "archive/zip"55 "bytes"6+ "io"7+ "strings"68 "testing"79 )810 @@ -100,6 +102,102 @@ func TestBlockRejectsCorruptColumn(t *testing.T) {
100102 }101103 }102104 105+func TestOpenWithLimitsRejectsImplausibleContainersBeforeReadingPayloads(t *testing.T) {106+ t.Parallel()107+108+ volume := minimalVolume()109+ volume.Assets[0].Data = []byte("sample asset")110+ data := nativeBytes(t, volume)111+ for name, limits := range map[string]Limits{112+ "entries": {MaxEntries: 1},113+ "blob": {MaxBlobBytes: 1},114+ "total": {MaxTotalBytes: 1},115+ } {116+ t.Run(name, func(t *testing.T) {117+ if _, err := OpenWithLimits(bytes.NewReader(data), int64(len(data)), StandardSchema(), limits); err == nil {118+ t.Fatal("container outside its explicit limit was accepted")119+ }120+ })121+ }122+}123+124+func TestOpenRejectsUnreferencedAndCompressedPackedEntries(t *testing.T) {125+ t.Parallel()126+127+ data := nativeBytes(t, minimalVolume())128+ compressed := rewriteAtlas(t, data, func(header *zip.FileHeader) {129+ if strings.HasSuffix(header.Name, ".pack") {130+ header.Method = zip.Deflate131+ }132+ }, false)133+ if _, err := Open(bytes.NewReader(compressed), int64(len(compressed)), StandardSchema()); err == nil || !strings.Contains(err.Error(), "not stored") {134+ t.Fatalf("compressed packed entry = %v", err)135+ }136+ orphaned := rewriteAtlas(t, data, nil, true)137+ if _, err := Open(bytes.NewReader(orphaned), int64(len(orphaned)), StandardSchema()); err == nil || !strings.Contains(err.Error(), "unreferenced") {138+ t.Fatalf("orphan entry = %v", err)139+ }140+}141+142+func nativeBytes(t *testing.T, volume Volume) []byte {143+ t.Helper()144+ bundle, err := Compile(volume)145+ if err != nil {146+ t.Fatal(err)147+ }148+ bundle.Release.CreatedAt = "2026-08-08T12:00:00Z"149+ var data bytes.Buffer150+ if err := Write(&data, bundle); err != nil {151+ t.Fatal(err)152+ }153+ return data.Bytes()154+}155+156+func rewriteAtlas(t *testing.T, source []byte, mutate func(*zip.FileHeader), orphan bool) []byte {157+ t.Helper()158+ archive, err := zip.NewReader(bytes.NewReader(source), int64(len(source)))159+ if err != nil {160+ t.Fatal(err)161+ }162+ var output bytes.Buffer163+ writer := zip.NewWriter(&output)164+ for _, entry := range archive.File {165+ reader, err := entry.Open()166+ if err != nil {167+ t.Fatal(err)168+ }169+ data, err := io.ReadAll(reader)170+ reader.Close()171+ if err != nil {172+ t.Fatal(err)173+ }174+ header := entry.FileHeader175+ if mutate != nil {176+ mutate(&header)177+ }178+ destination, err := writer.CreateHeader(&header)179+ if err != nil {180+ t.Fatal(err)181+ }182+ if _, err := destination.Write(data); err != nil {183+ t.Fatal(err)184+ }185+ }186+ if orphan {187+ destination, err := writer.CreateHeader(&zip.FileHeader{Name: "orphan.bin", Method: zip.Store})188+ if err != nil {189+ t.Fatal(err)190+ }191+ if _, err := destination.Write([]byte("orphan")); err != nil {192+ t.Fatal(err)193+ }194+ }195+ if err := writer.Close(); err != nil {196+ t.Fatal(err)197+ }198+ return output.Bytes()199+}200+103201 func minimalVolume() Volume {104202 return Volume{105203 ID: "minimal", Title: "Minimal",@@ -461,12 +461,19 @@ func decodeGeometry(data []byte) (Geometry, error) {
461461 if err != nil {462462 return Geometry{}, err463463 }464- geometry := Geometry{Kind: GeometryKind(data[0]), Parts: make([]GeometryPart, parts)}464+ kind := GeometryKind(data[0])465+ if kind < GeometryPoint || kind > GeometryPolygon || parts > (len(data)-cursor)/4 {466+ return Geometry{}, fmt.Errorf("geometry part framing is invalid")467+ }468+ geometry := Geometry{Kind: kind, Parts: make([]GeometryPart, parts)}465469 for part := range geometry.Parts {466470 rings, err := readCount()467471 if err != nil {468472 return Geometry{}, err469473 }474+ if rings > (len(data)-cursor)/4 {475+ return Geometry{}, fmt.Errorf("geometry ring framing is invalid")476+ }470477 geometry.Parts[part].Rings = make([][]Position, rings)471478 for ring := range geometry.Parts[part].Rings {472479 positions, err := readCount()@@ -2,6 +2,8 @@ package vnext
22 33 import (44 "bytes"5+ "crypto/sha256"6+ "errors"57 "fmt"68 "io"79 "os"@@ -78,54 +80,79 @@ func Scan(dir string, applicationSchema Schema) ([]Descriptor, []Skipped, error)
7880 }7981 8082 func Install(dir, source string, applicationSchema Schema) (Descriptor, error) {81- opened, err := OpenFile(source, applicationSchema)83+ descriptor, _, err := InstallWithStatus(dir, source, applicationSchema)84+ return descriptor, err85+}86+87+// InstallWithStatus validates and atomically installs the exact opened source.88+// present reports that an identical target was already committed.89+func InstallWithStatus(dir, source string, applicationSchema Schema) (Descriptor, bool, error) {90+ file, err := os.Open(source)8291 if err != nil {83- return Descriptor{}, err92+ return Descriptor{}, false, fmt.Errorf("open %s: %w", filepath.Base(source), err)8493 }85- if err := opened.Validate(); err != nil {86- opened.Close()87- return Descriptor{}, fmt.Errorf("%s: %w", filepath.Base(source), err)94+ defer file.Close()95+ info, err := file.Stat()96+ if err != nil {97+ return Descriptor{}, false, fmt.Errorf("stat %s: %w", filepath.Base(source), err)8898 }89- descriptor := opened.Descriptor()90- if err := opened.Close(); err != nil {91- return Descriptor{}, fmt.Errorf("close %s: %w", filepath.Base(source), err)99+ return installFrom(dir, file, info.Size(), source, applicationSchema)100+}101+102+type installSource interface {103+ io.Reader104+ io.ReaderAt105+ io.Seeker106+}107+108+func installFrom(dir string, source installSource, size int64, sourcePath string, applicationSchema Schema) (Descriptor, bool, error) {109+ opened, err := Open(source, size, applicationSchema)110+ if err != nil {111+ return Descriptor{}, false, err112+ }113+ if err := opened.Validate(); err != nil {114+ return Descriptor{}, false, fmt.Errorf("validate native source: %w", err)92115 }116+ descriptor := DescriptorOf(sourcePath, opened.VolumeID, opened.Release, size)93117 if err := os.MkdirAll(dir, 0o755); err != nil {94- return Descriptor{}, fmt.Errorf("create library: %w", err)118+ return Descriptor{}, false, fmt.Errorf("create library: %w", err)95119 }96120 target := filepath.Join(dir, VersionedFileName(descriptor.Slug, descriptor.release()))97- if sameFile(source, target) {98- return Describe(target, applicationSchema)121+ if sourcePath != "" && sameFile(sourcePath, target) {122+ descriptor.Locator = target123+ return descriptor, true, nil99124 }100125 if _, err := os.Stat(target); err == nil {101- return Describe(target, applicationSchema)126+ return validateExisting(target, source, size, descriptor, applicationSchema)127+ } else if !errors.Is(err, os.ErrNotExist) {128+ return Descriptor{}, false, fmt.Errorf("stat %s: %w", filepath.Base(target), err)102129 }103- if err := copyInto(dir, source, target); err != nil {104- return Descriptor{}, err130+ if _, err := source.Seek(0, io.SeekStart); err != nil {131+ return Descriptor{}, false, fmt.Errorf("rewind validated source: %w", err)105132 }106- return validateInstalled(target, applicationSchema)107-}108-109-func InstallBytes(dir string, source []byte, applicationSchema Schema) (Descriptor, error) {110- opened, err := Open(bytes.NewReader(source), int64(len(source)), applicationSchema)133+ installed, err := stageInto(dir, source, target)111134 if err != nil {112- return Descriptor{}, err135+ return Descriptor{}, false, err113136 }114- if err := opened.Validate(); err != nil {115- return Descriptor{}, err137+ if !installed {138+ return validateExisting(target, source, size, descriptor, applicationSchema)116139 }117- descriptor := DescriptorOf("", opened.VolumeID, opened.Release, int64(len(source)))118- if err := os.MkdirAll(dir, 0o755); err != nil {119- return Descriptor{}, fmt.Errorf("create library: %w", err)120- }121- target := filepath.Join(dir, VersionedFileName(descriptor.Slug, descriptor.release()))122- if _, err := os.Stat(target); err == nil {123- return Describe(target, applicationSchema)140+ if err := syncDirectory(dir); err != nil {141+ os.Remove(target)142+ return Descriptor{}, false, fmt.Errorf("sync Atlas library: %w", err)124143 }125- if err := stageInto(dir, bytes.NewReader(source), target); err != nil {126- return Descriptor{}, err144+ committed, err := validateInstalled(target, applicationSchema)145+ if err != nil {146+ os.Remove(target)147+ _ = syncDirectory(dir)148+ return Descriptor{}, false, err127149 }128- return validateInstalled(target, applicationSchema)150+ return committed, false, nil151+}152+153+func InstallBytes(dir string, source []byte, applicationSchema Schema) (Descriptor, error) {154+ descriptor, _, err := installFrom(dir, bytes.NewReader(source), int64(len(source)), "", applicationSchema)155+ return descriptor, err129156 }130157 131158 func validateInstalled(target string, applicationSchema Schema) (Descriptor, error) {@@ -140,40 +167,77 @@ func validateInstalled(target string, applicationSchema Schema) (Descriptor, err
140167 return installed.Descriptor(), nil141168 }142169 143-func copyInto(dir, source, target string) error {144- from, err := os.Open(source)145- if err != nil {146- return fmt.Errorf("open %s: %w", filepath.Base(source), err)147- }148- defer from.Close()149- return stageInto(dir, from, target)150-}151-152-func stageInto(dir string, source io.Reader, target string) error {170+func stageInto(dir string, source io.Reader, target string) (bool, error) {153171 staged, err := os.CreateTemp(dir, ".installing-*")154172 if err != nil {155- return fmt.Errorf("stage %s: %w", filepath.Base(target), err)173+ return false, fmt.Errorf("stage %s: %w", filepath.Base(target), err)156174 }157175 stagedName := staged.Name()158176 defer os.Remove(stagedName)159177 if _, err := io.Copy(staged, source); err != nil {160178 staged.Close()161- return fmt.Errorf("stage %s: %w", filepath.Base(target), err)179+ return false, fmt.Errorf("stage %s: %w", filepath.Base(target), err)162180 }163181 if err := staged.Sync(); err != nil {164182 staged.Close()165- return fmt.Errorf("sync %s: %w", filepath.Base(target), err)183+ return false, fmt.Errorf("sync %s: %w", filepath.Base(target), err)166184 }167185 if err := staged.Close(); err != nil {168- return fmt.Errorf("close %s: %w", filepath.Base(target), err)186+ return false, fmt.Errorf("close %s: %w", filepath.Base(target), err)169187 }170188 if err := os.Chmod(stagedName, 0o644); err != nil {171- return fmt.Errorf("mode %s: %w", filepath.Base(target), err)189+ return false, fmt.Errorf("mode %s: %w", filepath.Base(target), err)190+ }191+ if err := os.Link(stagedName, target); err != nil {192+ if errors.Is(err, os.ErrExist) {193+ return false, nil194+ }195+ return false, fmt.Errorf("install %s: %w", filepath.Base(target), err)196+ }197+ return true, nil198+}199+200+func validateExisting(target string, source installSource, sourceSize int64, expected Descriptor, applicationSchema Schema) (Descriptor, bool, error) {201+ held, err := validateInstalled(target, applicationSchema)202+ if err != nil {203+ return Descriptor{}, false, fmt.Errorf("existing stamp target is invalid: %w", err)172204 }173- if err := os.Rename(stagedName, target); err != nil {174- return fmt.Errorf("install %s: %w", filepath.Base(target), err)205+ if held.Slug != expected.Slug || held.Stamp != expected.Stamp || held.Size != sourceSize {206+ return Descriptor{}, false, fmt.Errorf("existing %s disagrees with its stamp identity", filepath.Base(target))207+ }208+ same, err := sameContent(source, sourceSize, target)209+ if err != nil {210+ return Descriptor{}, false, err211+ }212+ if !same {213+ return Descriptor{}, false, fmt.Errorf("existing %s differs byte-for-byte for stamp %s", filepath.Base(target), ShortStamp(expected.Stamp))214+ }215+ return held, true, nil216+}217+218+func sameContent(source io.ReadSeeker, sourceSize int64, target string) (bool, error) {219+ if _, err := source.Seek(0, io.SeekStart); err != nil {220+ return false, fmt.Errorf("rewind validated source: %w", err)221+ }222+ left := sha256.New()223+ written, err := io.Copy(left, source)224+ if err != nil {225+ return false, fmt.Errorf("hash validated source: %w", err)226+ }227+ if written != sourceSize {228+ return false, fmt.Errorf("validated source changed length while installing")229+ }230+ right, err := os.Open(target)231+ if err != nil {232+ return false, fmt.Errorf("open existing %s: %w", filepath.Base(target), err)233+ }234+ defer right.Close()235+ rightHash := sha256.New()236+ otherSize, err := io.Copy(rightHash, right)237+ if err != nil {238+ return false, fmt.Errorf("hash existing %s: %w", filepath.Base(target), err)175239 }176- return nil240+ return otherSize == sourceSize && bytes.Equal(left.Sum(nil), rightHash.Sum(nil)), nil177241 }178242 179243 func sameFile(left, right string) bool {@@ -5,6 +5,7 @@ import (
55 "os"66 "path/filepath"77 "strings"8+ "sync"89 "testing"910 )1011 @@ -66,3 +67,114 @@ func TestInstallRefusesCorruptionBeforeLibraryWrite(t *testing.T) {
6667 t.Fatalf("broken install left %d files", len(entries))6768 }6869 }70+71+func TestInstallRefusesAnInvalidOrDifferentExistingStampTarget(t *testing.T) {72+ t.Parallel()73+74+ source, data := nativeInstallFixture(t)75+ for _, test := range []struct {76+ name string77+ mutate func(string) error78+ }{79+ {name: "corrupt", mutate: func(path string) error {80+ contents, err := os.ReadFile(path)81+ if err != nil {82+ return err83+ }84+ contents[len(contents)/2] ^= 0xff85+ return os.WriteFile(path, contents, 0o644)86+ }},87+ {name: "different bytes", mutate: func(path string) error {88+ file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)89+ if err != nil {90+ return err91+ }92+ _, writeErr := file.WriteString("different container bytes")93+ closeErr := file.Close()94+ if writeErr != nil {95+ return writeErr96+ }97+ return closeErr98+ }},99+ } {100+ t.Run(test.name, func(t *testing.T) {101+ library := t.TempDir()102+ installed, present, err := InstallWithStatus(library, source, StandardSchema())103+ if err != nil || present {104+ t.Fatalf("initial install = %+v, present=%v, %v", installed, present, err)105+ }106+ if err := test.mutate(installed.Locator); err != nil {107+ t.Fatal(err)108+ }109+ if _, _, err := InstallWithStatus(library, source, StandardSchema()); err == nil {110+ t.Fatal("changed stamp target was accepted")111+ }112+ if got, err := os.ReadFile(source); err != nil || !bytes.Equal(got, data) {113+ t.Fatal("validated source changed while refusing the target")114+ }115+ })116+ }117+}118+119+func TestConcurrentInstallsCreateOneImmutableTarget(t *testing.T) {120+ t.Parallel()121+122+ source, _ := nativeInstallFixture(t)123+ library := t.TempDir()124+ const workers = 16125+ results := make(chan Descriptor, workers)126+ errors := make(chan error, workers)127+ var group sync.WaitGroup128+ for range workers {129+ group.Add(1)130+ go func() {131+ defer group.Done()132+ descriptor, err := Install(library, source, StandardSchema())133+ if err != nil {134+ errors <- err135+ return136+ }137+ results <- descriptor138+ }()139+ }140+ group.Wait()141+ close(errors)142+ close(results)143+ for err := range errors {144+ t.Errorf("concurrent install: %v", err)145+ }146+ var locator string147+ for result := range results {148+ if locator == "" {149+ locator = result.Locator150+ }151+ if result.Locator != locator {152+ t.Errorf("concurrent target = %s, want %s", result.Locator, locator)153+ }154+ }155+ entries, err := os.ReadDir(library)156+ if err != nil {157+ t.Fatal(err)158+ }159+ if len(entries) != 1 || filepath.Ext(entries[0].Name()) != Extension {160+ t.Fatalf("library entries = %v, want one committed Atlas", entries)161+ }162+}163+164+func nativeInstallFixture(t *testing.T) (string, []byte) {165+ t.Helper()166+ packed, err := Compile(minimalVolume())167+ if err != nil {168+ t.Fatal(err)169+ }170+ packed.Release.CreatedAt = "2026-08-08T12:00:00Z"171+ var data bytes.Buffer172+ if err := Write(&data, packed); err != nil {173+ t.Fatal(err)174+ }175+ source := filepath.Join(t.TempDir(), "sample-region.atlas")176+ if err := os.WriteFile(source, data.Bytes(), 0o644); err != nil {177+ t.Fatal(err)178+ }179+ return source, data.Bytes()180+}@@ -0,0 +1,8 @@
1+//go:build windows2+3+package vnext4+5+// Windows does not expose directory fsync through os.File.Sync. The atomic6+// hard-link commit still prevents replacement; durable directory flushing is7+// delegated to the filesystem on this platform.8+func syncDirectory(string) error { return nil }@@ -39,11 +39,12 @@ import (
3939 // opened yet, which is a state a request can genuinely arrive in: the page is4040 // served over the same asset server that the window is still starting.4141 type Window struct {42- live atomic.Pointer[context.Context]43- mu sync.Mutex44- install func(name string, content io.Reader) error45- refresh func(context.Context)46- pending []string42+ live atomic.Pointer[context.Context]43+ mu sync.Mutex44+ install func(name string, content io.Reader) error45+ refresh func(context.Context)46+ pending []string47+ flushing bool4748 }4849 4950 // Accept gives every native file-open surface the application's one install@@ -81,33 +82,47 @@ func (w *Window) Opened(ctx context.Context) {
8182 8283 func (w *Window) flush() {8384 w.mu.Lock()84- paths := append([]string(nil), w.pending...)85- w.pending = nil86- install := w.install87- refresh := w.refresh88- w.mu.Unlock()89- if install == nil {85+ if w.flushing {86+ w.mu.Unlock()9087 return9188 }89+ w.flushing = true90+ w.mu.Unlock()9291 installed := false93- for _, path := range paths {94- file, err := os.Open(path)95- if err == nil {96- err = install(filepath.Base(path), file)97- closeErr := file.Close()98- if err == nil {99- err = closeErr92+ for {93+ w.mu.Lock()94+ paths := append([]string(nil), w.pending...)95+ w.pending = nil96+ install := w.install97+ refresh := w.refresh98+ if len(paths) == 0 || install == nil {99+ if install == nil {100+ w.pending = append(paths, w.pending...)100101 }102+ w.flushing = false103+ w.mu.Unlock()104+ if installed && refresh != nil {105+ if live := w.live.Load(); live != nil {106+ refresh(*live)107+ }108+ }109+ return101110 }102- if err != nil {103- slog.Warn("native file open refused", logging.Op("install"), logging.Path(path), slog.Any("error", err))104- } else {105- installed = true106- }107- }108- if installed && refresh != nil {109- if live := w.live.Load(); live != nil {110- refresh(*live)111+ w.mu.Unlock()112+ for _, path := range paths {113+ file, err := os.Open(path)114+ if err == nil {115+ err = install(filepath.Base(path), file)116+ closeErr := file.Close()117+ if err == nil {118+ err = closeErr119+ }120+ }121+ if err != nil {122+ slog.Warn("native file open refused", logging.Op("install"), logging.Path(path), slog.Any("error", err))123+ } else {124+ installed = true125+ }111126 }112127 }113128 }@@ -6,6 +6,8 @@ import (
66 "os"77 "path/filepath"88 "reflect"9+ "sync"10+ "sync/atomic"911 "testing"1012 )1113 @@ -19,6 +21,55 @@ func TestAtlasPathsAcceptsOnlyNativeVolumeFiles(t *testing.T) {
1921 }2022 }2123 24+func TestConcurrentNativeFileIntakeIsSerializedAndRefreshesOnce(t *testing.T) {25+ dir := t.TempDir()26+ paths := []string{filepath.Join(dir, "first.atlas"), filepath.Join(dir, "second.atlas")}27+ for _, path := range paths {28+ if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o644); err != nil {29+ t.Fatal(err)30+ }31+ }32+ started := make(chan struct{})33+ release := make(chan struct{})34+ var active, maximum, installed, refreshed atomic.Int3235+ window := Window{36+ install: func(_ string, _ io.Reader) error {37+ current := active.Add(1)38+ for {39+ held := maximum.Load()40+ if current <= held || maximum.CompareAndSwap(held, current) {41+ break42+ }43+ }44+ if installed.Add(1) == 1 {45+ close(started)46+ <-release47+ }48+ active.Add(-1)49+ return nil50+ },51+ refresh: func(context.Context) { refreshed.Add(1) },52+ }53+ ctx := context.Background()54+ window.live.Store(&ctx)55+ var calls sync.WaitGroup56+ calls.Add(2)57+ go func() { defer calls.Done(); window.QueuePaths(paths[:1]) }()58+ <-started59+ queued := make(chan struct{})60+ go func() {61+ defer calls.Done()62+ window.QueuePaths(paths[1:])63+ close(queued)64+ }()65+ <-queued66+ close(release)67+ calls.Wait()68+ if installed.Load() != 2 || maximum.Load() != 1 || refreshed.Load() != 1 {69+ t.Fatalf("installed=%d maximum=%d refreshed=%d", installed.Load(), maximum.Load(), refreshed.Load())70+ }71+}72+2273 func TestNativeFileIntakeRefreshesTheOpenWindow(t *testing.T) {2374 path := filepath.Join(t.TempDir(), "world.atlas")2475 if err := os.WriteFile(path, []byte("native volume"), 0o644); err != nil {@@ -31,6 +31,11 @@ func TestFeatureAdaptersRefuseSilentlyIncompleteResponses(t *testing.T) {
3131 body: `{"type":"FeatureCollection","features":[],"numberReturned":1}`,3232 mention: "count differs",3333 },34+ {35+ adapter: "ogc-api-features",36+ body: `{"type":"FeatureCollection","features":[],"numberMatched":1,"numberReturned":0}`,37+ mention: "incomplete",38+ },3439 }3540 for _, test := range tests {3641 t.Run(test.adapter+" "+test.mention, func(t *testing.T) {@@ -55,6 +55,17 @@ func Build(ctx context.Context, options BuildOptions) (BuildResult, error) {
5555 return result, nil5656 }5757 var captures map[string]Capture58+ var capturedBytes int6459+ accountCapture := func(planned PlannedRequest, capture Capture) error {60+ if capture.Body.Length > planned.MaxBytes {61+ return fmt.Errorf("captured evidence for %s is %d bytes, exceeding request budget %d", planned.Source, capture.Body.Length, planned.MaxBytes)62+ }63+ if capture.Body.Length > plan.Budgets.TotalBytes-capturedBytes {64+ return fmt.Errorf("captured evidence exceeds total byte budget %d", plan.Budgets.TotalBytes)65+ }66+ capturedBytes += capture.Body.Length67+ return nil68+ }5869 if options.ReplayPath != "" {5970 selection, err := loadEvidenceSelection(options.ReplayPath)6071 if err != nil {@@ -66,6 +77,9 @@ func Build(ctx context.Context, options BuildOptions) (BuildResult, error) {
6677 }6778 for index, planned := range plan.Requests {6879 capture := captures[planned.ID]80+ if err := accountCapture(planned, capture); err != nil {81+ return result, err82+ }6983 emit(options, Event{Stage: "capture", Message: planned.Source, Current: index + 1, Total: len(plan.Requests), Bytes: capture.Body.Length, Cached: true})7084 }7185 } else {@@ -83,6 +97,9 @@ func Build(ctx context.Context, options BuildOptions) (BuildResult, error) {
8397 acquisitions[key] = capture8498 }8599 captures[planned.ID] = capture100+ if err := accountCapture(planned, capture); err != nil {101+ return result, err102+ }86103 emit(options, Event{87104 Stage: "capture", Message: planned.Source, Current: index + 1,88105 Total: len(plan.Requests), Bytes: capture.Body.Length, Cached: cached,@@ -272,32 +289,9 @@ func writeBuild(library string, bundle vnext.Bundle) (vnext.Descriptor, string,
272289 if err := file.Close(); err != nil {273290 return vnext.Descriptor{}, "", false, err274291 }275- opened, err := vnext.OpenFile(stage, vnext.StandardSchema())292+ installed, present, err := vnext.InstallWithStatus(library, stage, vnext.StandardSchema())276293 if err != nil {277- return vnext.Descriptor{}, "", false, fmt.Errorf("reopen native build: %w", err)278- }279- if err := opened.Validate(); err != nil {280- opened.Close()281- return vnext.Descriptor{}, "", false, fmt.Errorf("validate native build: %w", err)282- }283- descriptor := opened.Descriptor()284- if err := opened.Close(); err != nil {285- return vnext.Descriptor{}, "", false, err286- }287- target := filepath.Join(library, vnext.VersionedFileName(descriptor.Slug, vnext.Release{288- Title: descriptor.Title, CreatedAt: descriptor.CreatedAt, Revision: descriptor.Revision,289- Stamp: descriptor.Stamp, Worlds: descriptor.Worlds,290- }))291- if _, err := os.Stat(target); err == nil {292- held, err := vnext.Describe(target, vnext.StandardSchema())293- return held, target, true, err294- }295- if err := os.Chmod(stage, 0o644); err != nil {296- return vnext.Descriptor{}, "", false, err297- }298- if err := os.Rename(stage, target); err != nil {299- return vnext.Descriptor{}, "", false, err294+ return vnext.Descriptor{}, "", false, fmt.Errorf("publish native build: %w", err)300295 }301- installed, err := vnext.Describe(target, vnext.StandardSchema())302- return installed, target, false, err296+ return installed, installed.Locator, present, nil303297 }@@ -243,15 +243,27 @@ func (cache *Cache) validateBlob(ref BlobRef) error {
243243 if err != nil {244244 return err245245 }246- data, err := os.ReadFile(path)246+ file, err := os.Open(path)247247 if err != nil {248248 return fmt.Errorf("read captured body %s: %w", ref.SHA256, err)249249 }250- if int64(len(data)) != ref.Length {251- return fmt.Errorf("captured body %s length is %d, want %d", ref.SHA256, len(data), ref.Length)250+ defer file.Close()251+ info, err := file.Stat()252+ if err != nil {253+ return fmt.Errorf("stat captured body %s: %w", ref.SHA256, err)254+ }255+ if !info.Mode().IsRegular() || info.Size() != ref.Length {256+ return fmt.Errorf("captured body %s length is %d, want %d", ref.SHA256, info.Size(), ref.Length)252257 }253- digest := sha256.Sum256(data)254- if actual := hex.EncodeToString(digest[:]); actual != ref.SHA256 {258+ hasher := sha256.New()259+ written, err := io.Copy(hasher, file)260+ if err != nil {261+ return fmt.Errorf("hash captured body %s: %w", ref.SHA256, err)262+ }263+ if written != ref.Length {264+ return fmt.Errorf("captured body %s length is %d, want %d", ref.SHA256, written, ref.Length)265+ }266+ if actual := hex.EncodeToString(hasher.Sum(nil)); actual != ref.SHA256 {255267 return fmt.Errorf("captured body hash is %s, want %s", actual, ref.SHA256)256268 }257269 return nil@@ -284,11 +296,27 @@ func validateSHA256(name, value string) error {
284296 }285297 286298 func (cache *Cache) read(ctx context.Context, request Request) ([]byte, string, error) {299+ if request.MaxBytes <= 0 {300+ request.MaxBytes = defaultBuildBudgets.RequestBytes301+ }287302 if !isRemote(request.Locator) {288- data, err := os.ReadFile(request.Locator)303+ file, err := os.Open(request.Locator)289304 if err != nil {290305 return nil, "", fmt.Errorf("read %s source %s: %w", request.Adapter, request.Source, err)291306 }307+ defer file.Close()308+ if info, err := file.Stat(); err != nil {309+ return nil, "", fmt.Errorf("stat %s source %s: %w", request.Adapter, request.Source, err)310+ } else if info.Size() > request.MaxBytes {311+ return nil, "", fmt.Errorf("read %s source %s: %d bytes exceeds request budget %d", request.Adapter, request.Source, info.Size(), request.MaxBytes)312+ }313+ data, err := io.ReadAll(io.LimitReader(file, request.MaxBytes+1))314+ if err != nil {315+ return nil, "", fmt.Errorf("read %s source %s: %w", request.Adapter, request.Source, err)316+ }317+ if int64(len(data)) > request.MaxBytes {318+ return nil, "", fmt.Errorf("read %s source %s exceeds request budget %d", request.Adapter, request.Source, request.MaxBytes)319+ }292320 return data, request.MediaType, nil293321 }294322 req, err := http.NewRequestWithContext(ctx, http.MethodGet, request.Locator, nil)@@ -306,10 +334,16 @@ func (cache *Cache) read(ctx context.Context, request Request) ([]byte, string,
306334 _, _ = io.Copy(io.Discard, response.Body)307335 return nil, "", fmt.Errorf("fetch %s source %s: %s", request.Adapter, request.Source, response.Status)308336 }309- body, err := io.ReadAll(response.Body)337+ if response.ContentLength > request.MaxBytes {338+ return nil, "", fmt.Errorf("fetch %s source %s: %d bytes exceeds request budget %d", request.Adapter, request.Source, response.ContentLength, request.MaxBytes)339+ }340+ body, err := io.ReadAll(io.LimitReader(response.Body, request.MaxBytes+1))310341 if err != nil {311342 return nil, "", fmt.Errorf("read %s source %s: %w", request.Adapter, request.Source, err)312343 }344+ if int64(len(body)) > request.MaxBytes {345+ return nil, "", fmt.Errorf("read %s source %s exceeds request budget %d", request.Adapter, request.Source, request.MaxBytes)346+ }313347 mediaType := strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0])314348 return body, mediaType, nil315349 }@@ -3,6 +3,8 @@ package authoring
33 import (44 "context"55 "encoding/json"6+ "net/http"7+ "net/http/httptest"68 "os"79 "path/filepath"810 "strings"@@ -12,6 +14,66 @@ import (
1214 "time"1315 )1416 17+func TestCaptureEnforcesRequestBudgetWithoutResidue(t *testing.T) {18+ body := strings.Repeat("x", 9)19+ server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {20+ flusher, _ := rw.(http.Flusher)21+ for _, value := range body {22+ _, _ = rw.Write([]byte{byte(value)})23+ if flusher != nil {24+ flusher.Flush()25+ }26+ }27+ }))28+ defer server.Close()29+ root := t.TempDir()30+ cache := OpenCache(root)31+ request := Request{32+ Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson", Locator: server.URL,33+ IdentityLocator: "https://example.invalid/sample-region.geojson", MediaType: "application/geo+json", MaxBytes: 8,34+ }35+ finalizeRequest(&request)36+ if _, _, err := cache.Acquire(context.Background(), request, false); err == nil || !strings.Contains(err.Error(), "request budget") {37+ t.Fatalf("oversized capture = %v", err)38+ }39+ entries, err := os.ReadDir(root)40+ if err != nil {41+ t.Fatal(err)42+ }43+ if len(entries) != 0 {44+ t.Fatalf("oversized capture left cache residue: %v", entries)45+ }46+ request.MaxBytes = int64(len(body))47+ if _, _, err := cache.Acquire(context.Background(), request, false); err != nil {48+ t.Fatalf("exact-limit capture: %v", err)49+ }50+}51+52+func TestOfflineCaptureNeverContactsTheSource(t *testing.T) {53+ var calls atomic.Int6454+ server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {55+ calls.Add(1)56+ rw.Header().Set("Content-Type", "application/geo+json")57+ _, _ = rw.Write([]byte(`{"type":"FeatureCollection","features":[]}`))58+ }))59+ defer server.Close()60+ cache := OpenCache(t.TempDir())61+ request := Request{62+ Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson", Locator: server.URL,63+ IdentityLocator: "https://example.invalid/sample-region.geojson", MediaType: "application/geo+json", MaxBytes: 1024,64+ }65+ finalizeRequest(&request)66+ if _, _, err := cache.Acquire(context.Background(), request, false); err != nil {67+ t.Fatal(err)68+ }69+ if _, _, err := cache.Acquire(context.Background(), request, true); err != nil {70+ t.Fatal(err)71+ }72+ if got := calls.Load(); got != 1 {73+ t.Fatalf("source calls = %d, want exactly one online call", got)74+ }75+}76+1577 func TestCaptureReusesUnchangedEvidence(t *testing.T) {1678 root := t.TempDir()1779 source := filepath.Join(root, "sample-region.geojson")@@ -1,16 +1,21 @@
11 package authoring22 33 import (4+ "bytes"45 "encoding/json"6+ "errors"57 "fmt"8+ "io"69 "os"710 "path/filepath"811 "sort"12+ "strings"13+ "time"914 1015 "github.com/FelineStateMachine/atlas/format/vnext"1116 )1217 13-const BuildReceiptFormat = "atlas-build-receipt/v2"18+const BuildReceiptFormat = "atlas-build-receipt/v3"1419 1520 // EvidenceSelection is the executable replay lock embedded in every authored1621 // Atlas. It identifies the exact immutable captures selected for a build; it@@ -23,13 +28,17 @@ type EvidenceSelection struct {
2328 }2429 2530 type SelectedCapture struct {26- Request string `json:"request"`27- Acquisition string `json:"acquisition"`28- Source string `json:"source"`29- SHA256 string `json:"sha256"`30- Length int64 `json:"length"`31- MediaType string `json:"mediaType"`32- CapturedAt string `json:"capturedAt"`31+ Request string `json:"request"`32+ Acquisition string `json:"acquisition"`33+ Source string `json:"source"`34+ Adapter string `json:"adapter"`35+ AdapterVersion string `json:"adapterVersion"`36+ SHA256 string `json:"sha256"`37+ Length int64 `json:"length"`38+ MediaType string `json:"mediaType"`39+ CapturedAt string `json:"capturedAt"`40+ License string `json:"license"`41+ Attribution string `json:"attribution"`3342 }3443 3544 func buildReceipt(plan Plan, captures map[string]Capture) ([]byte, string, error) {@@ -44,7 +53,9 @@ func buildReceipt(plan Plan, captures map[string]Capture) ([]byte, string, error
4453 }4554 selection.Captures = append(selection.Captures, SelectedCapture{4655 Request: request.ID, Acquisition: requestCacheKey(request.Request), Source: request.Source, SHA256: capture.Body.SHA256,56+ Adapter: request.Adapter, AdapterVersion: request.AdapterVersion,4757 Length: capture.Body.Length, MediaType: capture.Body.MediaType, CapturedAt: capture.CapturedAt,58+ License: request.License, Attribution: request.Attribution,4859 })4960 if capture.CapturedAt > createdAt {5061 createdAt = capture.CapturedAt@@ -92,19 +103,43 @@ func loadEvidenceSelection(path string) (EvidenceSelection, error) {
92103 }93104 94105 func decodeEvidenceSelection(data []byte) (EvidenceSelection, error) {106+ return ParseEvidenceSelection(data)107+}108+109+// ParseEvidenceSelection strictly validates the executable receipt contract110+// used by replay and the compiled-artifact inspector.111+func ParseEvidenceSelection(data []byte) (EvidenceSelection, error) {95112 var selection EvidenceSelection96- if err := json.Unmarshal(data, &selection); err != nil {113+ decoder := json.NewDecoder(bytes.NewReader(data))114+ decoder.DisallowUnknownFields()115+ if err := decoder.Decode(&selection); err != nil {97116 return EvidenceSelection{}, fmt.Errorf("decode replay receipt: %w", err)98117 }99- if selection.Format != BuildReceiptFormat || selection.Project == "" || len(selection.Captures) == 0 {118+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {119+ if err == nil {120+ return EvidenceSelection{}, fmt.Errorf("replay receipt carries more than one JSON value")121+ }122+ return EvidenceSelection{}, fmt.Errorf("decode replay receipt trailer: %w", err)123+ }124+ if selection.Format != BuildReceiptFormat || selection.Project == "" || len(selection.Captures) == 0 || validateSHA256("project", selection.ProjectDigest) != nil {100125 return EvidenceSelection{}, fmt.Errorf("invalid replay receipt")101126 }102127 seen := make(map[string]bool, len(selection.Captures))128+ previousRequest, previousSource := "", ""103129 for _, capture := range selection.Captures {104- if capture.Request == "" || capture.Acquisition == "" || capture.Source == "" || capture.SHA256 == "" || capture.Length < 0 || capture.MediaType == "" || capture.CapturedAt == "" || seen[capture.Request] {130+ if validateSHA256("request", capture.Request) != nil || validateSHA256("acquisition", capture.Acquisition) != nil || validateSHA256("body", capture.SHA256) != nil ||131+ capture.Source == "" || capture.Adapter == "" || capture.AdapterVersion == "" || capture.Length < 0 || strings.TrimSpace(capture.MediaType) == "" ||132+ strings.TrimSpace(capture.License) == "" || strings.TrimSpace(capture.Attribution) == "" || seen[capture.Request] {105133 return EvidenceSelection{}, fmt.Errorf("invalid replay capture for request %s", capture.Request)106134 }135+ if _, err := time.Parse(time.RFC3339Nano, capture.CapturedAt); err != nil {136+ return EvidenceSelection{}, fmt.Errorf("invalid replay capture time for request %s", capture.Request)137+ }138+ if previousRequest > capture.Request || (previousRequest == capture.Request && previousSource > capture.Source) {139+ return EvidenceSelection{}, fmt.Errorf("replay captures are not canonically ordered")140+ }107141 seen[capture.Request] = true142+ previousRequest, previousSource = capture.Request, capture.Source108143 }109144 return selection, nil110145 }@@ -123,7 +158,8 @@ func replayCaptures(plan Plan, selection EvidenceSelection, cache *Cache) (map[s
123158 captures := make(map[string]Capture, len(plan.Requests))124159 for _, request := range plan.Requests {125160 want, ok := selected[request.ID]126- if !ok || want.Source != request.Source || want.Acquisition != requestCacheKey(request.Request) {161+ if !ok || want.Source != request.Source || want.Acquisition != requestCacheKey(request.Request) || want.Adapter != request.Adapter ||162+ want.AdapterVersion != request.AdapterVersion || want.License != request.License || want.Attribution != request.Attribution {127163 return nil, fmt.Errorf("replay receipt does not select source %s request %s", request.Source, request.ID)128164 }129165 capture, err := cache.Select(want.Acquisition, want.SHA256)@@ -77,15 +77,19 @@ func TestBuildReplaysExactEvidenceFromEarlierAtlas(t *testing.T) {
7777 }7878 7979 func TestReplayAllowsMappingOnlyProjectChangeButRejectsAcquisitionDrift(t *testing.T) {80+ policy := strings.Repeat("d", 64)8081 request := PlannedRequest{Request: Request{8182 ID: strings.Repeat("a", 64), Kind: RequestFeatures, Source: "sample-places",83+ Adapter: "geojson", AdapterVersion: "geojson/v1", License: "Example data", Attribution: "Sample Region contributors",8284 }}83- plan := Plan{Project: "sample-region", ProjectDigest: "new-policy", Requests: []PlannedRequest{request}}85+ plan := Plan{Project: "sample-region", ProjectDigest: policy, Requests: []PlannedRequest{request}}8486 selection := EvidenceSelection{85- Format: BuildReceiptFormat, Project: "sample-region", ProjectDigest: "old-policy",87+ Format: BuildReceiptFormat, Project: "sample-region", ProjectDigest: strings.Repeat("e", 64),8688 Captures: []SelectedCapture{{8789 Request: request.ID, Acquisition: request.ID, Source: request.Source, SHA256: strings.Repeat("b", 64),90+ Adapter: request.Adapter, AdapterVersion: request.AdapterVersion,8891 Length: 1, MediaType: "application/geo+json", CapturedAt: "2026-08-08T12:00:00Z",92+ License: request.License, Attribution: request.Attribution,8993 }},9094 }9195 data, _, err := buildReceipt(plan, map[string]Capture{request.ID: {@@ -96,7 +100,7 @@ func TestReplayAllowsMappingOnlyProjectChangeButRejectsAcquisitionDrift(t *testi
96100 t.Fatal(err)97101 }98102 decoded, err := decodeEvidenceSelection(data)99- if err != nil || decoded.ProjectDigest != "new-policy" {103+ if err != nil || decoded.ProjectDigest != policy {100104 t.Fatalf("current policy receipt = %+v, %v", decoded, err)101105 }102106 selection.Captures[0].Request = strings.Repeat("c", 64)@@ -104,3 +108,60 @@ func TestReplayAllowsMappingOnlyProjectChangeButRejectsAcquisitionDrift(t *testi
104108 t.Fatalf("replay acquisition drift = %v", err)105109 }106110 }111+112+func TestSampleRegionReplayIsByteIdenticalAcrossFreshLibraries(t *testing.T) {113+ projectPath := filepath.Join("..", "..", "examples", "sample-region.atlas-project")114+ cacheDir := filepath.Join(t.TempDir(), "cache")115+ first, err := Build(context.Background(), BuildOptions{116+ ProjectPath: projectPath, CacheDir: cacheDir, LibraryDir: filepath.Join(t.TempDir(), "first-library"),117+ })118+ if err != nil {119+ t.Fatal(err)120+ }121+ second, err := Build(context.Background(), BuildOptions{122+ ProjectPath: projectPath, CacheDir: cacheDir, LibraryDir: filepath.Join(t.TempDir(), "second-library"),123+ ReplayPath: first.Path,124+ })125+ if err != nil {126+ t.Fatal(err)127+ }128+ firstBytes, err := os.ReadFile(first.Path)129+ if err != nil {130+ t.Fatal(err)131+ }132+ secondBytes, err := os.ReadFile(second.Path)133+ if err != nil {134+ t.Fatal(err)135+ }136+ if first.Present || second.Present || filepath.Base(first.Path) != filepath.Base(second.Path) || !bytes.Equal(firstBytes, secondBytes) {137+ t.Fatalf("fresh-library replay differs: first=%s second=%s", first.Path, second.Path)138+ }139+}140+141+func TestBuildRefusesACorruptExistingStampTarget(t *testing.T) {142+ projectPath := filepath.Join("..", "..", "examples", "sample-region.atlas-project")143+ cacheDir := filepath.Join(t.TempDir(), "cache")144+ libraryDir := filepath.Join(t.TempDir(), "library")145+ first, err := Build(context.Background(), BuildOptions{146+ ProjectPath: projectPath, CacheDir: cacheDir, LibraryDir: libraryDir,147+ })148+ if err != nil {149+ t.Fatal(err)150+ }151+ file, err := os.OpenFile(first.Path, os.O_APPEND|os.O_WRONLY, 0)152+ if err != nil {153+ t.Fatal(err)154+ }155+ if _, err := file.WriteString("changed container bytes"); err != nil {156+ file.Close()157+ t.Fatal(err)158+ }159+ if err := file.Close(); err != nil {160+ t.Fatal(err)161+ }162+ if _, err := Build(context.Background(), BuildOptions{163+ ProjectPath: projectPath, CacheDir: cacheDir, LibraryDir: libraryDir, Offline: true,164+ }); err == nil || !strings.Contains(err.Error(), "existing") {165+ t.Fatalf("corrupt stamp target = %v", err)166+ }167+}@@ -36,6 +36,7 @@ type Project struct {
3636 Assets []Asset `yaml:"assets,omitempty"`3737 Presentation Presentation `yaml:"presentation"`3838 Release Release `yaml:"release,omitempty"`39+ Budgets BuildBudgets `yaml:"budgets,omitempty"`3940 4041 baseDir string4142 }@@ -227,6 +228,51 @@ type Release struct {
227228 Revision int `yaml:"revision,omitempty"`228229 }229230 231+// BuildBudgets are reproducible refusal limits. Zero selects the stable232+// compiler default; a manifest can choose a smaller or deliberately larger233+// positive value, but evidence is never silently truncated.234+type BuildBudgets struct {235+ RequestBytes int64 `yaml:"request-bytes,omitempty" json:"requestBytes"`236+ TotalBytes int64 `yaml:"total-bytes,omitempty" json:"totalBytes"`237+ Requests int `yaml:"requests,omitempty" json:"requests"`238+ RasterTiles int64 `yaml:"raster-tiles,omitempty" json:"rasterTiles"`239+ RasterPixels int64 `yaml:"raster-pixels,omitempty" json:"rasterPixels"`240+}241+242+var defaultBuildBudgets = BuildBudgets{243+ RequestBytes: 512 << 20,244+ TotalBytes: 8 << 30,245+ Requests: 100_000,246+ RasterTiles: 90_000,247+ RasterPixels: 16_000_000_000,248+}249+250+func (budgets BuildBudgets) resolved() (BuildBudgets, error) {251+ if budgets.RequestBytes < 0 || budgets.TotalBytes < 0 || budgets.Requests < 0 || budgets.RasterTiles < 0 || budgets.RasterPixels < 0 {252+ return BuildBudgets{}, fmt.Errorf("build budgets must be positive")253+ }254+ resolved := budgets255+ if resolved.RequestBytes == 0 {256+ resolved.RequestBytes = defaultBuildBudgets.RequestBytes257+ }258+ if resolved.TotalBytes == 0 {259+ resolved.TotalBytes = defaultBuildBudgets.TotalBytes260+ }261+ if resolved.Requests == 0 {262+ resolved.Requests = defaultBuildBudgets.Requests263+ }264+ if resolved.RasterTiles == 0 {265+ resolved.RasterTiles = defaultBuildBudgets.RasterTiles266+ }267+ if resolved.RasterPixels == 0 {268+ resolved.RasterPixels = defaultBuildBudgets.RasterPixels269+ }270+ if resolved.RequestBytes > resolved.TotalBytes {271+ return BuildBudgets{}, fmt.Errorf("request byte budget %d exceeds total byte budget %d", resolved.RequestBytes, resolved.TotalBytes)272+ }273+ return resolved, nil274+}275+230276 // LoadProject strictly decodes one manifest. Unknown fields fail so a typo231277 // cannot silently remove a source, mapping, or layer from a build.232278 func LoadProject(path string) (Project, error) {@@ -283,6 +329,9 @@ func (project Project) Validate() error {
283329 if err := validateTarget(project.Target); err != nil {284330 return err285331 }332+ if _, err := project.Budgets.resolved(); err != nil {333+ return fmt.Errorf("project %s: %w", project.ID, err)334+ }286335 if len(project.Sources) == 0 && len(project.Rasters) == 0 {287336 return fmt.Errorf("project %s configures no sources or rasters", project.ID)288337 }@@ -422,6 +471,9 @@ func validateSource(source Source, target CoordinateSpace, sets map[string]Featu
422471 if source.Adapter == "" || source.Locator == "" {423472 return fmt.Errorf("source %s requires an adapter and locator", source.ID)424473 }474+ if strings.TrimSpace(source.License) == "" || strings.TrimSpace(source.Attribution) == "" {475+ return fmt.Errorf("source %s must state a license and attribution", source.ID)476+ }425477 if source.EstimateBytes < 0 {426478 return fmt.Errorf("source %s has a negative size estimate", source.ID)427479 }@@ -497,6 +549,9 @@ func validateRaster(raster Raster) error {
497549 if raster.Name == "" || raster.Adapter == "" || raster.Locator == "" || raster.TileSize <= 0 || raster.EstimateBytes < 0 || raster.MaxZoom < 0 || raster.FullZoom < 0 || raster.Shard < 0 {498550 return fmt.Errorf("raster %s is incomplete", raster.ID)499551 }552+ if strings.TrimSpace(raster.License) == "" || strings.TrimSpace(raster.Attribution) == "" {553+ return fmt.Errorf("raster %s must state a license and attribution", raster.ID)554+ }500555 if raster.Bounds != nil && !validRasterRect(*raster.Bounds) {501556 return fmt.Errorf("raster %s has invalid bounds", raster.ID)502557 }@@ -559,6 +614,9 @@ func validateAsset(asset Asset) error {
559614 if asset.Locator == "" || asset.MediaType == "" || asset.EstimateBytes < 0 {560615 return fmt.Errorf("asset %s is incomplete", asset.ID)561616 }617+ if strings.TrimSpace(asset.License) == "" || strings.TrimSpace(asset.Attribution) == "" {618+ return fmt.Errorf("asset %s must state a license and attribution", asset.ID)619+ }562620 return nil563621 }564622 @@ -154,6 +154,9 @@ func validateAdapterCompleteness(source Source, collection geoJSON) error {
154154 if collection.NumberReturned != nil && *collection.NumberReturned != int64(len(collection.Features)) {155155 return fmt.Errorf("source %s OGC response count differs from its feature payload", source.ID)156156 }157+ if collection.NumberMatched != nil && *collection.NumberMatched > int64(len(collection.Features)) {158+ return fmt.Errorf("source %s OGC response is incomplete: %d matched but only %d returned", source.ID, *collection.NumberMatched, len(collection.Features))159+ }157160 for _, link := range collection.Links {158161 if strings.EqualFold(link.Rel, "next") && strings.TrimSpace(link.Href) != "" {159162 return fmt.Errorf("source %s OGC response has a next page; narrow or partition the configured query", source.ID)@@ -5,6 +5,7 @@ import (
55 "encoding/hex"66 "encoding/json"77 "fmt"8+ "math"89 "net/url"910 "path/filepath"1011 "sort"@@ -38,6 +39,7 @@ type Request struct {
3839 Zoom int64 `json:"zoom,omitempty"`3940 X int64 `json:"x,omitempty"`4041 Y int64 `json:"y,omitempty"`42+ MaxBytes int64 `json:"maxBytes"`4143 }4244 4345 type PlannedRequest struct {@@ -56,6 +58,9 @@ type Plan struct {
5658 Estimated int64 `json:"estimatedBytes"`5759 Cached int `json:"cachedRequests"`5860 Licenses []Obligation `json:"licenses,omitempty"`61+ Budgets BuildBudgets `json:"budgets"`62+ RasterTiles int64 `json:"rasterTiles"`63+ RasterPixels int64 `json:"rasterPixels"`5964 Output string `json:"output"`6065 }6166 @@ -66,13 +71,32 @@ type Obligation struct {
6671 }6772 6873 func PlanProject(project Project, cache *Cache, output string) (Plan, error) {74+ budgets, err := project.Budgets.resolved()75+ if err != nil {76+ return Plan{}, err77+ }6978 digest, err := projectDigest(project)7079 if err != nil {7180 return Plan{}, err7281 }7382 plan := Plan{7483 Project: project.ID, Title: project.Title, ProjectDigest: digest,75- World: project.Target.World, Output: output,84+ World: project.Target.World, Output: output, Budgets: budgets,85+ }86+ appendRequest := func(request PlannedRequest) error {87+ if len(plan.Requests) >= budgets.Requests {88+ return fmt.Errorf("build plans more than %d requests", budgets.Requests)89+ }90+ request.MaxBytes = budgets.RequestBytes91+ plan.Requests = append(plan.Requests, request)92+ if request.EstimateBytes > math.MaxInt64-plan.Estimated {93+ return fmt.Errorf("build byte estimate overflows")94+ }95+ plan.Estimated += request.EstimateBytes96+ if plan.Estimated > budgets.TotalBytes {97+ return fmt.Errorf("build estimate %d exceeds total byte budget %d", plan.Estimated, budgets.TotalBytes)98+ }99+ return nil76100 }77101 for _, source := range project.Sources {78102 request, err := featureRequest(project, source)@@ -81,15 +105,21 @@ func PlanProject(project Project, cache *Cache, output string) (Plan, error) {
81105 }82106 planned := PlannedRequest{Request: request, EstimateBytes: source.EstimateBytes}83107 planned.Cached = cache != nil && cache.Has(requestCacheKey(request))84- plan.Requests = append(plan.Requests, planned)85- plan.Estimated += source.EstimateBytes108+ if err := appendRequest(planned); err != nil {109+ return Plan{}, err110+ }86111 plan.Licenses = append(plan.Licenses, Obligation{Source: source.ID, License: source.License, Attribution: source.Attribution})87112 }88113 for _, raster := range project.Rasters {89- requests, err := rasterRequests(project, raster)114+ requests, tiles, pixels, err := rasterRequests(project, raster, budgets)90115 if err != nil {91116 return Plan{}, err92117 }118+ if tiles > budgets.RasterTiles-plan.RasterTiles || pixels > budgets.RasterPixels-plan.RasterPixels {119+ return Plan{}, fmt.Errorf("raster %s exceeds the remaining raster budget", raster.ID)120+ }121+ plan.RasterTiles += tiles122+ plan.RasterPixels += pixels93123 each := int64(0)94124 if len(requests) > 0 {95125 each = raster.EstimateBytes / int64(len(requests))@@ -101,17 +131,19 @@ func PlanProject(project Project, cache *Cache, output string) (Plan, error) {
101131 }102132 planned := PlannedRequest{Request: request, EstimateBytes: estimate}103133 planned.Cached = cache != nil && cache.Has(requestCacheKey(request))104- plan.Requests = append(plan.Requests, planned)134+ if err := appendRequest(planned); err != nil {135+ return Plan{}, err136+ }105137 }106- plan.Estimated += raster.EstimateBytes107138 plan.Licenses = append(plan.Licenses, Obligation{Source: raster.ID, License: raster.License, Attribution: raster.Attribution})108139 }109140 for _, asset := range project.Assets {110141 request := assetRequest(project, asset)111142 planned := PlannedRequest{Request: request, EstimateBytes: asset.EstimateBytes}112143 planned.Cached = cache != nil && cache.Has(requestCacheKey(request))113- plan.Requests = append(plan.Requests, planned)114- plan.Estimated += asset.EstimateBytes144+ if err := appendRequest(planned); err != nil {145+ return Plan{}, err146+ }115147 plan.Licenses = append(plan.Licenses, Obligation{Source: asset.ID, License: asset.License, Attribution: asset.Attribution})116148 }117149 sort.Slice(plan.Requests, func(i, j int) bool { return plan.Requests[i].ID < plan.Requests[j].ID })@@ -197,7 +229,7 @@ func featureRequest(project Project, source Source) (Request, error) {
197229 return request, nil198230 }199231 200-func rasterRequests(project Project, raster Raster) ([]Request, error) {232+func rasterRequests(project Project, raster Raster, budgets BuildBudgets) ([]Request, int64, int64, error) {201233 makeRequest := func(locator, identity string, zoom, x, y int64) Request {202234 request := Request{203235 Kind: RequestRaster, Source: raster.ID, Adapter: raster.Adapter,@@ -209,7 +241,27 @@ func rasterRequests(project Project, raster Raster) ([]Request, error) {
209241 return request210242 }211243 if raster.Adapter == "raster-file" {212- return []Request{makeRequest(project.ResolveLocator(raster.Locator), raster.Locator, 0, 0, 0)}, nil244+ tiles, err := pyramidTileCount(raster.MaxZoom)245+ if err != nil || tiles > budgets.RasterTiles {246+ return nil, 0, 0, fmt.Errorf("raster %s exceeds tile budget %d", raster.ID, budgets.RasterTiles)247+ }248+ pixels, err := checkedPixels(tiles, raster.TileSize)249+ if err != nil || pixels > budgets.RasterPixels {250+ return nil, 0, 0, fmt.Errorf("raster %s exceeds pixel budget %d", raster.ID, budgets.RasterPixels)251+ }252+ return []Request{makeRequest(project.ResolveLocator(raster.Locator), raster.Locator, 0, 0, 0)}, tiles, pixels, nil253+ }254+ var tileCount int64255+ for _, level := range raster.Levels {256+ width, height := level.MaxX-level.MinX+1, level.MaxY-level.MinY+1257+ if width > math.MaxInt64/height || tileCount > budgets.RasterTiles-width*height {258+ return nil, 0, 0, fmt.Errorf("raster %s exceeds tile budget %d", raster.ID, budgets.RasterTiles)259+ }260+ tileCount += width * height261+ }262+ pixels, err := checkedPixels(tileCount, raster.TileSize)263+ if err != nil || pixels > budgets.RasterPixels {264+ return nil, 0, 0, fmt.Errorf("raster %s exceeds pixel budget %d", raster.ID, budgets.RasterPixels)213265 }214266 var requests []Request215267 for _, level := range raster.Levels {@@ -223,7 +275,33 @@ func rasterRequests(project Project, raster Raster) ([]Request, error) {
223275 }224276 }225277 }226- return requests, nil278+ return requests, tileCount, pixels, nil279+}280+281+func pyramidTileCount(maxZoom int64) (int64, error) {282+ var total int64283+ for zoom := int64(0); zoom <= maxZoom; zoom++ {284+ if zoom >= 32 {285+ return 0, fmt.Errorf("zoom %d overflows tile count", zoom)286+ }287+ level := int64(1) << (2 * zoom)288+ if total > math.MaxInt64-level {289+ return 0, fmt.Errorf("zoom %d overflows tile count", zoom)290+ }291+ total += level292+ }293+ return total, nil294+}295+296+func checkedPixels(tiles, tileSize int64) (int64, error) {297+ if tileSize <= 0 || tileSize > math.MaxInt64/tileSize {298+ return 0, fmt.Errorf("tile size overflows pixel count")299+ }300+ perTile := tileSize * tileSize301+ if tiles > math.MaxInt64/perTile {302+ return 0, fmt.Errorf("raster pixel count overflows")303+ }304+ return tiles * perTile, nil227305 }228306 229307 func requestID(request Request) string {@@ -62,13 +62,13 @@ func TestWMTSPlansAnExplicitPortableWindow(t *testing.T) {
6262 raster := Raster{6363 ID: "background", Name: "Sample Background", Adapter: "wmts",6464 Locator: "https://example.invalid/tiles/{TileMatrix}/{TileRow}/{TileCol}.png",65- MediaType: "image/png", TileSize: 256,65+ MediaType: "image/png", License: "Example data", Attribution: "Sample Region contributors", TileSize: 256,6666 Levels: []RasterLevel{{Zoom: 0, MinX: 0, MinY: 0, MaxX: 0, MaxY: 0}, {Zoom: 1, MinX: 0, MinY: 0, MaxX: 1, MaxY: 1}},6767 }6868 if err := validateRaster(raster); err != nil {6969 t.Fatal(err)7070 }71- requests, err := rasterRequests(project, raster)71+ requests, _, _, err := rasterRequests(project, raster, defaultBuildBudgets)7272 if err != nil {7373 t.Fatal(err)7474 }@@ -81,3 +81,26 @@ func TestWMTSPlansAnExplicitPortableWindow(t *testing.T) {
8181 }8282 }8383 }84+85+func TestRasterPlanRefusesExplosiveWindowBeforeEnumeration(t *testing.T) {86+ project := Project{baseDir: t.TempDir()}87+ raster := Raster{88+ ID: "background", Adapter: "xyz", Locator: "https://example.invalid/{z}/{x}/{y}.png", TileSize: 256,89+ Levels: []RasterLevel{{Zoom: 30, MinX: 0, MinY: 0, MaxX: 1<<30 - 1, MaxY: 1<<30 - 1}},90+ }91+ budgets := defaultBuildBudgets92+ budgets.RasterTiles = 1093+ if _, _, _, err := rasterRequests(project, raster, budgets); err == nil || !strings.Contains(err.Error(), "tile budget") {94+ t.Fatalf("explosive raster plan = %v", err)95+ }96+}97+98+func TestBuildBudgetsResolveStableDefaultsAndRejectInversion(t *testing.T) {99+ resolved, err := (BuildBudgets{}).resolved()100+ if err != nil || resolved != defaultBuildBudgets {101+ t.Fatalf("resolved defaults = %+v, %v", resolved, err)102+ }103+ if _, err := (BuildBudgets{RequestBytes: 2, TotalBytes: 1}).resolved(); err == nil {104+ t.Fatal("request budget larger than total was accepted")105+ }106+}@@ -1,18 +1,14 @@
11 package workbench22 33 import (4- "bytes"54 "crypto/sha256"65 "encoding/base64"76 "encoding/hex"8- "encoding/json"97 "fmt"10- "io"118 "net/http"129 "sort"1310 "strconv"1411 "strings"15- "time"1612 1713 "github.com/FelineStateMachine/atlas/format/semconv"1814 "github.com/FelineStateMachine/atlas/format/vnext"@@ -296,34 +292,10 @@ func countBlobPrefix(names []string, template string) int {
296292 }297293 298294 func inspectEvidenceSelection(data []byte) (authoring.EvidenceSelection, error) {299- var selection authoring.EvidenceSelection300- decoder := json.NewDecoder(bytes.NewReader(data))301- decoder.DisallowUnknownFields()302- if err := decoder.Decode(&selection); err != nil {295+ selection, err := authoring.ParseEvidenceSelection(data)296+ if err != nil {303297 return selection, fmt.Errorf("receipt metadata is invalid: %w", err)304298 }305- if err := decoder.Decode(&struct{}{}); err != io.EOF {306- return selection, fmt.Errorf("receipt metadata has trailing data")307- }308- if selection.Format != authoring.BuildReceiptFormat || selection.Project == "" || !isLowerSHA256(selection.ProjectDigest) || len(selection.Captures) == 0 {309- return selection, fmt.Errorf("receipt metadata does not satisfy %s", authoring.BuildReceiptFormat)310- }311- seen := make(map[string]bool, len(selection.Captures))312- previousRequest, previousSource := "", ""313- for _, capture := range selection.Captures {314- if !isLowerSHA256(capture.Request) || !isLowerSHA256(capture.Acquisition) || !isLowerSHA256(capture.SHA256) ||315- capture.Source == "" || capture.Length < 0 || capture.MediaType == "" || seen[capture.Request] {316- return selection, fmt.Errorf("receipt capture metadata is invalid")317- }318- if _, err := time.Parse(time.RFC3339Nano, capture.CapturedAt); err != nil {319- return selection, fmt.Errorf("receipt capture time is invalid")320- }321- if previousRequest > capture.Request || (previousRequest == capture.Request && previousSource > capture.Source) {322- return selection, fmt.Errorf("receipt captures are not canonically ordered")323- }324- seen[capture.Request] = true325- previousRequest, previousSource = capture.Request, capture.Source326- }327299 return selection, nil328300 }329301 @@ -217,7 +217,9 @@ func TestInspectorReceiptDecoderIsStrictAndContained(t *testing.T) {
217217 Format: authoring.BuildReceiptFormat, Project: "sample-region", ProjectDigest: hashA,218218 Captures: []authoring.SelectedCapture{{219219 Request: hashA, Acquisition: hashB, Source: "sample-source", SHA256: hashA,220+ Adapter: "geojson", AdapterVersion: "geojson/v1",220221 Length: 4, MediaType: "application/geo+json", CapturedAt: "2026-08-08T12:00:00Z",222+ License: "Example data", Attribution: "Sample Region contributors",221223 }},222224 }223225 data, err := json.Marshal(selection)@@ -112,8 +112,8 @@
112112 {{range .Assets}}<p><b>{{.ID}}</b> · {{.MediaType}} · {{.Bytes}} bytes{{if .Provenance}}<br><span class="dim">{{.Provenance}}</span>{{end}}</p>{{end}}113113 {{with .Receipt}}<details open><summary>Executable replay receipt · {{len .Captures}} captures</summary>114114 <p><b>{{.Project}}</b> · project digest <code>{{short .ProjectDigest}}</code></p>115- <table><thead><tr><th>source</th><th>capture</th><th>media</th><th>bytes</th><th>captured</th></tr></thead><tbody>116- {{range .Captures}}<tr><td>{{.Source}}</td><td><code>{{short .SHA256}}</code><br><span class="dim">acq {{short .Acquisition}}</span></td><td>{{.MediaType}}</td><td>{{.Length}}</td><td>{{.CapturedAt}}</td></tr>{{end}}115+ <table><thead><tr><th>source</th><th>adapter</th><th>capture</th><th>obligation</th><th>media / bytes</th><th>captured</th></tr></thead><tbody>116+ {{range .Captures}}<tr><td>{{.Source}}</td><td>{{.Adapter}}<br><span class="dim">{{.AdapterVersion}}</span></td><td><code>{{short .SHA256}}</code><br><span class="dim">acq {{short .Acquisition}}</span></td><td>{{.License}}<br><span class="dim">{{.Attribution}}</span></td><td>{{.MediaType}}<br>{{.Length}} bytes</td><td>{{.CapturedAt}}</td></tr>{{end}}117117 </tbody></table>118118 </details>{{else}}{{if $.ReceiptErr}}<p class="warn-inline">The embedded receipt is integrity-protected, but its metadata is not valid: {{$.ReceiptErr}}</p>{{else}}<p class="dim">No Atlas authoring receipt is embedded.</p>{{end}}{{end}}119119 </section>@@ -87,7 +87,14 @@ const (
8787 dataDirEnv = "ATLAS_DATA_DIR"8888 )8989 90+// version is replaced from the release tag by the packaging workflow.91+var version = "dev"92+9093 func main() {94+ if len(os.Args) == 2 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {95+ fmt.Println("Atlas " + version)96+ return97+ }9198 if err := run(); err != nil {9299 fmt.Fprintln(os.Stderr, "atlas:", err)93100 os.Exit(1)