diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6f09caf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml new file mode 100644 index 0000000..d528d8d --- /dev/null +++ b/.github/workflows/package-release.yml @@ -0,0 +1,144 @@ +name: Package Release +on: + workflow_run: + workflows: [Semantic Release] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: Existing vMAJOR.MINOR.PATCH tag to package or repair + required: true + type: string +permissions: + contents: read +concurrency: + group: editor-package-${{ inputs.tag || github.event.workflow_run.head_sha }} + cancel-in-progress: false +jobs: + prepare: + name: Resolve immutable release tag + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 3 + outputs: + sha: ${{ steps.release.outputs.sha }} + tag: ${{ steps.release.outputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - id: release + name: Resolve tag to exact commit + env: + REQUESTED_TAG: ${{ inputs.tag }} + WORKFLOW_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + tag=${REQUESTED_TAG} + if [[ -z ${tag} ]]; then + tag=$(git tag --points-at "${WORKFLOW_SHA}" --list 'v*' --sort=-version:refname | head -n 1) + fi + if [[ ! ${tag} =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "No exact semantic-release tag was found." >&2 + exit 1 + fi + sha=$(git rev-parse --verify "${tag}^{commit}") + if ! git merge-base --is-ancestor "${sha}" origin/main; then + echo "Release tag is not reachable from the protected main branch." >&2 + exit 1 + fi + if [[ -n ${WORKFLOW_SHA} && ${sha} != "${WORKFLOW_SHA}" ]]; then + echo "Release tag does not point at the completed workflow revision." >&2 + exit 1 + fi + echo "tag=${tag}" >>"${GITHUB_OUTPUT}" + echo "sha=${sha}" >>"${GITHUB_OUTPUT}" + + linux: + name: Linux release package + needs: prepare + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.sha }} + - name: Install Linux native build dependencies + run: tools/install-linux-native-deps.sh + - name: Install pinned toolchain and SBOM tools + run: | + rustup toolchain install 1.97.1 --profile minimal + install -d "${RUNNER_TEMP}/bin" + curl --fail --silent --show-error --location https://github.com/rust-secure-code/cargo-auditable/releases/download/v0.7.5/cargo-auditable-x86_64-unknown-linux-gnu.tar.xz --output "${RUNNER_TEMP}/cargo-auditable.tar.xz" + printf '%s %s\n' 322eda09f24e5dba97371c6c2c6949569c411ddf893852e88135cc5b51d5a719 "${RUNNER_TEMP}/cargo-auditable.tar.xz" | sha256sum --check --strict + tar -xJf "${RUNNER_TEMP}/cargo-auditable.tar.xz" -C "${RUNNER_TEMP}/bin" --strip-components=1 cargo-auditable-x86_64-unknown-linux-gnu/cargo-auditable + curl --fail --silent --show-error --location https://github.com/anchore/syft/releases/download/v1.50.0/syft_1.50.0_linux_amd64.tar.gz --output "${RUNNER_TEMP}/syft.tar.gz" + printf '%s %s\n' bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788 "${RUNNER_TEMP}/syft.tar.gz" | sha256sum --check --strict + tar -xzf "${RUNNER_TEMP}/syft.tar.gz" -C "${RUNNER_TEMP}/bin" syft + echo "${RUNNER_TEMP}/bin" >>"${GITHUB_PATH}" + - name: Build immutable package + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: tools/package-linux.sh dist "${RELEASE_TAG#v}" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-release + path: dist/* + if-no-files-found: error + retention-days: 1 + + windows: + name: Windows release package + needs: prepare + runs-on: windows-2025 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.sha }} + - run: rustup toolchain install 1.97.1 --profile minimal + - name: Install pinned SBOM tools + shell: pwsh + run: | + Invoke-WebRequest https://github.com/anchore/syft/releases/download/v1.50.0/syft_1.50.0_windows_amd64.zip -OutFile "$env:RUNNER_TEMP/syft.zip" + if ((Get-FileHash -Algorithm SHA256 "$env:RUNNER_TEMP/syft.zip").Hash.ToLowerInvariant() -ne "815ee6973ec5dff6a671d7f41b0e78835a8c45b91d5a39f4743ea1cee833d3be") { throw "Syft digest mismatch" } + Expand-Archive "$env:RUNNER_TEMP/syft.zip" "$env:RUNNER_TEMP/syft" + "$env:RUNNER_TEMP/syft" | Out-File -FilePath $env:GITHUB_PATH -Append + Invoke-WebRequest https://github.com/rust-secure-code/cargo-auditable/releases/download/v0.7.5/cargo-auditable-x86_64-pc-windows-msvc.zip -OutFile "$env:RUNNER_TEMP/cargo-auditable.zip" + if ((Get-FileHash -Algorithm SHA256 "$env:RUNNER_TEMP/cargo-auditable.zip").Hash.ToLowerInvariant() -ne "83a7d5955c7ac96ede5d896ac9ede5f7ecce9ece0e95d9e47acd766b09e2ef1b") { throw "cargo-auditable digest mismatch" } + Expand-Archive "$env:RUNNER_TEMP/cargo-auditable.zip" "$env:RUNNER_TEMP/cargo-auditable" + "$env:RUNNER_TEMP/cargo-auditable" | Out-File -FilePath $env:GITHUB_PATH -Append + - name: Build immutable package + shell: pwsh + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: tools/package-windows.ps1 -Output dist -Version $env:RELEASE_TAG.TrimStart('v') + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-release + path: dist/* + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish complete release + needs: [prepare, linux, windows] + runs-on: ubuntu-24.04 + timeout-minutes: 3 + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + - name: Create complete release only after both platforms pass + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: | + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + echo "Refusing to replace an existing release." >&2 + exit 1 + fi + gh release create "${RELEASE_TAG}" dist/* --verify-tag --generate-notes diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..835f679 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,18 @@ +name: Pull Request Policy +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] +permissions: + contents: read +jobs: + conventional-title: + name: Conventional PR title + runs-on: ubuntu-24.04 + timeout-minutes: 2 + steps: + - name: Validate title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + pattern='^[a-z][a-z0-9-]*(\([a-z0-9][a-z0-9._/-]*\))?(!)?: .+' + [[ ${PR_TITLE} =~ ${pattern} ]] || { echo "PR title must use Conventional Commits style" >&2; exit 1; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6c6e30c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Semantic Release +on: + workflow_dispatch: + push: + branches: [main] +permissions: + contents: write + issues: write + pull-requests: write +concurrency: + group: editor-semantic-release + cancel-in-progress: false +jobs: + release: + name: Release editor source + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - env: + GH_TOKEN: ${{ github.token }} + run: >- + npx --yes --package=semantic-release@25.0.9 + --package=conventional-changelog-conventionalcommits@9.3.1 + semantic-release diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..9b68f7e --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,75 @@ +name: Editor validation +on: + pull_request: + branches: [main] + push: + branches: [main] +permissions: + contents: read +concurrency: + group: editor-validation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + linux: + name: Linux validation + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Install Linux native build dependencies + run: tools/install-linux-native-deps.sh + - name: Install pinned Rust quality tools + run: | + rustup toolchain install 1.97.1 --profile minimal --component clippy,rustfmt + install -d "${RUNNER_TEMP}/tools/cargo-deny" "${RUNNER_TEMP}/tools/cargo-auditable" "${RUNNER_TEMP}/bin" + curl --fail --silent --show-error --location https://github.com/EmbarkStudios/cargo-deny/releases/download/0.20.2/cargo-deny-0.20.2-x86_64-unknown-linux-musl.tar.gz --output "${RUNNER_TEMP}/cargo-deny.tar.gz" + printf '%s %s\n' 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f "${RUNNER_TEMP}/cargo-deny.tar.gz" | sha256sum --check --strict + tar -xzf "${RUNNER_TEMP}/cargo-deny.tar.gz" -C "${RUNNER_TEMP}/tools/cargo-deny" --strip-components=1 + install "${RUNNER_TEMP}/tools/cargo-deny/cargo-deny" "${RUNNER_TEMP}/bin/cargo-deny" + curl --fail --silent --show-error --location https://github.com/rust-secure-code/cargo-auditable/releases/download/v0.7.5/cargo-auditable-x86_64-unknown-linux-gnu.tar.xz --output "${RUNNER_TEMP}/cargo-auditable.tar.xz" + printf '%s %s\n' 322eda09f24e5dba97371c6c2c6949569c411ddf893852e88135cc5b51d5a719 "${RUNNER_TEMP}/cargo-auditable.tar.xz" | sha256sum --check --strict + tar -xJf "${RUNNER_TEMP}/cargo-auditable.tar.xz" -C "${RUNNER_TEMP}/tools/cargo-auditable" --strip-components=1 + install "${RUNNER_TEMP}/tools/cargo-auditable/cargo-auditable" "${RUNNER_TEMP}/bin/cargo-auditable" + curl --fail --silent --show-error --location https://github.com/anchore/syft/releases/download/v1.50.0/syft_1.50.0_linux_amd64.tar.gz --output "${RUNNER_TEMP}/syft.tar.gz" + printf '%s %s\n' bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788 "${RUNNER_TEMP}/syft.tar.gz" | sha256sum --check --strict + tar -xzf "${RUNNER_TEMP}/syft.tar.gz" -C "${RUNNER_TEMP}/bin" syft + echo "${RUNNER_TEMP}/bin" >>"${GITHUB_PATH}" + - run: cargo fetch --locked + - run: tools/validate.sh + + windows: + name: Windows build, tests, and package + runs-on: windows-2025 + timeout-minutes: 35 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: rustup toolchain install 1.97.1 --profile minimal --component clippy,rustfmt + - run: cargo test --locked --workspace --all-targets + - name: Install pinned packaging tools + shell: pwsh + run: | + Invoke-WebRequest https://github.com/anchore/syft/releases/download/v1.50.0/syft_1.50.0_windows_amd64.zip -OutFile "$env:RUNNER_TEMP/syft.zip" + if ((Get-FileHash -Algorithm SHA256 "$env:RUNNER_TEMP/syft.zip").Hash.ToLowerInvariant() -ne "815ee6973ec5dff6a671d7f41b0e78835a8c45b91d5a39f4743ea1cee833d3be") { throw "Syft digest mismatch" } + Expand-Archive "$env:RUNNER_TEMP/syft.zip" "$env:RUNNER_TEMP/syft" + "$env:RUNNER_TEMP/syft" | Out-File -FilePath $env:GITHUB_PATH -Append + Invoke-WebRequest https://github.com/rust-secure-code/cargo-auditable/releases/download/v0.7.5/cargo-auditable-x86_64-pc-windows-msvc.zip -OutFile "$env:RUNNER_TEMP/cargo-auditable.zip" + if ((Get-FileHash -Algorithm SHA256 "$env:RUNNER_TEMP/cargo-auditable.zip").Hash.ToLowerInvariant() -ne "83a7d5955c7ac96ede5d896ac9ede5f7ecce9ece0e95d9e47acd766b09e2ef1b") { throw "cargo-auditable digest mismatch" } + Expand-Archive "$env:RUNNER_TEMP/cargo-auditable.zip" "$env:RUNNER_TEMP/cargo-auditable" + "$env:RUNNER_TEMP/cargo-auditable" | Out-File -FilePath $env:GITHUB_PATH -Append + - name: Package dry run + shell: pwsh + run: tools/package-windows.ps1 -Output dist -Version 0.1.0-test.1 + + aggregate: + name: Editor validation + if: always() + needs: [linux, windows] + runs-on: ubuntu-24.04 + timeout-minutes: 2 + steps: + - env: + LINUX_RESULT: ${{ needs.linux.result }} + WINDOWS_RESULT: ${{ needs.windows.result }} + run: test "${LINUX_RESULT}" = success -a "${WINDOWS_RESULT}" = success diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d2b84c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/build/ +/dist/ +/target/ diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..67a7e75 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,8 @@ +{ + "branches": ["main"], + "tagFormat": "v${version}", + "plugins": [ + ["@semantic-release/commit-analyzer", {"preset":"conventionalcommits","releaseRules":[{"breaking":true,"release":"major"},{"type":"feat","release":"minor"},{"type":"*","release":"patch"}]}], + ["@semantic-release/release-notes-generator", {"preset":"conventionalcommits"}] + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..683469a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing + +Use new MIT code and synthetic or separately licensed, manifested fixtures. +Do not consult or copy Gridarta/classic implementation, tests, comments, +configuration, structure, or assets. Behavior observation does not authorize +source reuse; follow `PROVENANCE.md` and the root grant registry exactly. + +Keep authority in the owning released dependency. New editor code must not add +a parser, writer, renderer, server/client/protocol edge, direct filesystem write +from UI code, or manifest-local sibling override. Run `tools/validate.sh` and +use a Conventional Commit message and PR title. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2d8d19a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,346 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atrinik-diagnostics" +version = "0.1.0" +source = "git+https://github.com/atrinik/content-toolkit?rev=b2178d442af5d897a45619c200fec5ceb39fc3cf#b2178d442af5d897a45619c200fec5ceb39fc3cf" + +[[package]] +name = "atrinik-editor" +version = "0.1.0" +dependencies = [ + "atrinik-editor-commands", + "atrinik-editor-document", + "atrinik-editor-preview", + "atrinik-editor-project", + "atrinik-editor-ui", + "sdl3", +] + +[[package]] +name = "atrinik-editor-commands" +version = "0.1.0" +dependencies = [ + "atrinik-source", + "atrinik-transaction", +] + +[[package]] +name = "atrinik-editor-document" +version = "0.1.0" +dependencies = [ + "atrinik-source", +] + +[[package]] +name = "atrinik-editor-preview" +version = "0.1.0" +dependencies = [ + "atrinik-render-api", + "atrinik-render-resources", + "atrinik-render-testkit", + "atrinik-scene", +] + +[[package]] +name = "atrinik-editor-project" +version = "0.1.0" + +[[package]] +name = "atrinik-editor-testkit" +version = "0.1.0" +dependencies = [ + "atrinik-editor-project", +] + +[[package]] +name = "atrinik-editor-ui" +version = "0.1.0" +dependencies = [ + "atrinik-editor-project", +] + +[[package]] +name = "atrinik-render-api" +version = "0.1.0" +source = "git+https://github.com/atrinik/renderer?rev=3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9#3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9" +dependencies = [ + "atrinik-render-resources", + "atrinik-scene", +] + +[[package]] +name = "atrinik-render-resources" +version = "0.1.0" +source = "git+https://github.com/atrinik/renderer?rev=3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9#3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9" +dependencies = [ + "atrinik-scene", + "sha2", +] + +[[package]] +name = "atrinik-render-testkit" +version = "0.1.0" +source = "git+https://github.com/atrinik/renderer?rev=3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9#3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9" +dependencies = [ + "atrinik-render-api", + "atrinik-render-resources", + "atrinik-scene", + "sha2", +] + +[[package]] +name = "atrinik-scene" +version = "0.1.0" +source = "git+https://github.com/atrinik/renderer?rev=3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9#3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9" + +[[package]] +name = "atrinik-source" +version = "0.1.0" +source = "git+https://github.com/atrinik/content-toolkit?rev=b2178d442af5d897a45619c200fec5ceb39fc3cf#b2178d442af5d897a45619c200fec5ceb39fc3cf" +dependencies = [ + "atrinik-diagnostics", + "sha2", +] + +[[package]] +name = "atrinik-transaction" +version = "0.1.0" +source = "git+https://github.com/atrinik/content-toolkit?rev=b2178d442af5d897a45619c200fec5ceb39fc3cf#b2178d442af5d897a45619c200fec5ceb39fc3cf" +dependencies = [ + "atrinik-source", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "rpkg-config" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a2d2f3481209a6b42eec2fbb49063fb4e8d35b57023401495d4fe0f85c817f0" + +[[package]] +name = "sdl3" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25bd22eb1bbc9137e914022b4994ed35591eea0884e9e3e98e6d9895cad6e1d2" +dependencies = [ + "bitflags", + "libc", + "sdl3-image-sys", + "sdl3-mixer-sys", + "sdl3-sys", + "sdl3-ttf-sys", +] + +[[package]] +name = "sdl3-image-src" +version = "3.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe273101c7dab94551183212eee9adef1a7bf274d407f0b7bfe72482960ab25c" + +[[package]] +name = "sdl3-image-sys" +version = "0.6.4+SDL-image-3.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a445f781b39a1c1bc751f5f4612191e0402006e35ad5d02d9193281afad1cf4" +dependencies = [ + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-image-src", + "sdl3-sys", + "vcpkg", +] + +[[package]] +name = "sdl3-mixer-src" +version = "3.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cd815ae87084588c7dbd027c1667b0a5e21a6b5ae7b22ecb230bc21c7063eca" + +[[package]] +name = "sdl3-mixer-sys" +version = "0.6.3+SDL-mixer-3.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b5a157588924cf886bdc3a7c9d0e478cdad35d315740f26af00609a61e7c327" +dependencies = [ + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-mixer-src", + "sdl3-sys", + "vcpkg", +] + +[[package]] +name = "sdl3-src" +version = "3.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75661ac9dbedc58da5ce739e9688be6bf9d5542a282d89e7e29fdb7dcb43936" + +[[package]] +name = "sdl3-sys" +version = "0.6.8+SDL-3.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97e3d18c4994224aec3fb2d3d189f5fc88af0958fb8ed7b569272fde558f033" +dependencies = [ + "cc", + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-src", + "vcpkg", +] + +[[package]] +name = "sdl3-ttf-src" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8deaa09c46d6aa8e8a81a601eb4685b2a57f2ce8a4ea3c59e8b623b526d1125" + +[[package]] +name = "sdl3-ttf-sys" +version = "0.6.1+SDL-ttf-3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137096072109d6c834d4cb30b8a617ded4f150c7766757eddc834108bbcefd2" +dependencies = [ + "cmake", + "pkg-config", + "rpkg-config", + "sdl3-sys", + "sdl3-ttf-src", + "vcpkg", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..deef7a4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] +members = [ + "crates/atrinik-editor-app", + "crates/atrinik-editor-commands", + "crates/atrinik-editor-document", + "crates/atrinik-editor-preview", + "crates/atrinik-editor-project", + "crates/atrinik-editor-testkit", + "crates/atrinik-editor-ui", +] +resolver = "3" + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.97.1" +license = "MIT" +repository = "https://github.com/atrinik/editor" + +[workspace.dependencies] +atrinik-editor-commands = { path = "crates/atrinik-editor-commands", version = "=0.1.0" } +atrinik-editor-document = { path = "crates/atrinik-editor-document", version = "=0.1.0" } +atrinik-editor-preview = { path = "crates/atrinik-editor-preview", version = "=0.1.0" } +atrinik-editor-project = { path = "crates/atrinik-editor-project", version = "=0.1.0" } +atrinik-editor-testkit = { path = "crates/atrinik-editor-testkit", version = "=0.1.0" } +atrinik-editor-ui = { path = "crates/atrinik-editor-ui", version = "=0.1.0" } +atrinik-render-api = { git = "https://github.com/atrinik/renderer", rev = "3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9", version = "=0.1.0" } +atrinik-render-resources = { git = "https://github.com/atrinik/renderer", rev = "3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9", version = "=0.1.0" } +atrinik-render-testkit = { git = "https://github.com/atrinik/renderer", rev = "3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9", version = "=0.1.0" } +atrinik-scene = { git = "https://github.com/atrinik/renderer", rev = "3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9", version = "=0.1.0" } +atrinik-source = { git = "https://github.com/atrinik/content-toolkit", rev = "b2178d442af5d897a45619c200fec5ceb39fc3cf", version = "=0.1.0" } +atrinik-transaction = { git = "https://github.com/atrinik/content-toolkit", rev = "b2178d442af5d897a45619c200fec5ceb39fc3cf", version = "=0.1.0" } +sdl3 = { version = "=0.18.4", default-features = false, features = ["build-from-source-static"] } + +[workspace.lints.rust] +unsafe_code = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" diff --git a/PROVENANCE.md b/PROVENANCE.md new file mode 100644 index 0000000..3c6e7cc --- /dev/null +++ b/PROVENANCE.md @@ -0,0 +1,18 @@ +# Provenance and clean-room policy + +All implementation, tests, documentation, and synthetic fixtures introduced by +this repository are new MIT work. The M1 behavior inventory was independently +derived from user-visible entry points and authored-format metadata at exact +`atrinik/classic` and `atrinik/content@1.x` revisions recorded in its evidence +scope. It copies no Gridarta/classic implementation, tests, comments, JAR, +configuration, source fixture, or asset. + +Historical material may be migrated only after the exhaustive grant registry +in `atrinik/atrinik` and its complete-history, sole-authorship, identity, +separability, third-party, transformation, and recording requirements all pass. +M1 invokes no grant. `provenance/reuse.json` records excluded behavior evidence; +`provenance/assets.json` is an empty fail-closed allowlist. + +Authored project content retains its individual license. Opening, previewing, +editing, validating, or packaging it does not apply this repository's MIT +license. Attribution and unknown licensing fields remain lossless data. diff --git a/README.md b/README.md index 6fcbf19..547d882 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,65 @@ # Atrinik editor -This repository is the fresh, clean-room native Atrinik content editor implemented in Rust. +This is the fresh MIT Rust authoring application for Atrinik. It is independent +of Gridarta, `atrinik/classic`, the connected client, protocol, and server. -New source code in this repository is licensed under the MIT License. It consumes the shared [renderer](https://github.com/atrinik/renderer) and [content toolkit](https://github.com/atrinik/content-toolkit) rather than duplicating either. The classic GPL editor is maintained in the [`editor/` module of atrinik/classic](https://github.com/atrinik/classic/tree/main/editor). +## M1 architecture -The implementation roadmap is tracked by [atrinik/atrinik#168](https://github.com/atrinik/atrinik/issues/168). +The editor pins the immutable v1.0.0 source revisions of +[`atrinik/content-toolkit`](https://github.com/atrinik/content-toolkit) and +[`atrinik/renderer`](https://github.com/atrinik/renderer). Toolkit documents and +transactions remain authoritative; renderer scenes and GPU resources remain +renderer-owned. Wrapper profiles are the only supported local override +mechanism. + +```text +released content toolkit -> document adapter -> semantic commands/history + | | +project/path policy ------------------+------> UI model ----+-> app +released renderer -------> scene/preview adapter ----------+ +``` + +The seven workspace crates own application composition, project/tab and safe +path state, document adaptation, commands/history, UI panels/tools, preview +integration, and deterministic test fakes. None owns a parser, serializer, +filesystem writer, renderer, network service, or server lifecycle. + +## Build and validation + +Rust 1.97.1 and SDL 3.4.14 are pinned. Linux builds need the native SDL headers +installed by `tools/install-linux-native-deps.sh`. The Atrinik devcontainer +release that includes the editor toolchain also provides them. + +```sh +cargo build --locked --workspace +cargo test --locked --workspace --all-targets +cargo run --locked --package atrinik-editor -- version +cargo run --locked --package atrinik-editor -- headless +SDL_VIDEO_DRIVER=dummy cargo run --locked --package atrinik-editor -- window +tools/validate.sh +``` + +`tools/validate.sh` checks formatting, Clippy-as-errors, all tests/docs, +dependency architecture/licenses/advisories, provenance/assets, path/inventory +contracts, the shared-renderer empty viewport, SDL lifecycle, Linux release +dry-run/SBOM/reproducibility, and diff hygiene. GitHub also builds/tests/packages +on Windows and exposes one required aggregate check named `Editor validation`. + +The root wrapper does not yet expose replacement editor build/run commands; +that handoff belongs to `atrinik/atrinik#266`. It must use profile-local Cargo +overrides rather than editing these manifests. The editor never launches a +server; future playtests use wrapper-owned profile/topology/state lifecycle. + +## Safety, parity, and releases + +[ADR 0001](decisions/0001-editor-architecture.md), the +[threat model](docs/THREAT_MODEL.md), and [mutable path table](docs/MUTABLE_PATHS.md) +define authority. The machine-readable +[behavior inventory](migration/behavior-parity.json) assigns every observed +classic authoring workflow to one owner/issue/milestone and requires fixture +evidence before parity claims. + +Every squash merge uses a Conventional Commit PR title and semantic-release. +Linux and Windows packages include checksums, CycloneDX SBOMs, provenance, +license, and third-party notices; no content, server/client, classic code, +Gridarta, recovery data, or mutable project state is bundled. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4bcc30d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,10 @@ +# Security policy + +Report vulnerabilities privately through GitHub's security advisory interface. +Do not include project source, credentials, private assets, or recovery data in +a public issue. + +Supported releases are the latest GitHub release. Path traversal, symlink or +canonical-root escape, stale/external overwrite, partial transaction, project +code execution, dependency/source substitution, unbounded authored input, and +server control from the editor are security defects. See `docs/THREAT_MODEL.md`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..bc2a533 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,13 @@ +# Third-party notices + +The editor source is MIT. Its exact dependency graph is recorded by +`Cargo.lock`, checked by `cargo-deny`, and included in each CycloneDX release +SBOM. + +- Atrinik content-toolkit v1.0.0 crates: MIT. +- Atrinik renderer v1.0.0 crates: MIT. +- `sdl3` Rust bindings: MIT. +- SDL 3.4.14: Zlib License. + +Authored content opened by the editor is not part of this software and retains +its own license and attribution. diff --git a/crates/atrinik-editor-app/Cargo.toml b/crates/atrinik-editor-app/Cargo.toml new file mode 100644 index 0000000..7685f33 --- /dev/null +++ b/crates/atrinik-editor-app/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "atrinik-editor" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +atrinik-editor-commands.workspace = true +atrinik-editor-document.workspace = true +atrinik-editor-preview.workspace = true +atrinik-editor-project.workspace = true +atrinik-editor-ui.workspace = true +sdl3.workspace = true + +[lints] +workspace = true diff --git a/crates/atrinik-editor-app/src/main.rs b/crates/atrinik-editor-app/src/main.rs new file mode 100644 index 0000000..1e29347 --- /dev/null +++ b/crates/atrinik-editor-app/src/main.rs @@ -0,0 +1,71 @@ +// Copyright 2026 The Atrinik Project +// SPDX-License-Identifier: MIT + +#![forbid(unsafe_code)] + +fn main() { + let command = std::env::args() + .nth(1) + .unwrap_or_else(|| "version".to_owned()); + match command.as_str() { + "version" | "--version" => println!( + "atrinik-editor {} toolkit={} renderer={}", + env!("CARGO_PKG_VERSION"), + atrinik_editor_document::TOOLKIT_COMPATIBILITY, + atrinik_editor_preview::RENDERER_COMPATIBILITY + ), + "headless" => { + let mut state = atrinik_editor_project::ProjectState::default(); + let path = atrinik_editor_project::RelativePath::new("maps/empty.map") + .expect("constant path is valid"); + state + .open(path.clone()) + .expect("empty project state is available"); + let document = atrinik_editor_document::DocumentView::open( + "headless:empty", + std::sync::Arc::from(&b"name empty\n"[..]), + ) + .expect("synthetic document is valid"); + let history = atrinik_editor_commands::History::default(); + let mut ui = atrinik_editor_ui::UiState::default(); + ui.select(atrinik_editor_ui::Selection { + document: path, + semantic_id: 1, + }) + .expect("synthetic selection is valid"); + let _scene = + atrinik_editor_preview::empty_scene(1, 1, 1).expect("synthetic viewport is valid"); + println!( + "headless generation={} diagnostics={} history={:?}", + state.generation(), + document.diagnostics_len(), + history.depths() + ); + } + "window" => { + let sdl = sdl3::init().unwrap_or_else(|error| fail(&error)); + let video = sdl.video().unwrap_or_else(|error| fail(&error)); + let window = video + .window("Atrinik editor", 640, 480) + .hidden() + .resizable() + .build() + .unwrap_or_else(|error| fail(&error)); + let (width, height) = window.size_in_pixels(); + if width == 0 || height == 0 { + fail("SDL returned an empty window"); + } + drop(window); + println!("window=created-and-destroyed pixels={width}x{height}"); + } + _ => { + eprintln!("usage: atrinik-editor [version|headless|window]"); + std::process::exit(2); + } + } +} + +fn fail(error: &(impl std::fmt::Display + ?Sized)) -> ! { + eprintln!("atrinik-editor: {error}"); + std::process::exit(1); +} diff --git a/crates/atrinik-editor-commands/Cargo.toml b/crates/atrinik-editor-commands/Cargo.toml new file mode 100644 index 0000000..050dcf0 --- /dev/null +++ b/crates/atrinik-editor-commands/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "atrinik-editor-commands" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +atrinik-source.workspace = true +atrinik-transaction.workspace = true + +[lints] +workspace = true diff --git a/crates/atrinik-editor-commands/src/lib.rs b/crates/atrinik-editor-commands/src/lib.rs new file mode 100644 index 0000000..6f48c4a --- /dev/null +++ b/crates/atrinik-editor-commands/src/lib.rs @@ -0,0 +1,140 @@ +// Copyright 2026 The Atrinik Project +// SPDX-License-Identifier: MIT + +#![forbid(unsafe_code)] + +use atrinik_source::{Document, Revision}; +use atrinik_transaction::Transaction; +use std::{fmt, sync::Arc}; + +pub const MAX_HISTORY: usize = 1_024; + +#[derive(Clone, Debug)] +struct Entry { + before: Arc, + after: Arc, +} + +#[derive(Clone, Debug, Default)] +pub struct History { + undo: Vec, + redo: Vec, +} + +impl History { + pub fn replace_value( + &mut self, + current: Arc, + expected: Revision, + record: usize, + replacement: &[u8], + ) -> Result, Error> { + if current.revision() != expected { + return Err(Error::StaleRevision); + } + if self.undo.len() >= MAX_HISTORY { + return Err(Error::HistoryFull); + } + let mut transaction = Transaction::new(current.clone()); + transaction + .replace_value(record, replacement) + .map_err(Error::Toolkit)?; + let after = Arc::new(transaction.preview().map_err(Error::Toolkit)?); + self.undo.push(Entry { + before: current, + after: after.clone(), + }); + self.redo.clear(); + Ok(after) + } + + pub fn undo(&mut self, current: &Arc) -> Result, Error> { + let entry = self.undo.pop().ok_or(Error::NothingToUndo)?; + if entry.after.revision() != current.revision() { + self.undo.push(entry); + return Err(Error::StaleRevision); + } + let before = entry.before.clone(); + self.redo.push(entry); + Ok(before) + } + + pub fn redo(&mut self, current: &Arc) -> Result, Error> { + let entry = self.redo.pop().ok_or(Error::NothingToRedo)?; + if entry.before.revision() != current.revision() { + self.redo.push(entry); + return Err(Error::StaleRevision); + } + let after = entry.after.clone(); + self.undo.push(entry); + Ok(after) + } + + #[must_use] + pub fn depths(&self) -> (usize, usize) { + (self.undo.len(), self.redo.len()) + } +} + +#[derive(Debug)] +pub enum Error { + StaleRevision, + HistoryFull, + NothingToUndo, + NothingToRedo, + Toolkit(atrinik_source::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "editor command error: {self:?}") + } +} + +impl std::error::Error for Error {} + +#[cfg(test)] +mod tests { + use super::{Error, History}; + use atrinik_source::{Document, Limits, SourceId}; + use std::sync::Arc; + + fn document() -> Arc { + Arc::new( + Document::parse( + SourceId::new("test:history").unwrap(), + Arc::<[u8]>::from(&b"name old\n"[..]), + Limits::default(), + ) + .unwrap(), + ) + } + + #[test] + fn commands_are_preconditioned_and_reversible() { + let original = document(); + let mut history = History::default(); + assert!(matches!( + history.replace_value( + original.clone(), + atrinik_source::Document::parse( + SourceId::new("test:other").unwrap(), + Arc::<[u8]>::from(&b"name other\n"[..]), + Limits::default() + ) + .unwrap() + .revision(), + 0, + b"new" + ), + Err(Error::StaleRevision) + )); + let changed = history + .replace_value(original.clone(), original.revision(), 0, b"new") + .unwrap(); + assert_eq!(changed.source_bytes(), b"name new\n"); + let undone = history.undo(&changed).unwrap(); + assert_eq!(undone.source_bytes(), b"name old\n"); + assert_eq!(history.redo(&undone).unwrap().source_bytes(), b"name new\n"); + } +} diff --git a/crates/atrinik-editor-document/Cargo.toml b/crates/atrinik-editor-document/Cargo.toml new file mode 100644 index 0000000..9b03fa3 --- /dev/null +++ b/crates/atrinik-editor-document/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "atrinik-editor-document" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +atrinik-source.workspace = true + +[lints] +workspace = true diff --git a/crates/atrinik-editor-document/src/lib.rs b/crates/atrinik-editor-document/src/lib.rs new file mode 100644 index 0000000..b5aa47d --- /dev/null +++ b/crates/atrinik-editor-document/src/lib.rs @@ -0,0 +1,71 @@ +// Copyright 2026 The Atrinik Project +// SPDX-License-Identifier: MIT + +#![forbid(unsafe_code)] + +use atrinik_source::{Document, Limits, Revision, SourceId}; +use std::{fmt, sync::Arc}; + +pub const TOOLKIT_COMPATIBILITY: &str = "content-toolkit-v1/0.1.0"; + +#[derive(Clone, Debug)] +pub struct DocumentView { + document: Arc, +} + +impl DocumentView { + pub fn open(source_id: &str, bytes: Arc<[u8]>) -> Result { + let source_id = SourceId::new(source_id).map_err(Error::Toolkit)?; + let document = + Document::parse(source_id, bytes, Limits::default()).map_err(Error::Toolkit)?; + Ok(Self { + document: Arc::new(document), + }) + } + + #[must_use] + pub fn revision(&self) -> Revision { + self.document.revision() + } + + #[must_use] + pub fn document(&self) -> &Arc { + &self.document + } + + #[must_use] + pub fn diagnostics_len(&self) -> usize { + self.document.diagnostics().values().len() + } + + #[must_use] + pub fn unchanged_bytes(&self) -> &[u8] { + self.document.source_bytes() + } +} + +#[derive(Debug)] +pub enum Error { + Toolkit(atrinik_source::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "document adapter error: {self:?}") + } +} + +impl std::error::Error for Error {} + +#[cfg(test)] +mod tests { + use super::DocumentView; + use std::sync::Arc; + + #[test] + fn unchanged_document_remains_byte_identical() { + let bytes: Arc<[u8]> = Arc::from(&b"# retained\r\nname value\r\nunknown custom\r\n"[..]); + let view = DocumentView::open("project:maps/example", bytes.clone()).unwrap(); + assert_eq!(view.unchanged_bytes(), bytes.as_ref()); + } +} diff --git a/crates/atrinik-editor-preview/Cargo.toml b/crates/atrinik-editor-preview/Cargo.toml new file mode 100644 index 0000000..53b5362 --- /dev/null +++ b/crates/atrinik-editor-preview/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "atrinik-editor-preview" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +atrinik-render-api.workspace = true +atrinik-render-resources.workspace = true +atrinik-scene.workspace = true + +[dev-dependencies] +atrinik-render-testkit.workspace = true + +[lints] +workspace = true diff --git a/crates/atrinik-editor-preview/src/lib.rs b/crates/atrinik-editor-preview/src/lib.rs new file mode 100644 index 0000000..cf2d5f1 --- /dev/null +++ b/crates/atrinik-editor-preview/src/lib.rs @@ -0,0 +1,61 @@ +// Copyright 2026 The Atrinik Project +// SPDX-License-Identifier: MIT + +#![forbid(unsafe_code)] + +use atrinik_render_api::{Error as RenderError, FrameOutput, RenderRequest, Renderer}; +use atrinik_render_resources::ResourceProvider; +use atrinik_scene::{SceneBundle, SceneLimits, Viewport}; +use std::sync::Arc; + +pub const RENDERER_COMPATIBILITY: &str = "scene-bundle-v1/0.1.0"; + +pub fn empty_scene( + width: u32, + height: u32, + revision: u64, +) -> Result { + SceneBundle::new( + revision, + 0, + Viewport { + width, + height, + scale_milli: 1_000, + }, + [0.04, 0.05, 0.07, 1.0], + [], + SceneLimits::default(), + ) +} + +pub fn render_scene( + renderer: &mut R, + scene: &SceneBundle, + resources: Arc, +) -> Result { + renderer.render(RenderRequest { scene, resources }) +} + +#[cfg(test)] +mod tests { + use super::{empty_scene, render_scene}; + use atrinik_render_api::{BackendPreference, TargetDescriptor, TargetKind}; + use atrinik_render_testkit::{ReferenceRenderer, synthetic_provider}; + + #[test] + fn released_shared_renderer_draws_empty_viewport() { + let scene = empty_scene(32, 24, 1).unwrap(); + let mut renderer = ReferenceRenderer::new(TargetDescriptor { + kind: TargetKind::Offscreen, + width: 32, + height: 24, + backend: BackendPreference::Automatic, + }) + .unwrap(); + let frame = render_scene(&mut renderer, &scene, synthetic_provider().unwrap()).unwrap(); + assert_eq!((frame.width, frame.height), (32, 24)); + assert!(frame.semantic_ids.iter().all(|identity| *identity == 0)); + assert!(frame.coverage.iter().all(|coverage| *coverage == 0)); + } +} diff --git a/crates/atrinik-editor-project/Cargo.toml b/crates/atrinik-editor-project/Cargo.toml new file mode 100644 index 0000000..914e3da --- /dev/null +++ b/crates/atrinik-editor-project/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "atrinik-editor-project" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true diff --git a/crates/atrinik-editor-project/src/lib.rs b/crates/atrinik-editor-project/src/lib.rs new file mode 100644 index 0000000..4723afa --- /dev/null +++ b/crates/atrinik-editor-project/src/lib.rs @@ -0,0 +1,341 @@ +// Copyright 2026 The Atrinik Project +// SPDX-License-Identifier: MIT + +#![forbid(unsafe_code)] + +use std::{collections::BTreeSet, fmt}; + +pub const MAX_PATH_BYTES: usize = 1_024; +pub const MAX_OPEN_DOCUMENTS: usize = 256; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RelativePath(String); + +impl RelativePath { + pub fn new(value: impl AsRef) -> Result { + let value = value.as_ref(); + if value.is_empty() + || value.len() > MAX_PATH_BYTES + || value.contains([':', '\\']) + || value.chars().any(char::is_control) + || value.starts_with('/') + || value.starts_with("//") + || value.split('/').any(|part| !portable_segment(part)) + { + return Err(Error::InvalidPath); + } + Ok(Self(value.to_owned())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn portable_segment(part: &str) -> bool { + if part.is_empty() || matches!(part, "." | "..") || part.ends_with(['.', ' ']) { + return false; + } + let stem = part + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + !matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + && !(stem.len() == 4 + && (stem.starts_with("COM") || stem.starts_with("LPT")) + && matches!(stem.as_bytes()[3], b'1'..=b'9')) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FileKind { + Regular, + Directory, + Symlink, + Special, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileStamp { + pub canonical: String, + pub identity: u128, + pub revision: [u8; 32], + pub kind: FileKind, +} + +pub trait PathProbe { + fn inspect(&self, relative: &RelativePath) -> Result; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InspectedWrite { + relative: RelativePath, + stamp: FileStamp, +} + +impl InspectedWrite { + #[must_use] + pub fn relative(&self) -> &RelativePath { + &self.relative + } + + #[must_use] + pub fn stamp(&self) -> &FileStamp { + &self.stamp + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PathPolicy { + canonical_root: String, + writable_prefixes: Vec, + denied_prefixes: Vec, +} + +impl PathPolicy { + pub fn new( + canonical_root: impl Into, + writable_prefixes: Vec, + denied_prefixes: Vec, + ) -> Result { + let canonical_root = canonical_root.into(); + if canonical_root.is_empty() + || canonical_root.len() > MAX_PATH_BYTES + || writable_prefixes.is_empty() + { + return Err(Error::InvalidPolicy); + } + Ok(Self { + canonical_root, + writable_prefixes, + denied_prefixes, + }) + } + + pub fn inspect( + &self, + probe: &P, + relative: RelativePath, + ) -> Result { + self.check_relative(&relative)?; + let stamp = probe.inspect(&relative)?; + self.check_stamp(&relative, &stamp)?; + Ok(InspectedWrite { relative, stamp }) + } + + pub fn revalidate( + &self, + probe: &P, + inspected: &InspectedWrite, + ) -> Result { + self.check_relative(&inspected.relative)?; + let current = probe.inspect(&inspected.relative)?; + self.check_stamp(&inspected.relative, ¤t)?; + if current != inspected.stamp { + return Err(Error::ExternalChange); + } + Ok(current) + } + + fn check_relative(&self, relative: &RelativePath) -> Result<(), Error> { + let path = relative.as_str(); + if self + .denied_prefixes + .iter() + .any(|prefix| within(path, prefix.as_str())) + || !self + .writable_prefixes + .iter() + .any(|prefix| within(path, prefix.as_str())) + { + return Err(Error::WriteDenied); + } + Ok(()) + } + + fn check_stamp(&self, relative: &RelativePath, stamp: &FileStamp) -> Result<(), Error> { + if stamp.kind != FileKind::Regular { + return Err(match stamp.kind { + FileKind::Symlink => Error::SymlinkEscape, + _ => Error::UnsupportedFile, + }); + } + let expected = format!( + "{}/{}", + self.canonical_root.trim_end_matches('/'), + relative.as_str() + ); + if stamp.canonical != expected { + return Err(Error::CanonicalEscape); + } + Ok(()) + } +} + +fn within(path: &str, prefix: &str) -> bool { + path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectState { + open: BTreeSet, + active: Option, + generation: u64, +} + +impl Default for ProjectState { + fn default() -> Self { + Self { + open: BTreeSet::new(), + active: None, + generation: 1, + } + } +} + +impl ProjectState { + pub fn open(&mut self, path: RelativePath) -> Result<(), Error> { + if !self.open.contains(&path) && self.open.len() >= MAX_OPEN_DOCUMENTS { + return Err(Error::LimitExceeded); + } + self.open.insert(path.clone()); + self.active = Some(path); + self.bump()?; + Ok(()) + } + + pub fn close(&mut self, path: &RelativePath) -> Result<(), Error> { + if !self.open.remove(path) { + return Err(Error::NotOpen); + } + if self.active.as_ref() == Some(path) { + self.active = self.open.iter().next_back().cloned(); + } + self.bump()?; + Ok(()) + } + + #[must_use] + pub fn active(&self) -> Option<&RelativePath> { + self.active.as_ref() + } + + #[must_use] + pub const fn generation(&self) -> u64 { + self.generation + } + + fn bump(&mut self) -> Result<(), Error> { + self.generation = self.generation.checked_add(1).ok_or(Error::LimitExceeded)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Error { + InvalidPath, + InvalidPolicy, + WriteDenied, + CanonicalEscape, + SymlinkEscape, + UnsupportedFile, + ExternalChange, + LimitExceeded, + NotOpen, + Probe(String), +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "editor project error: {self:?}") + } +} + +impl std::error::Error for Error {} + +#[cfg(test)] +mod tests { + use super::{Error, FileKind, FileStamp, PathPolicy, PathProbe, ProjectState, RelativePath}; + + struct Probe(FileStamp); + impl PathProbe for Probe { + fn inspect(&self, _: &RelativePath) -> Result { + Ok(self.0.clone()) + } + } + + #[test] + fn rejects_path_attacks_and_denied_outputs() { + for path in [ + "", + "../map", + "/map", + "C:/map", + "maps//x", + "maps/./x", + "maps\\x", + "maps/file:stream", + "maps/NUL.txt", + "maps/com1", + "maps/trailing.", + "maps/trailing ", + "maps/control\nname", + ] { + assert_eq!(RelativePath::new(path), Err(Error::InvalidPath)); + } + let policy = PathPolicy::new( + "/project", + vec![RelativePath::new("maps").unwrap()], + vec![RelativePath::new("maps/generated").unwrap()], + ) + .unwrap(); + assert_eq!( + policy.inspect( + &Probe(FileStamp { + canonical: "/project/maps/generated/x".into(), + identity: 1, + revision: [1; 32], + kind: FileKind::Regular + }), + RelativePath::new("maps/generated/x").unwrap(), + ), + Err(Error::WriteDenied) + ); + } + + #[test] + fn revalidation_detects_identity_and_revision_changes() { + let path = RelativePath::new("maps/a").unwrap(); + let first = FileStamp { + canonical: "/project/maps/a".into(), + identity: 7, + revision: [1; 32], + kind: FileKind::Regular, + }; + let policy = + PathPolicy::new("/project", vec![RelativePath::new("maps").unwrap()], vec![]).unwrap(); + let inspected = policy.inspect(&Probe(first.clone()), path).unwrap(); + let mut changed = first; + changed.revision = [2; 32]; + assert_eq!( + policy.revalidate(&Probe(changed), &inspected), + Err(Error::ExternalChange) + ); + } + + #[test] + fn tab_state_is_bounded_and_deterministic() { + let mut state = ProjectState::default(); + let a = RelativePath::new("maps/a").unwrap(); + let b = RelativePath::new("maps/b").unwrap(); + state.open(a.clone()).unwrap(); + state.open(b.clone()).unwrap(); + state.close(&b).unwrap(); + assert_eq!(state.active(), Some(&a)); + assert_eq!(state.generation(), 4); + } +} diff --git a/crates/atrinik-editor-testkit/Cargo.toml b/crates/atrinik-editor-testkit/Cargo.toml new file mode 100644 index 0000000..700c830 --- /dev/null +++ b/crates/atrinik-editor-testkit/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "atrinik-editor-testkit" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +atrinik-editor-project.workspace = true + +[lints] +workspace = true diff --git a/crates/atrinik-editor-testkit/src/lib.rs b/crates/atrinik-editor-testkit/src/lib.rs new file mode 100644 index 0000000..182b361 --- /dev/null +++ b/crates/atrinik-editor-testkit/src/lib.rs @@ -0,0 +1,42 @@ +// Copyright 2026 The Atrinik Project +// SPDX-License-Identifier: MIT + +#![forbid(unsafe_code)] + +use atrinik_editor_project::{Error, FileKind, FileStamp, PathProbe, RelativePath}; +use std::{collections::BTreeMap, sync::RwLock}; + +#[derive(Default)] +pub struct MemoryPathProbe { + files: RwLock>, +} + +impl MemoryPathProbe { + pub fn insert(&self, path: RelativePath, stamp: FileStamp) -> Result<(), Error> { + self.files + .write() + .map_err(|_| Error::Probe("poisoned fake".into()))? + .insert(path, stamp); + Ok(()) + } + + pub fn regular(root: &str, relative: &RelativePath, identity: u128, revision: u8) -> FileStamp { + FileStamp { + canonical: format!("{}/{}", root.trim_end_matches('/'), relative.as_str()), + identity, + revision: [revision; 32], + kind: FileKind::Regular, + } + } +} + +impl PathProbe for MemoryPathProbe { + fn inspect(&self, relative: &RelativePath) -> Result { + self.files + .read() + .map_err(|_| Error::Probe("poisoned fake".into()))? + .get(relative) + .cloned() + .ok_or_else(|| Error::Probe("missing fake path".into())) + } +} diff --git a/crates/atrinik-editor-ui/Cargo.toml b/crates/atrinik-editor-ui/Cargo.toml new file mode 100644 index 0000000..48e153f --- /dev/null +++ b/crates/atrinik-editor-ui/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "atrinik-editor-ui" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +atrinik-editor-project.workspace = true + +[lints] +workspace = true diff --git a/crates/atrinik-editor-ui/src/lib.rs b/crates/atrinik-editor-ui/src/lib.rs new file mode 100644 index 0000000..10bdacf --- /dev/null +++ b/crates/atrinik-editor-ui/src/lib.rs @@ -0,0 +1,137 @@ +// Copyright 2026 The Atrinik Project +// SPDX-License-Identifier: MIT + +#![forbid(unsafe_code)] + +use atrinik_editor_project::RelativePath; +use std::{collections::BTreeMap, fmt}; + +pub const MAX_PANEL_DIAGNOSTICS: usize = 256; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Panel { + Project, + Catalog, + Inspector, + Diagnostics, + History, + Preview, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Selection { + pub document: RelativePath, + pub semantic_id: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DiagnosticLink { + pub document: RelativePath, + pub start: usize, + pub end: usize, + pub code: String, +} + +#[derive(Clone, Debug)] +pub struct UiState { + panels: BTreeMap, + selection: Option, + diagnostics: Vec, +} + +impl Default for UiState { + fn default() -> Self { + let panels = [ + Panel::Project, + Panel::Catalog, + Panel::Inspector, + Panel::Diagnostics, + Panel::History, + Panel::Preview, + ] + .into_iter() + .map(|panel| (panel, true)) + .collect(); + Self { + panels, + selection: None, + diagnostics: Vec::new(), + } + } +} + +impl UiState { + pub fn set_panel_visible(&mut self, panel: Panel, visible: bool) { + self.panels.insert(panel, visible); + } + + pub fn select(&mut self, selection: Selection) -> Result<(), Error> { + if selection.semantic_id == 0 { + return Err(Error::InvalidSelection); + } + self.selection = Some(selection); + Ok(()) + } + + pub fn replace_diagnostics(&mut self, diagnostics: Vec) -> Result<(), Error> { + if diagnostics.len() > MAX_PANEL_DIAGNOSTICS + || diagnostics.iter().any(|diagnostic| { + diagnostic.code.is_empty() + || diagnostic.code.len() > 128 + || diagnostic.start > diagnostic.end + }) + { + return Err(Error::InvalidDiagnostics); + } + self.diagnostics = diagnostics; + Ok(()) + } + + #[must_use] + pub fn selection(&self) -> Option<&Selection> { + self.selection.as_ref() + } + + #[must_use] + pub fn diagnostics(&self) -> &[DiagnosticLink] { + &self.diagnostics + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Error { + InvalidSelection, + InvalidDiagnostics, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "editor UI model error: {self:?}") + } +} + +impl std::error::Error for Error {} + +#[cfg(test)] +mod tests { + use super::{Error, Selection, UiState}; + use atrinik_editor_project::RelativePath; + + #[test] + fn semantic_selection_never_depends_on_pixels() { + let mut ui = UiState::default(); + assert_eq!( + ui.select(Selection { + document: RelativePath::new("maps/a").unwrap(), + semantic_id: 0 + }), + Err(Error::InvalidSelection) + ); + ui.select(Selection { + document: RelativePath::new("maps/a").unwrap(), + semantic_id: 42, + }) + .unwrap(); + assert_eq!(ui.selection().unwrap().semantic_id, 42); + } +} diff --git a/decisions/0001-editor-architecture.md b/decisions/0001-editor-architecture.md new file mode 100644 index 0000000..3bfeaa6 --- /dev/null +++ b/decisions/0001-editor-architecture.md @@ -0,0 +1,29 @@ +# ADR 0001: editor authority and dependency direction + +Status: accepted for M1. + +The editor is an offline authoring consumer. `atrinik/content-toolkit` owns +source syntax, lossless documents, schemas, catalogs, diagnostics, compilers, +semantic diffs, and atomic file transactions. `atrinik/renderer` owns scene +types, resources, device/GPU state, output targets, semantic masks, and +offscreen rendering. This repository owns project/tab state, explicit user +commands and history, document-to-scene adaptation, authoring panels/tools, +diagnostic presentation, preview composition, recovery UI, and packages. + +The dependency direction is: + +```text +content-toolkit release -> document adapter -> commands -> application +renderer release -> preview adapter -> application +project state -> commands/UI/test fakes +``` + +Production manifests pin immutable released source revisions. Coordinated local +overrides are wrapper-profile concerns and never change Cargo manifests. There +is no client, protocol, server, classic, Gridarta, parser, writer, network +service, or alternate renderer dependency. + +M1 uses the shared renderer's versioned scene/API/resource crates and its +deterministic reference test renderer to prove an empty viewport. GPU and SDL +handles stay below released renderer/application boundaries. The application +owns only SDL lifecycle; headless model tests do not initialize SDL. diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..dc2b584 --- /dev/null +++ b/deny.toml @@ -0,0 +1,25 @@ +[graph] +all-features = true + +[advisories] +yanked = "deny" + +[licenses] +allow = ["Apache-2.0", "MIT", "Zlib"] + +[bans] +multiple-versions = "deny" +wildcards = "deny" +deny = [ + { name = "reqwest" }, + { name = "tokio" }, + { name = "quinn" }, +] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-git = [ + "https://github.com/atrinik/content-toolkit", + "https://github.com/atrinik/renderer", +] diff --git a/docs/MUTABLE_PATHS.md b/docs/MUTABLE_PATHS.md new file mode 100644 index 0000000..1cda92e --- /dev/null +++ b/docs/MUTABLE_PATHS.md @@ -0,0 +1,14 @@ +# Mutable path ownership + +| Path class | Owner | Mutation contract | Recovery/user visibility | +| --- | --- | --- | --- | +| Authored source roots | content-toolkit transaction publisher | explicit semantic command, expected revision, validation, dry-run diff, canonical revalidation, atomic publish | dirty/conflict/diff/save state visible per document | +| Project/editor preferences | editor project service | bounded typed update, atomic editor-owned storage | reset/export visible; never mixed with authored source | +| Autosave/recovery | editor recovery service | append/replace outside project with quotas and expiry | recovery chooser; explicit discard/restore | +| Generated/collected/build output | content-toolkit compiler/collector | replace versioned output root, never treated as source | build result and provenance visible | +| Renderer cache | renderer resource provider | digest/revision keyed and bounded | safe eviction; diagnostics only | +| Wrapper build/runtime/state/logs | `atrinik/atrinik` | wrapper profile/topology/state contracts only | wrapper `show`/`ps`/`logs`/`down` | +| Credentials/trust | owning OS/wrapper service | never an editor document or snapshot | never rendered, logged, recovered, or packaged | + +No panel, tool, preview adapter, SDL callback, watcher, or application shell may +write a file directly. The current M1 crates expose no filesystem writer. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..9cc4447 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,46 @@ +# Safe authoring threat model + +## Assets and trust boundaries + +Authored projects, paths, links, source bytes, metadata, automation +instructions, external changes, and renderer resources are untrusted. Source +documents and their legal attribution are the protected assets. Credentials, +runtime state, collected/generated output, and recovery data must never enter a +source transaction. + +The editor holds presentation and command intent. Content-toolkit holds parsing +and transaction authority. Renderer holds GPU/resource authority. The root +wrapper alone may provision or supervise a server playtest. + +## Required controls + +- Accept portable relative slash-separated paths only. Reject absolute, drive, + UNC, traversal, empty/dot segments, control characters, Windows alternate + streams and device names, trailing dot/space aliases, excessive length, and + ambiguous case. +- Declare source/write roots and deny generated, collected, build, runtime, + state, log, and recovery prefixes even when nested below a source root. +- Canonicalize without following authority from project instructions. Reject + symlinks and special files. Require canonical root containment. +- Capture file identity and content revision at inspection, then canonicalize + and revalidate both immediately before toolkit publication. An external edit, + replacement, permission change, or path retarget produces a conflict. +- Preview validation/diffs before publication. Multi-file publication is one + toolkit transaction; disk-full, permission, rename, crash, and validation + faults publish all intended revisions or none. +- Keep bounded recovery journals outside the project. Recovery is explicit, + visible, expiring, and never silently written over source. +- Never execute project scripts, shell fragments, plugins, macros, or build + instructions merely by opening a project. Automation is an explicit bounded + command with dry-run defaults. +- Never connect to, start, embed, stop, or mutate a game server. Playtest is a + versioned wrapper request with isolated state and cleanup. + +## Current M1 proof and residual work + +`RelativePath`, `PathPolicy`, `FileStamp`, and `PathProbe` enforce lexical, +class, canonical, type, identity, and revision checks with deterministic fakes. +They intentionally perform no real filesystem operation. Canonical paths use a +slash-normalized adapter contract. Issue #4 integrates the released toolkit +publisher and OS-specific canonical/file-identity/case-collision adapter; issue +#9 owns fault, race, symlink-swap, permission, disk-full, and crash tests. diff --git a/migration/behavior-parity.json b/migration/behavior-parity.json new file mode 100644 index 0000000..c689e88 --- /dev/null +++ b/migration/behavior-parity.json @@ -0,0 +1,75 @@ +{ + "schema_version": 1, + "inventory": "classic-editor-authoring-v1", + "evidence_scope": [ + "atrinik/classic@49304ea3ba2507e1ee3380652a90c2c6c5af709b editor packaging", + "atrinik/content@01b1fdb65c2243df4bafe9c8109fc93229df0121 on 1.x: editor wrapper, authoring metadata, schemas, maps, and retained tools", + "external Gridarta user-visible behavior only; no implementation or fixture copied" + ], + "equivalence_gate": "every required row must become verified or receive an evidence-backed approved difference/retirement", + "rows": [ + {"id":"editor.install","domain":"lifecycle","entry_point":"classic editor package/INSTALL","behavior":"install and launch authoring application","fixture":"manual:clean-install","expected":"application reports version and opens without a project","failure":"actionable missing-runtime diagnostic","formats":[],"preservation":"semantic","owner":"atrinik/editor","issue":8,"milestone":"M6","surface":"Linux/Windows package","acceptance":"manual package smoke","status":"required","provenance":"behavior observation only"}, + {"id":"editor.project.discover","domain":"project","entry_point":"Gridarta project chooser","behavior":"discover a content project from explicit roots","fixture":"project:minimal","expected":"one deterministic project identity","failure":"reject ambiguous or nested roots","formats":["project tree"],"preservation":"semantic","owner":"atrinik/editor","issue":3,"milestone":"M4","surface":"project picker","acceptance":"headless discovery test","status":"required","provenance":"new fixture required"}, + {"id":"editor.project.configure","domain":"project","entry_point":"editor configuration","behavior":"configure source roots and visible preferences","fixture":"project:settings","expected":"validated explicit configuration","failure":"unknown instructions are inert","formats":["editor settings"],"preservation":"both","owner":"atrinik/editor","issue":3,"milestone":"M4","surface":"project settings","acceptance":"configuration round-trip test","status":"required","provenance":"new fixture required"}, + {"id":"editor.map.create","domain":"map","entry_point":"File/New Map","behavior":"create a bounded map with explicit metadata","fixture":"map:new","expected":"valid unsaved document","failure":"invalid dimensions create nothing","formats":["map"],"preservation":"semantic","owner":"atrinik/editor","issue":11,"milestone":"M4","surface":"new-map command","acceptance":"command test","status":"required","provenance":"new fixture required"}, + {"id":"editor.document.open","domain":"lifecycle","entry_point":"File/Open","behavior":"open supported authored documents losslessly","fixture":"document:ordinary","expected":"byte-identical unchanged view","failure":"malformed source remains inspectable without mutation","formats":["map","archetype","artifact","animation","treasure","faction","interface","quest"],"preservation":"both","owner":"atrinik/editor","issue":12,"milestone":"M2","surface":"document adapter","acceptance":"open/no-op round-trip","status":"required","provenance":"separately licensed corpus fixture required"}, + {"id":"editor.document.save","domain":"lifecycle","entry_point":"File/Save","behavior":"save validated semantic edits atomically","fixture":"document:one-edit","expected":"only intended bytes change","failure":"source remains unchanged","formats":["authored text"],"preservation":"both","owner":"atrinik/editor","issue":4,"milestone":"M4","surface":"save command","acceptance":"fault-injected transaction test","status":"required","provenance":"new fixture required"}, + {"id":"editor.recent","domain":"lifecycle","entry_point":"recent files","behavior":"reopen bounded recent documents","fixture":"project:recent","expected":"stable most-recent ordering","failure":"missing paths remain visible and inert","formats":["editor state"],"preservation":"semantic","owner":"atrinik/editor","issue":3,"milestone":"M4","surface":"recent list","acceptance":"headless state test","status":"required","provenance":"new fixture required"}, + {"id":"editor.tabs","domain":"lifecycle","entry_point":"multi-map windows/tabs","behavior":"open, focus, close, and restore multiple documents","fixture":"project:three-documents","expected":"independent dirty/selection/history state","failure":"one failed tab does not affect another","formats":["editor state"],"preservation":"semantic","owner":"atrinik/editor","issue":3,"milestone":"M4","surface":"tab model","acceptance":"headless tab test","status":"required","provenance":"new fixture required"}, + {"id":"editor.tiled.navigate","domain":"map","entry_point":"tiled map navigation","behavior":"follow cardinal and diagonal tiled neighbors","fixture":"map:tiled-chain","expected":"open exact linked coordinate neighbor","failure":"broken link is diagnostic","formats":["map"],"preservation":"both","owner":"atrinik/editor","issue":5,"milestone":"M4","surface":"viewport navigation","acceptance":"linked-map fixture","status":"required","provenance":"separately licensed corpus fixture required"}, + {"id":"editor.recovery","domain":"recovery","entry_point":"autosave/recovery","behavior":"recover unsaved command state outside source roots","fixture":"fault:crash-after-autosave","expected":"explicit bounded recovery choice","failure":"never overwrite source silently","formats":["recovery journal"],"preservation":"semantic","owner":"atrinik/editor","issue":10,"milestone":"M4","surface":"recovery UI","acceptance":"crash/restart test","status":"required","provenance":"new fixture required"}, + {"id":"map.header","domain":"map","entry_point":"map properties","behavior":"edit headers without source churn","fixture":"map:headers","expected":"unknown headers and ordering retained","failure":"invalid value rejected","formats":["map"],"preservation":"both","owner":"atrinik/editor","issue":6,"milestone":"M4","surface":"inspector","acceptance":"targeted edit fixture","status":"required","provenance":"separately licensed corpus fixture required"}, + {"id":"map.region","domain":"map","entry_point":"region property","behavior":"select and validate region identity","fixture":"map:region","expected":"stable catalog reference","failure":"missing region diagnostic","formats":["map","region catalog"],"preservation":"both","owner":"atrinik/editor","issue":6,"milestone":"M4","surface":"reference inspector","acceptance":"catalog-link test","status":"required","provenance":"new fixture required"}, + {"id":"map.neighbors","domain":"map","entry_point":"tile paths","behavior":"edit reciprocal tiled neighbors","fixture":"map:neighbors","expected":"multi-file semantic diff","failure":"partial publication impossible","formats":["map"],"preservation":"both","owner":"atrinik/editor","issue":4,"milestone":"M4","surface":"multi-file command","acceptance":"transaction fault test","status":"required","provenance":"new fixture required"}, + {"id":"map.exits","domain":"map","entry_point":"exit properties","behavior":"edit exit targets and backlinks","fixture":"map:exit-backlink","expected":"target path and coordinates validate","failure":"broken backlink diagnostic","formats":["map"],"preservation":"both","owner":"atrinik/editor","issue":6,"milestone":"M4","surface":"exit inspector","acceptance":"linked-exit fixture","status":"required","provenance":"separately licensed corpus fixture required"}, + {"id":"map.coordinates","domain":"map","entry_point":"map canvas/status","behavior":"display and edit bounded logical coordinates","fixture":"map:coordinate-bounds","expected":"exact logical coordinates","failure":"out-of-range edit rejected","formats":["map"],"preservation":"semantic","owner":"atrinik/editor","issue":5,"milestone":"M4","surface":"viewport tools","acceptance":"boundary test","status":"required","provenance":"new fixture required"}, + {"id":"map.layers","domain":"map","entry_point":"layer controls","behavior":"inspect and place objects by ordered layer","fixture":"map:layers","expected":"painter order preserved","failure":"unknown layer remains lossless","formats":["map"],"preservation":"both","owner":"atrinik/editor","issue":5,"milestone":"M4","surface":"layer tools","acceptance":"scene/order fixture","status":"required","provenance":"new fixture required"}, + {"id":"map.sublayers","domain":"map","entry_point":"sub-layer property","behavior":"preserve and edit sub-layer order","fixture":"map:sublayers","expected":"stable same-tile ordering","failure":"invalid order diagnostic","formats":["map","archetype"],"preservation":"both","owner":"atrinik/editor","issue":6,"milestone":"M4","surface":"inspector","acceptance":"ordering fixture","status":"required","provenance":"new fixture required"}, + {"id":"map.depth","domain":"map","entry_point":"linked physical depth","behavior":"navigate and render stacked map depth","fixture":"map:stacked-depth","expected":"stable cross-depth projection","failure":"cycle/broken link bounded diagnostic","formats":["map"],"preservation":"both","owner":"atrinik/renderer","issue":11,"milestone":"M2","surface":"ordered scene snapshot","acceptance":"cross-depth golden scene","status":"required","provenance":"separately licensed corpus fixture required"}, + {"id":"map.metadata","domain":"map","entry_point":"map metadata","behavior":"edit name, size, environment, reset, and attribution metadata","fixture":"map:metadata","expected":"schema-driven fields retain unknown data","failure":"constraint diagnostic links to field","formats":["map"],"preservation":"both","owner":"atrinik/editor","issue":6,"milestone":"M4","surface":"map inspector","acceptance":"metadata edit fixture","status":"required","provenance":"new fixture required"}, + {"id":"object.place","domain":"objects","entry_point":"archetype palette/canvas","behavior":"place archetype instance at layer and coordinate","fixture":"map:place-object","expected":"one semantic insertion","failure":"unknown archetype rejected","formats":["map","archetype catalog"],"preservation":"both","owner":"atrinik/editor","issue":5,"milestone":"M4","surface":"placement tool","acceptance":"placement fixture","status":"required","provenance":"new fixture required"}, + {"id":"object.multipart","domain":"objects","entry_point":"multipart placement","behavior":"place/select/move multipart object as one semantic identity","fixture":"map:multipart","expected":"parts remain linked and ordered","failure":"incomplete part diagnostic","formats":["map","archetype"],"preservation":"both","owner":"atrinik/editor","issue":5,"milestone":"M4","surface":"selection/tool model","acceptance":"multipart fixture","status":"required","provenance":"new fixture required"}, + {"id":"object.inventory","domain":"objects","entry_point":"object inventory tree","behavior":"inspect and edit nested inventory","fixture":"map:inventory","expected":"nesting and order retained","failure":"depth/size limits reject atomically","formats":["map","archetype"],"preservation":"both","owner":"atrinik/editor","issue":6,"milestone":"M4","surface":"object tree","acceptance":"nested inventory fixture","status":"required","provenance":"new fixture required"}, + {"id":"object.container","domain":"objects","entry_point":"container properties","behavior":"edit container capacity/class and contents","fixture":"map:container","expected":"references validate","failure":"invalid capacity diagnostic","formats":["map","archetype"],"preservation":"both","owner":"atrinik/editor","issue":6,"milestone":"M4","surface":"inspector","acceptance":"container fixture","status":"required","provenance":"new fixture required"}, + {"id":"object.properties","domain":"objects","entry_point":"property sheet","behavior":"schema-driven typed property editing","fixture":"object:properties","expected":"labels, types, ranges, and references from toolkit schema","failure":"invalid input never reaches transaction","formats":["archetype","map"],"preservation":"both","owner":"atrinik/content-toolkit","issue":4,"milestone":"M2","surface":"versioned schema/catalog API","acceptance":"schema fixture","status":"required","provenance":"behavior observation only"}, + {"id":"object.unknown-fields","domain":"objects","entry_point":"raw/custom properties","behavior":"preserve and explicitly edit unknown fields","fixture":"object:unknown-fields","expected":"untouched bytes exact","failure":"unsupported edit remains visible","formats":["authored text"],"preservation":"both","owner":"atrinik/content-toolkit","issue":5,"milestone":"M2","surface":"lossless transaction API","acceptance":"unknown-field round-trip","status":"required","provenance":"new fixture required"}, + {"id":"object.messages","domain":"objects","entry_point":"message editor","behavior":"edit multiline message blocks","fixture":"object:multiline-message","expected":"line endings and delimiters retained","failure":"unclosed block diagnostic","formats":["archetype","map","dialogue"],"preservation":"both","owner":"atrinik/content-toolkit","issue":5,"milestone":"M2","surface":"lossless document API","acceptance":"multiline fixture","status":"required","provenance":"new fixture required"}, + {"id":"object.order","domain":"objects","entry_point":"object tree ordering","behavior":"reorder same-tile and nested objects","fixture":"map:object-order","expected":"minimal deterministic move","failure":"stale order precondition rejects","formats":["map"],"preservation":"both","owner":"atrinik/editor","issue":4,"milestone":"M4","surface":"move command","acceptance":"ordering transaction test","status":"required","provenance":"new fixture required"}, + {"id":"edit.copy-paste","domain":"editing","entry_point":"Copy/Paste","behavior":"copy semantic object selection across documents","fixture":"map:copy-paste","expected":"new stable identities and retained fields","failure":"incompatible target creates no mutation","formats":["map"],"preservation":"semantic","owner":"atrinik/editor","issue":4,"milestone":"M4","surface":"clipboard command","acceptance":"cross-document test","status":"required","provenance":"new fixture required"}, + {"id":"edit.direction","domain":"editing","entry_point":"rotate/direction controls","behavior":"rotate turnable objects using semantic direction","fixture":"map:direction","expected":"direction and rendered orientation agree","failure":"non-turnable object unchanged","formats":["map","archetype"],"preservation":"both","owner":"atrinik/editor","issue":5,"milestone":"M4","surface":"direction tool","acceptance":"direction scene fixture","status":"required","provenance":"new fixture required"}, + {"id":"edit.search","domain":"editing","entry_point":"Find/Search","behavior":"search bounded project catalog and open documents","fixture":"project:search","expected":"stable ranked identity results","failure":"large query is bounded","formats":["project index"],"preservation":"semantic","owner":"atrinik/content-toolkit","issue":4,"milestone":"M2","surface":"catalog index API","acceptance":"index query test","status":"required","provenance":"new fixture required"}, + {"id":"edit.palette","domain":"editing","entry_point":"archetype palette","behavior":"browse categorized placeable objects","fixture":"catalog:palette","expected":"stable identity/category ordering","failure":"missing resource remains diagnostic","formats":["catalog","resource manifest"],"preservation":"semantic","owner":"atrinik/editor","issue":6,"milestone":"M4","surface":"catalog panel","acceptance":"palette model test","status":"required","provenance":"new fixture required"}, + {"id":"edit.favorites","domain":"editing","entry_point":"favorites","behavior":"persist bounded stable-ID favorites","fixture":"catalog:favorites","expected":"renamed labels do not break identity","failure":"missing favorite remains visible","formats":["editor settings"],"preservation":"semantic","owner":"atrinik/editor","issue":6,"milestone":"M4","surface":"favorites model","acceptance":"identity test","status":"required","provenance":"new fixture required"}, + {"id":"edit.bulk","domain":"editing","entry_point":"bulk property/replace tools","behavior":"preview and atomically apply bounded multi-object edits","fixture":"map:bulk-edit","expected":"deterministic semantic diff","failure":"one invalid target aborts all","formats":["map"],"preservation":"both","owner":"atrinik/content-toolkit","issue":5,"milestone":"M2","surface":"transaction/diff API","acceptance":"multi-edit fault test","status":"required","provenance":"new fixture required"}, + {"id":"content.artifacts","domain":"structured-content","entry_point":"artifact authoring","behavior":"inspect/edit artifact definitions","fixture":"content:artifact","expected":"schema validation and lossless save","failure":"bad reference diagnostic","formats":["artifacts"],"preservation":"both","owner":"atrinik/editor","issue":7,"milestone":"M5","surface":"measured-demand panel","acceptance":"artifact workflow fixture","status":"required","provenance":"separately licensed fixture required"}, + {"id":"content.animations","domain":"structured-content","entry_point":"animation authoring","behavior":"inspect/edit animation sequences","fixture":"content:animation","expected":"frame order/timing retained","failure":"missing frame diagnostic","formats":["animations"],"preservation":"both","owner":"atrinik/editor","issue":7,"milestone":"M5","surface":"animation panel","acceptance":"animation fixture","status":"required","provenance":"separately licensed fixture required"}, + {"id":"content.treasures","domain":"structured-content","entry_point":"treasure authoring","behavior":"inspect/edit weighted treasure graphs","fixture":"content:treasure","expected":"weights and graph identity retained","failure":"cycle/missing reference diagnostic","formats":["treasures"],"preservation":"both","owner":"atrinik/editor","issue":7,"milestone":"M5","surface":"treasure panel","acceptance":"treasure fixture","status":"required","provenance":"separately licensed fixture required"}, + {"id":"content.factions","domain":"structured-content","entry_point":"faction authoring","behavior":"inspect/edit faction relationships","fixture":"content:faction","expected":"stable identities and relations","failure":"unknown identity diagnostic","formats":["factions"],"preservation":"both","owner":"atrinik/editor","issue":7,"milestone":"M5","surface":"faction panel","acceptance":"faction fixture","status":"required","provenance":"separately licensed fixture required"}, + {"id":"content.interfaces","domain":"structured-content","entry_point":"interface/dialogue editor","behavior":"inspect/edit dialogue/interface trees","fixture":"content:dialogue","expected":"branches, messages, and conditions retained","failure":"unreachable/broken branch diagnostic","formats":["interfaces","dialogue"],"preservation":"both","owner":"atrinik/editor","issue":7,"milestone":"M5","surface":"dialogue panel","acceptance":"dialogue fixture","status":"required","provenance":"separately licensed fixture required"}, + {"id":"content.quests","domain":"structured-content","entry_point":"quest authoring","behavior":"inspect/edit quest state definitions","fixture":"content:quest","expected":"state graph and rewards retained","failure":"broken transition diagnostic","formats":["quests"],"preservation":"both","owner":"atrinik/editor","issue":7,"milestone":"M5","surface":"quest panel","acceptance":"quest fixture","status":"required","provenance":"separately licensed fixture required"}, + {"id":"content.npcs","domain":"structured-content","entry_point":"NPC properties/scripts","behavior":"author NPC data without executing project code","fixture":"content:npc","expected":"data and opaque script reference retained","failure":"automation stays inert","formats":["map","archetype","dialogue"],"preservation":"both","owner":"atrinik/editor","issue":7,"milestone":"M5","surface":"NPC inspector","acceptance":"inert-script security test","status":"required","provenance":"separately licensed fixture required"}, + {"id":"content.shops","domain":"structured-content","entry_point":"shop/service authoring","behavior":"edit shop inventory, pricing, and services","fixture":"content:shop","expected":"catalog references validate","failure":"invalid service diagnostic","formats":["map","archetype","treasures"],"preservation":"both","owner":"atrinik/editor","issue":7,"milestone":"M5","surface":"shop inspector","acceptance":"shop fixture","status":"required","provenance":"separately licensed fixture required"}, + {"id":"content.attribution","domain":"licensing","entry_point":"authored metadata","behavior":"preserve attribution and license fields","fixture":"content:attribution","expected":"exact legal metadata retained","failure":"missing metadata diagnosed, never synthesized","formats":["all authored formats"],"preservation":"both","owner":"atrinik/content-toolkit","issue":4,"milestone":"M2","surface":"schema/diagnostics","acceptance":"attribution round-trip","status":"required","provenance":"separately licensed fixture required"}, + {"id":"content.other","domain":"structured-content","entry_point":"other registered authored types","behavior":"open/validate/save every schema-registered content class","fixture":"content:class-matrix","expected":"zero unsupported registered classes","failure":"unknown class remains lossless","formats":["authored content registry"],"preservation":"both","owner":"atrinik/editor","issue":16,"milestone":"M5","surface":"parity matrix","acceptance":"whole-pack class audit","status":"required","provenance":"separately licensed corpus fixtures required"}, + {"id":"validate.document","domain":"validation","entry_point":"Validate","behavior":"run deterministic schema and reference validation","fixture":"validation:ordinary-adversarial","expected":"stable linked diagnostics","failure":"diagnostic cap and cancellation","formats":["all authored formats"],"preservation":"semantic","owner":"atrinik/content-toolkit","issue":4,"milestone":"M2","surface":"diagnostics API","acceptance":"diagnostic golden test","status":"required","provenance":"new fixture required"}, + {"id":"validate.map-check","domain":"validation","entry_point":"map checker","behavior":"check maps and preview safe fixes","fixture":"validation:map-check","expected":"dry-run diff before mutation","failure":"unsafe fix unavailable","formats":["map"],"preservation":"both","owner":"atrinik/content-toolkit","issue":8,"milestone":"M4","surface":"checker migration","acceptance":"checker parity fixture","status":"required","provenance":"behavior observation only"}, + {"id":"build.collect","domain":"build","entry_point":"collection tools","behavior":"collect declared content deterministically","fixture":"build:collect","expected":"same input identity yields same output","failure":"generated output never writable as source","formats":["content tree","collection"],"preservation":"semantic","owner":"atrinik/content-toolkit","issue":8,"milestone":"M4","surface":"collector CLI/API","acceptance":"reproducible collection test","status":"required","provenance":"new fixture required"}, + {"id":"build.compile","domain":"build","entry_point":"content build","behavior":"compile bounded ABIN/AMAP artifacts","fixture":"build:compile","expected":"deterministic artifact digest","failure":"no partial artifact","formats":["ABIN","AMAP"],"preservation":"semantic","owner":"atrinik/content-toolkit","issue":6,"milestone":"M2","surface":"compiler API","acceptance":"golden artifact test","status":"required","provenance":"new fixture required"}, + {"id":"build.package","domain":"build","entry_point":"package/release tools","behavior":"package authored sources and generated products separately","fixture":"build:package","expected":"manifested reproducible archive","failure":"ambiguous license blocks package","formats":["release archive"],"preservation":"semantic","owner":"atrinik/content-toolkit","issue":7,"milestone":"M2","surface":"CLI release","acceptance":"two-build comparison","status":"required","provenance":"new fixture required"}, + {"id":"diff.semantic","domain":"editing","entry_point":"diff preview","behavior":"show semantic and exact-byte effects before save","fixture":"document:semantic-diff","expected":"intended spans and preserved bytes visible","failure":"stale revision invalidates diff","formats":["authored text"],"preservation":"both","owner":"atrinik/content-toolkit","issue":5,"milestone":"M2","surface":"diff API","acceptance":"diff fixture","status":"required","provenance":"new fixture required"}, + {"id":"paths.generated","domain":"filesystem","entry_point":"generated/collected paths","behavior":"distinguish immutable source from generated/runtime outputs","fixture":"project:path-classes","expected":"write authority per path class","failure":"generated/runtime source write denied","formats":["project tree"],"preservation":"semantic","owner":"atrinik/editor","issue":2,"milestone":"M1","surface":"path policy","acceptance":"path attack tests","status":"required","provenance":"new fixture required"}, + {"id":"preview.local","domain":"preview","entry_point":"Preview","behavior":"render immutable local scene without a server","fixture":"preview:empty-and-map","expected":"shared renderer output","failure":"device/resource failure leaves document unchanged","formats":["scene bundle"],"preservation":"semantic","owner":"atrinik/renderer","issue":12,"milestone":"M2","surface":"headless renderer","acceptance":"golden frame test","status":"required","provenance":"new fixture required"}, + {"id":"preview.client-exact","domain":"preview","entry_point":"client-exact preview","behavior":"use the same renderer contracts and resources as client","fixture":"preview:client-equivalence","expected":"semantic/depth/coverage equivalence","failure":"version mismatch blocks preview","formats":["scene bundle","resource envelope"],"preservation":"semantic","owner":"atrinik/renderer","issue":7,"milestone":"M2","surface":"shared renderer","acceptance":"cross-consumer golden test","status":"required","provenance":"new fixture required"}, + {"id":"playtest.isolated","domain":"playtest","entry_point":"Playtest","behavior":"invoke wrapper-owned isolated topology","fixture":"playtest:one-map","expected":"versioned profile/state/topology lifecycle","failure":"editor never starts or mutates server directly","formats":["wrapper invocation"],"preservation":"semantic","owner":"atrinik/atrinik","issue":269,"milestone":"M2","surface":"atrinik topology commands","acceptance":"wrapper lifecycle test","status":"required","provenance":"new fixture required"}, + {"id":"history.undo-redo","domain":"editing","entry_point":"Undo/Redo","behavior":"reverse and replay semantic commands","fixture":"history:branching","expected":"revision-preconditioned bounded history","failure":"external change invalidates operation","formats":["editor command state"],"preservation":"both","owner":"atrinik/editor","issue":4,"milestone":"M4","surface":"history model","acceptance":"branching history test","status":"required","provenance":"new fixture required"}, + {"id":"state.dirty","domain":"lifecycle","entry_point":"dirty indicators/close","behavior":"track source revision versus published revision","fixture":"state:dirty","expected":"per-document explicit dirty state","failure":"close prompts without data loss","formats":["editor state"],"preservation":"semantic","owner":"atrinik/editor","issue":3,"milestone":"M4","surface":"document lifecycle","acceptance":"dirty/close test","status":"required","provenance":"new fixture required"}, + {"id":"write.atomic","domain":"filesystem","entry_point":"save transaction","behavior":"publish single/multi-file edits atomically","fixture":"fault:write-matrix","expected":"all intended revisions or none","failure":"permission/disk/rename/crash recoverable","formats":["authored files"],"preservation":"both","owner":"atrinik/content-toolkit","issue":5,"milestone":"M2","surface":"transaction publisher","acceptance":"fault matrix","status":"required","provenance":"new fixture required"}, + {"id":"write.external-change","domain":"filesystem","entry_point":"watcher/save","behavior":"detect file identity/revision change before publication","fixture":"fault:external-edit","expected":"explicit conflict","failure":"never overwrite external edit","formats":["authored files"],"preservation":"both","owner":"atrinik/editor","issue":4,"milestone":"M4","surface":"save precondition","acceptance":"TOCTOU fake test","status":"required","provenance":"new fixture required"}, + {"id":"failure.malformed","domain":"failure","entry_point":"open/validate","behavior":"display malformed source and bounded diagnostics","fixture":"failure:malformed-matrix","expected":"recoverable read-only document","failure":"no panic or source mutation","formats":["all authored formats"],"preservation":"both","owner":"atrinik/content-toolkit","issue":9,"milestone":"M4","surface":"corpus/fault API","acceptance":"malformed corpus","status":"required","provenance":"new fixture required"}, + {"id":"failure.messages","domain":"failure","entry_point":"diagnostics/status","behavior":"present actionable stable failures without source/secrets leakage","fixture":"failure:diagnostics","expected":"code, safe message, source link","failure":"bounded summary on overflow","formats":["diagnostics"],"preservation":"semantic","owner":"atrinik/editor","issue":6,"milestone":"M4","surface":"diagnostics panel","acceptance":"presentation test","status":"required","provenance":"new fixture required"}, + {"id":"input.keyboard","domain":"accessibility","entry_point":"keyboard commands","behavior":"all core commands have discoverable keyboard paths","fixture":"input:keyboard","expected":"semantic command parity","failure":"conflicts diagnosed","formats":["keymap"],"preservation":"semantic","owner":"atrinik/editor","issue":11,"milestone":"M4","surface":"command palette/keymap","acceptance":"keymap audit","status":"required","provenance":"new fixture required"}, + {"id":"accessibility","domain":"accessibility","entry_point":"native UI","behavior":"labels, focus, scaling, contrast, and non-pointer workflows","fixture":"accessibility:matrix","expected":"platform accessibility tree and keyboard reachability","failure":"unsupported control blocks release","formats":["UI model"],"preservation":"semantic","owner":"atrinik/editor","issue":9,"milestone":"M6","surface":"native accessibility","acceptance":"manual/automated platform audit","status":"required","provenance":"new fixture required"}, + {"id":"platform.linux-windows","domain":"platform","entry_point":"Linux/Windows application","behavior":"equivalent supported authoring outcomes on both platforms","fixture":"platform:matrix","expected":"same project/document/command semantics","failure":"platform-specific path ambiguity rejected","formats":["packages","paths"],"preservation":"semantic","owner":"atrinik/editor","issue":8,"milestone":"M6","surface":"release packages","acceptance":"cross-platform CI/manual matrix","status":"required","provenance":"new fixture required"}, + {"id":"automation.cli","domain":"automation","entry_point":"maintainer CLI","behavior":"headless validate/diff/build/preview workflows","fixture":"automation:cli","expected":"versioned deterministic machine output","failure":"bounded nonzero failure with no mutation by default","formats":["CLI JSON"],"preservation":"semantic","owner":"atrinik/editor","issue":10,"milestone":"M4","surface":"editor automation CLI","acceptance":"CLI integration test","status":"required","provenance":"new fixture required"} + ] +} diff --git a/policy/dependencies.json b/policy/dependencies.json new file mode 100644 index 0000000..bec813a --- /dev/null +++ b/policy/dependencies.json @@ -0,0 +1,26 @@ +{ + "schema_version": 1, + "owner": "editor maintainers", + "review_cadence": "monthly and on every dependency update", + "eol_response": "upgrade, replace, or remove before an unsupported release ships", + "rust": "1.97.1", + "edition": "2024", + "direct_external": [ + {"repository":"atrinik/content-toolkit","release":"v1.0.0","version":"0.1.0","revision":"b2178d442af5d897a45619c200fec5ceb39fc3cf","crates":["atrinik-source","atrinik-transaction"],"purpose":"released lossless document and semantic transaction authority"}, + {"repository":"atrinik/renderer","release":"v1.0.0","version":"0.1.0","revision":"3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9","crates":["atrinik-scene","atrinik-render-api","atrinik-render-resources"],"purpose":"released scene, render, and resource contracts"}, + {"repository":"crates.io","release":"sdl3 0.18.4 / SDL 3.4.14","version":"0.18.4","revision":"Cargo.lock","crates":["sdl3"],"purpose":"isolated native application window lifecycle"} + ], + "local_override_policy": "wrapper profiles only; never edit Cargo manifests", + "ci_tools": [ + {"name":"cargo-deny","version":"0.20.2","source":"github.com/EmbarkStudios/cargo-deny releases","validation":"SHA-256 before execution"}, + {"name":"cargo-auditable","version":"0.7.5","source":"github.com/rust-secure-code/cargo-auditable releases","validation":"SHA-256 before execution"}, + {"name":"Syft","version":"1.50.0","source":"github.com/anchore/syft releases","validation":"SHA-256 before execution"}, + {"name":"semantic-release","version":"25.0.9","source":"npm registry","validation":"ephemeral exact-version execution"} + ], + "actions": [ + {"repository":"actions/checkout","revision":"3d3c42e5aac5ba805825da76410c181273ba90b1"}, + {"repository":"actions/upload-artifact","revision":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"}, + {"repository":"actions/download-artifact","revision":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"} + ], + "forbidden": ["atrinik/client","atrinik/protocol","atrinik/server","atrinik/classic","Gridarta","write-capable network services","editor parsers/writers","editor renderers"] +} diff --git a/provenance/assets.json b/provenance/assets.json new file mode 100644 index 0000000..c240189 --- /dev/null +++ b/provenance/assets.json @@ -0,0 +1 @@ +{"schema_version":1,"assets":[]} diff --git a/provenance/reuse.json b/provenance/reuse.json new file mode 100644 index 0000000..4858025 --- /dev/null +++ b/provenance/reuse.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "grant_used": false, + "records": [ + {"id":"classic-editor-behavior","source":"atrinik/classic@49304ea3ba2507e1ee3380652a90c2c6c5af709b editor packaging","status":"excluded","reason":"behavior and entry-point evidence only; no implementation or fixture copied"}, + {"id":"content-1x-authoring-behavior","source":"atrinik/content@01b1fdb65c2243df4bafe9c8109fc93229df0121","status":"excluded","reason":"authored-format and user-visible behavior evidence only; no implementation or fixture copied"}, + {"id":"gridarta","source":"external Gridarta","status":"excluded","reason":"GPL/external implementation, tests, assets, configuration, and JAR are outside the MIT boundary"} + ] +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..9946197 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/tools/check-architecture.sh b/tools/check-architecture.sh new file mode 100755 index 0000000..986d8f7 --- /dev/null +++ b/tools/check-architecture.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +metadata=$(mktemp /tmp/atrinik-editor-metadata.XXXXXX) +trap 'rm -f -- "${metadata}"' EXIT +cargo metadata --locked --offline --format-version 1 >"${metadata}" + +jq -e ' + def deps($name): [.packages[] | select(.name == $name) | .dependencies[].name] | sort; + deps("atrinik-editor-project") == [] and + deps("atrinik-editor-document") == ["atrinik-source"] and + deps("atrinik-editor-commands") == ["atrinik-source","atrinik-transaction"] and + deps("atrinik-editor-ui") == ["atrinik-editor-project"] and + deps("atrinik-editor-preview") == ["atrinik-render-api","atrinik-render-resources","atrinik-render-testkit","atrinik-scene"] and + deps("atrinik-editor-testkit") == ["atrinik-editor-project"] and + deps("atrinik-editor") == ["atrinik-editor-commands","atrinik-editor-document","atrinik-editor-preview","atrinik-editor-project","atrinik-editor-ui","sdl3"] and + ([.packages[].name | select(test("(client|protocol|server|classic|gridarta)"; "i"))] | length == 0) and + all(.packages[].dependencies[]; + (.source // "") as $source | + ($source == "" or + $source == "registry+https://github.com/rust-lang/crates.io-index" or + ($source | startswith("git+https://github.com/atrinik/content-toolkit?rev=b2178d442af5d897a45619c200fec5ceb39fc3cf")) or + ($source | startswith("git+https://github.com/atrinik/renderer?rev=3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9")))) +' "${metadata}" >/dev/null + +if grep -RnE --include='*.rs' \ + 'std::(fs|net|process::Command)|tokio::|fn[[:space:]]+(parse|serialize|write|save)[[:space:]]*\(' \ + crates >/dev/null; then + echo "editor source duplicates a forbidden parser/writer or direct I/O authority" >&2 + exit 1 +fi diff --git a/tools/check-foundations.sh b/tools/check-foundations.sh new file mode 100755 index 0000000..4538492 --- /dev/null +++ b/tools/check-foundations.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository=$(git rev-parse --show-toplevel) +cd "${repository}" + +jq -e ' + .schema_version == 1 and (.rows | length >= 55) and + ([.rows[].id] | length == (unique | length)) and + all(.rows[]; + .id != "" and .domain != "" and .entry_point != "" and .behavior != "" and + .fixture != "" and .expected != "" and .failure != "" and + (.formats | type == "array") and (.preservation | IN("byte","semantic","both")) and + .owner != "" and .issue > 0 and .milestone != "" and .surface != "" and + .acceptance != "" and .status == "required" and .provenance != "") +' migration/behavior-parity.json >/dev/null +jq -e '.schema_version == 1 and .grant_used == false and all(.records[]; .status == "excluded")' provenance/reuse.json >/dev/null +jq -e '.schema_version == 1 and .assets == []' provenance/assets.json >/dev/null +jq -e '.schema_version == 1 and .rust == "1.97.1" and (.direct_external | length == 3)' policy/dependencies.json >/dev/null + +if grep -RhE '^[[:space:]]*uses:' .github/workflows 2>/dev/null \ + | grep -Ev '@[0-9a-f]{40}([[:space:]]|$)' >/dev/null; then + echo "workflow action is not pinned to an immutable commit" >&2 + exit 1 +fi +for required in CONTRIBUTING.md PROVENANCE.md SECURITY.md THIRD_PARTY_NOTICES.md \ + decisions/0001-editor-architecture.md docs/THREAT_MODEL.md docs/MUTABLE_PATHS.md; do + test -s "${required}" +done +tools/check-architecture.sh diff --git a/tools/install-linux-native-deps.sh b/tools/install-linux-native-deps.sh new file mode 100755 index 0000000..fd583d1 --- /dev/null +++ b/tools/install-linux-native-deps.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +sudo apt-get update +sudo apt-get install --no-install-recommends --yes \ + libasound2-dev libdbus-1-dev libdecor-0-dev libdrm-dev libegl1-mesa-dev \ + libgbm-dev libgl1-mesa-dev libgles2-mesa-dev libibus-1.0-dev \ + libpipewire-0.3-dev libpulse-dev libsndio-dev libudev-dev liburing-dev \ + libwayland-dev libx11-dev libxcursor-dev libxext-dev libxfixes-dev \ + libxi-dev libxkbcommon-dev libxrandr-dev libxss-dev libxtst-dev diff --git a/tools/package-linux.sh b/tools/package-linux.sh new file mode 100755 index 0000000..a781cb2 --- /dev/null +++ b/tools/package-linux.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository=$(git rev-parse --show-toplevel) +cd "${repository}" +output=${1:-dist} +version=${2:-0.1.0-dev} +if [[ ! ${version} =~ ^[0-9]+\.[0-9]+\.[0-9]+([.+-][0-9A-Za-z.-]+)?$ ]]; then + echo "invalid release version: ${version}" >&2 + exit 2 +fi +if [[ -e ${output} ]]; then + echo "release output already exists: ${output}" >&2 + exit 1 +fi +for command in cargo-auditable syft strip; do command -v "${command}" >/dev/null || { echo "missing required tool: ${command}" >&2; exit 1; }; done + +stage=$(mktemp -d /tmp/atrinik-editor-stage.XXXXXX) +trap 'rm -rf -- "${stage}"' EXIT +cargo auditable build --locked --release --package atrinik-editor +target=$(cargo metadata --locked --offline --format-version 1 --no-deps | jq -r .target_directory) +install -d "${stage}/atrinik-editor-${version}/bin" "${output}" +install "${target}/release/atrinik-editor" "${stage}/atrinik-editor-${version}/bin/atrinik-editor" +strip "${stage}/atrinik-editor-${version}/bin/atrinik-editor" +cp LICENSE PROVENANCE.md THIRD_PARTY_NOTICES.md policy/dependencies.json \ + "${stage}/atrinik-editor-${version}/" +"${stage}/atrinik-editor-${version}/bin/atrinik-editor" version >/dev/null +SYFT_CHECK_FOR_APP_UPDATE=false syft \ + "${stage}/atrinik-editor-${version}/bin/atrinik-editor" \ + --source-name atrinik-editor --source-version "${version}" \ + --output "cyclonedx-json=${stage}/atrinik-editor-${version}/sbom.cdx.json" +sbom="${stage}/atrinik-editor-${version}/sbom.cdx.json" +normalized="${sbom}.normalized" +digest=$(printf 'atrinik-editor:linux-amd64:%s' "${version}" | sha256sum | cut -d' ' -f1) +serial="urn:uuid:${digest:0:8}-${digest:8:4}-8${digest:13:3}-8${digest:17:3}-${digest:20:12}" +jq --arg version "${version}" --arg serial "${serial}" ' + .serialNumber = $serial | + .metadata.timestamp = "1970-01-01T00:00:00Z" | + .metadata.component["bom-ref"] = ("atrinik-editor-linux-amd64@" + $version) | + (.components[] | select(.type == "file") | .name) = "/atrinik-editor" +' "${sbom}" >"${normalized}" +mv "${normalized}" "${sbom}" +jq -e '(.components // []) | length >= 10' "${sbom}" >/dev/null +jq -n --arg version "${version}" --arg revision "$(git rev-parse HEAD)" \ + --arg rust "$(rustc --version)" \ + '{schema_version:1,version:$version,revision:$revision,rust:$rust, + toolkit:{release:"v1.0.0",revision:"b2178d442af5d897a45619c200fec5ceb39fc3cf"}, + renderer:{release:"v1.0.0",revision:"3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9"}}' \ + >"${stage}/atrinik-editor-${version}/provenance.json" + +archive="${output}/atrinik-editor-${version}-linux-amd64.tar.gz" +tar --sort=name --owner=0 --group=0 --numeric-owner --mtime='UTC 1970-01-01' \ + -C "${stage}" -cf - "atrinik-editor-${version}" | gzip -n >"${archive}" +git archive --format=tar --prefix="atrinik-editor-${version}/" HEAD \ + | gzip -n >"${output}/atrinik-editor-${version}-source.tar.gz" +(cd "${output}" && sha256sum ./* >SHA256SUMS) diff --git a/tools/package-windows.ps1 b/tools/package-windows.ps1 new file mode 100644 index 0000000..2b60acc --- /dev/null +++ b/tools/package-windows.ps1 @@ -0,0 +1,42 @@ +param( + [string]$Output = "dist", + [Parameter(Mandatory = $true)][string]$Version +) +$ErrorActionPreference = "Stop" +if ($Version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+([.+-][0-9A-Za-z.-]+)?$') { throw "invalid release version" } +if (Test-Path $Output) { throw "release output already exists: $Output" } +foreach ($Command in @("cargo-auditable", "syft")) { + if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { throw "missing required tool: $Command" } +} +cargo auditable build --locked --release --package atrinik-editor +$Metadata = cargo metadata --locked --offline --format-version 1 --no-deps | ConvertFrom-Json +$StageRoot = Join-Path $env:RUNNER_TEMP ([System.IO.Path]::GetRandomFileName()) +$Stage = Join-Path $StageRoot "atrinik-editor-$Version" +try { + New-Item -ItemType Directory -Path "$Stage/bin", $Output | Out-Null + Copy-Item (Join-Path $Metadata.target_directory "release/atrinik-editor.exe") "$Stage/bin/" + Copy-Item LICENSE, PROVENANCE.md, THIRD_PARTY_NOTICES.md, policy/dependencies.json $Stage + & "$Stage/bin/atrinik-editor.exe" version | Out-Null + syft "$Stage/bin/atrinik-editor.exe" --source-name atrinik-editor --source-version $Version --output "cyclonedx-json=$Stage/sbom.cdx.json" + $Sbom = Get-Content "$Stage/sbom.cdx.json" -Raw | ConvertFrom-Json + if ($Sbom.components.Count -lt 10) { throw "binary SBOM is incomplete" } + $Digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes("atrinik-editor:windows-amd64:$Version"))).ToLowerInvariant() + $Sbom.serialNumber = "urn:uuid:$($Digest.Substring(0, 8))-$($Digest.Substring(8, 4))-8$($Digest.Substring(13, 3))-8$($Digest.Substring(17, 3))-$($Digest.Substring(20, 12))" + $Sbom.metadata.timestamp = "1970-01-01T00:00:00Z" + $Sbom.metadata.component.'bom-ref' = "atrinik-editor-windows-amd64@$Version" + foreach ($Component in $Sbom.components) { + if ($Component.type -eq "file") { $Component.name = "/atrinik-editor.exe" } + } + $Sbom | ConvertTo-Json -Depth 20 -Compress | Set-Content "$Stage/sbom.cdx.json" -Encoding utf8NoBOM + $Provenance = [ordered]@{ + schema_version = 1; version = $Version; revision = (git rev-parse HEAD); rust = (rustc --version) + toolkit = [ordered]@{ release = "v1.0.0"; revision = "b2178d442af5d897a45619c200fec5ceb39fc3cf" } + renderer = [ordered]@{ release = "v1.0.0"; revision = "3a6bbeabc2b7eac8d162d758732a0495fe8a9dd9" } + } + $Provenance | ConvertTo-Json -Depth 4 | Set-Content "$Stage/provenance.json" -Encoding utf8NoBOM + $Archive = Join-Path $Output "atrinik-editor-$Version-windows-amd64.zip" + Compress-Archive -Path $Stage -DestinationPath $Archive + (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant() + " " + (Split-Path $Archive -Leaf) | Set-Content (Join-Path $Output "SHA256SUMS.windows") -Encoding ascii +} finally { + if (Test-Path $StageRoot) { Remove-Item -Recurse -Force $StageRoot } +} diff --git a/tools/validate.sh b/tools/validate.sh new file mode 100755 index 0000000..4c898e0 --- /dev/null +++ b/tools/validate.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository=$(git rev-parse --show-toplevel) +cd "${repository}" +test "$(rustc --version | awk '{print $2}')" = 1.97.1 +for command in cargo cargo-deny cargo-auditable jq syft; do command -v "${command}" >/dev/null || { echo "missing required tool: ${command}" >&2; exit 1; }; done +cargo fmt --all --check +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo test --locked --workspace --all-targets +cargo test --locked --workspace --doc +cargo deny --locked check +tools/check-foundations.sh +cargo run --locked --quiet --package atrinik-editor -- version +cargo run --locked --quiet --package atrinik-editor -- headless +SDL_VIDEO_DRIVER=dummy cargo run --locked --quiet --package atrinik-editor -- window +first=$(mktemp -d /tmp/atrinik-editor-release-first.XXXXXX); rmdir "${first}" +second=$(mktemp -d /tmp/atrinik-editor-release-second.XXXXXX); rmdir "${second}" +trap 'rm -rf -- "${first}" "${second}"' EXIT +tools/package-linux.sh "${first}" 0.1.0-test.1 +tools/package-linux.sh "${second}" 0.1.0-test.1 +cmp "${first}/atrinik-editor-0.1.0-test.1-linux-amd64.tar.gz" "${second}/atrinik-editor-0.1.0-test.1-linux-amd64.tar.gz" +cmp "${first}/atrinik-editor-0.1.0-test.1-source.tar.gz" "${second}/atrinik-editor-0.1.0-test.1-source.tar.gz" +git diff --check