From 364592b33148df12f5b2fa63dd2b620cdc1ac192 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 8 Aug 2026 15:12:08 +0000 Subject: [PATCH 1/6] feat(editor): establish clean-room foundations --- .github/dependabot.yml | 12 + .github/workflows/package-release.yml | 143 +++++++++ .github/workflows/pr-title.yml | 18 ++ .github/workflows/release.yml | 27 ++ .github/workflows/validate.yml | 75 +++++ .gitignore | 3 + .releaserc.json | 8 + CONTRIBUTING.md | 11 + Cargo.lock | 342 ++++++++++++++++++++++ Cargo.toml | 44 +++ PROVENANCE.md | 18 ++ README.md | 64 +++- SECURITY.md | 10 + THIRD_PARTY_NOTICES.md | 13 + crates/atrinik-editor-app/Cargo.toml | 14 + crates/atrinik-editor-app/src/main.rs | 53 ++++ crates/atrinik-editor-commands/Cargo.toml | 14 + crates/atrinik-editor-commands/src/lib.rs | 140 +++++++++ crates/atrinik-editor-document/Cargo.toml | 13 + crates/atrinik-editor-document/src/lib.rs | 69 +++++ crates/atrinik-editor-preview/Cargo.toml | 18 ++ crates/atrinik-editor-preview/src/lib.rs | 59 ++++ crates/atrinik-editor-project/Cargo.toml | 10 + crates/atrinik-editor-project/src/lib.rs | 316 ++++++++++++++++++++ crates/atrinik-editor-testkit/Cargo.toml | 13 + crates/atrinik-editor-testkit/src/lib.rs | 42 +++ crates/atrinik-editor-ui/Cargo.toml | 13 + crates/atrinik-editor-ui/src/lib.rs | 137 +++++++++ decisions/0001-editor-architecture.md | 29 ++ deny.toml | 25 ++ docs/MUTABLE_PATHS.md | 14 + docs/THREAT_MODEL.md | 43 +++ migration/behavior-parity.json | 75 +++++ policy/dependencies.json | 26 ++ provenance/assets.json | 1 + provenance/reuse.json | 9 + rust-toolchain.toml | 4 + tools/check-architecture.sh | 24 ++ tools/check-foundations.sh | 30 ++ tools/install-linux-native-deps.sh | 10 + tools/package-linux.sh | 45 +++ tools/package-windows.ps1 | 34 +++ tools/validate.sh | 24 ++ 43 files changed, 2089 insertions(+), 3 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/package-release.yml create mode 100644 .github/workflows/pr-title.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 .releaserc.json create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 PROVENANCE.md create mode 100644 SECURITY.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 crates/atrinik-editor-app/Cargo.toml create mode 100644 crates/atrinik-editor-app/src/main.rs create mode 100644 crates/atrinik-editor-commands/Cargo.toml create mode 100644 crates/atrinik-editor-commands/src/lib.rs create mode 100644 crates/atrinik-editor-document/Cargo.toml create mode 100644 crates/atrinik-editor-document/src/lib.rs create mode 100644 crates/atrinik-editor-preview/Cargo.toml create mode 100644 crates/atrinik-editor-preview/src/lib.rs create mode 100644 crates/atrinik-editor-project/Cargo.toml create mode 100644 crates/atrinik-editor-project/src/lib.rs create mode 100644 crates/atrinik-editor-testkit/Cargo.toml create mode 100644 crates/atrinik-editor-testkit/src/lib.rs create mode 100644 crates/atrinik-editor-ui/Cargo.toml create mode 100644 crates/atrinik-editor-ui/src/lib.rs create mode 100644 decisions/0001-editor-architecture.md create mode 100644 deny.toml create mode 100644 docs/MUTABLE_PATHS.md create mode 100644 docs/THREAT_MODEL.md create mode 100644 migration/behavior-parity.json create mode 100644 policy/dependencies.json create mode 100644 provenance/assets.json create mode 100644 provenance/reuse.json create mode 100644 rust-toolchain.toml create mode 100755 tools/check-architecture.sh create mode 100755 tools/check-foundations.sh create mode 100755 tools/install-linux-native-deps.sh create mode 100755 tools/package-linux.sh create mode 100644 tools/package-windows.ps1 create mode 100755 tools/validate.sh 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..31883e3 --- /dev/null +++ b/.github/workflows/package-release.yml @@ -0,0 +1,143 @@ +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 }} + 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..a149494 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,342 @@ +# 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-project", + "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..6f83834 --- /dev/null +++ b/crates/atrinik-editor-app/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "atrinik-editor" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +atrinik-editor-project.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..b948ccf --- /dev/null +++ b/crates/atrinik-editor-app/src/main.rs @@ -0,0 +1,53 @@ +// Copyright 2026 The Atrinik Project +// SPDX-License-Identifier: MIT + +#![forbid(unsafe_code)] + +const TOOLKIT_COMPATIBILITY: &str = "content-toolkit-v1/0.1.0"; +const RENDERER_COMPATIBILITY: &str = "scene-bundle-v1/0.1.0"; + +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"), + TOOLKIT_COMPATIBILITY, + 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).expect("empty project state is available"); + println!("headless generation={}", state.generation()); + } + "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..b476d0b --- /dev/null +++ b/crates/atrinik-editor-document/src/lib.rs @@ -0,0 +1,69 @@ +// Copyright 2026 The Atrinik Project +// SPDX-License-Identifier: MIT + +#![forbid(unsafe_code)] + +use atrinik_source::{Document, Limits, Revision, SourceId}; +use std::{fmt, sync::Arc}; + +#[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..3de1ca7 --- /dev/null +++ b/crates/atrinik-editor-preview/src/lib.rs @@ -0,0 +1,59 @@ +// 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 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..8d40c05 --- /dev/null +++ b/crates/atrinik-editor-project/src/lib.rs @@ -0,0 +1,316 @@ +// 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(['\0', '\\']) + || value.starts_with('/') + || value.starts_with("//") + || value.as_bytes().get(1) == Some(&b':') + || value + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | "..")) + { + return Err(Error::InvalidPath); + } + Ok(Self(value.to_owned())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[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", + ] { + 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..77a8256 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,43 @@ +# 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 relative slash-separated paths only. Reject absolute, drive, UNC, + traversal, empty/dot segments, NUL, 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. Issue #4 integrates +the released toolkit publisher and OS-specific canonical/file-identity 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..6c1c98c --- /dev/null +++ b/tools/check-architecture.sh @@ -0,0 +1,24 @@ +#!/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-project","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 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..a520b98 --- /dev/null +++ b/tools/package-linux.sh @@ -0,0 +1,45 @@ +#!/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" +jq -e '(.components // []) | length >= 10' "${stage}/atrinik-editor-${version}/sbom.cdx.json" >/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..0b264bc --- /dev/null +++ b/tools/package-windows.ps1 @@ -0,0 +1,34 @@ +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" } + $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 From 3eaa01a3d76ced15ad9c239c6e041f2d08fa052f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 8 Aug 2026 15:13:26 +0000 Subject: [PATCH 2/6] fix(editor): harden dependency validation --- tools/check-architecture.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/check-architecture.sh b/tools/check-architecture.sh index 6c1c98c..d4573e0 100755 --- a/tools/check-architecture.sh +++ b/tools/check-architecture.sh @@ -19,6 +19,6 @@ jq -e ' (.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#")))) + ($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 From b3af6415cef1b1a88dd994711f4d5c5a0c744848 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 8 Aug 2026 15:16:15 +0000 Subject: [PATCH 3/6] fix(release): normalize editor package metadata --- tools/package-linux.sh | 11 ++++++++++- tools/package-windows.ps1 | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tools/package-linux.sh b/tools/package-linux.sh index a520b98..feff214 100755 --- a/tools/package-linux.sh +++ b/tools/package-linux.sh @@ -29,7 +29,16 @@ 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" -jq -e '(.components // []) | length >= 10' "${stage}/atrinik-editor-${version}/sbom.cdx.json" >/dev/null +sbom="${stage}/atrinik-editor-${version}/sbom.cdx.json" +normalized="${sbom}.normalized" +jq --arg version "${version}" ' + .serialNumber = "urn:uuid:4e951c04-6a5e-5db4-8c15-67c613215450" | + .metadata.timestamp = "1970-01-01T00:00:00Z" | + .metadata.component["bom-ref"] = ("atrinik-editor@" + $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, diff --git a/tools/package-windows.ps1 b/tools/package-windows.ps1 index 0b264bc..6e2254f 100644 --- a/tools/package-windows.ps1 +++ b/tools/package-windows.ps1 @@ -20,6 +20,13 @@ try { 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" } + $Sbom.serialNumber = "urn:uuid:4e951c04-6a5e-5db4-8c15-67c613215450" + $Sbom.metadata.timestamp = "1970-01-01T00:00:00Z" + $Sbom.metadata.component.'bom-ref' = "atrinik-editor@$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" } From 7ec534a3c4c654bed4ce5ed115ee2f5d5128ba0c Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 8 Aug 2026 15:20:48 +0000 Subject: [PATCH 4/6] fix(editor): reject portable path aliases --- crates/atrinik-editor-project/src/lib.rs | 37 ++++++++++++++++++++---- docs/THREAT_MODEL.md | 13 +++++---- tools/check-architecture.sh | 7 +++++ tools/package-linux.sh | 8 +++-- tools/package-windows.ps1 | 5 ++-- 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/crates/atrinik-editor-project/src/lib.rs b/crates/atrinik-editor-project/src/lib.rs index 8d40c05..4723afa 100644 --- a/crates/atrinik-editor-project/src/lib.rs +++ b/crates/atrinik-editor-project/src/lib.rs @@ -16,13 +16,11 @@ impl RelativePath { let value = value.as_ref(); if value.is_empty() || value.len() > MAX_PATH_BYTES - || value.contains(['\0', '\\']) + || value.contains([':', '\\']) + || value.chars().any(char::is_control) || value.starts_with('/') || value.starts_with("//") - || value.as_bytes().get(1) == Some(&b':') - || value - .split('/') - .any(|part| part.is_empty() || matches!(part, "." | "..")) + || value.split('/').any(|part| !portable_segment(part)) { return Err(Error::InvalidPath); } @@ -35,6 +33,21 @@ impl RelativePath { } } +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, @@ -258,7 +271,19 @@ mod tests { #[test] fn rejects_path_attacks_and_denied_outputs() { for path in [ - "", "../map", "/map", "C:/map", "maps//x", "maps/./x", "maps\\x", + "", + "../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)); } diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 77a8256..9cc4447 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -14,8 +14,10 @@ wrapper alone may provision or supervise a server playtest. ## Required controls -- Accept relative slash-separated paths only. Reject absolute, drive, UNC, - traversal, empty/dot segments, NUL, excessive length, and ambiguous case. +- 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 @@ -38,6 +40,7 @@ wrapper alone may provision or supervise a server playtest. `RelativePath`, `PathPolicy`, `FileStamp`, and `PathProbe` enforce lexical, class, canonical, type, identity, and revision checks with deterministic fakes. -They intentionally perform no real filesystem operation. Issue #4 integrates -the released toolkit publisher and OS-specific canonical/file-identity adapter; -issue #9 owns fault, race, symlink-swap, permission, disk-full, and crash tests. +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/tools/check-architecture.sh b/tools/check-architecture.sh index d4573e0..5d0b7f5 100755 --- a/tools/check-architecture.sh +++ b/tools/check-architecture.sh @@ -22,3 +22,10 @@ jq -e ' ($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/package-linux.sh b/tools/package-linux.sh index feff214..a781cb2 100755 --- a/tools/package-linux.sh +++ b/tools/package-linux.sh @@ -31,10 +31,12 @@ SYFT_CHECK_FOR_APP_UPDATE=false syft \ --output "cyclonedx-json=${stage}/atrinik-editor-${version}/sbom.cdx.json" sbom="${stage}/atrinik-editor-${version}/sbom.cdx.json" normalized="${sbom}.normalized" -jq --arg version "${version}" ' - .serialNumber = "urn:uuid:4e951c04-6a5e-5db4-8c15-67c613215450" | +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@" + $version) | + .metadata.component["bom-ref"] = ("atrinik-editor-linux-amd64@" + $version) | (.components[] | select(.type == "file") | .name) = "/atrinik-editor" ' "${sbom}" >"${normalized}" mv "${normalized}" "${sbom}" diff --git a/tools/package-windows.ps1 b/tools/package-windows.ps1 index 6e2254f..2b60acc 100644 --- a/tools/package-windows.ps1 +++ b/tools/package-windows.ps1 @@ -20,9 +20,10 @@ try { 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" } - $Sbom.serialNumber = "urn:uuid:4e951c04-6a5e-5db4-8c15-67c613215450" + $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@$Version" + $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" } } From 2d334625870d7f2ebb97fc2f42993df6a7d65e84 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 8 Aug 2026 15:21:49 +0000 Subject: [PATCH 5/6] fix(editor): compose released foundation adapters --- Cargo.lock | 4 +++ crates/atrinik-editor-app/Cargo.toml | 4 +++ crates/atrinik-editor-app/src/main.rs | 32 ++++++++++++++++++----- crates/atrinik-editor-document/src/lib.rs | 2 ++ crates/atrinik-editor-preview/src/lib.rs | 2 ++ tools/check-architecture.sh | 2 +- 6 files changed, 38 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a149494..2d8d19a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,7 +11,11 @@ source = "git+https://github.com/atrinik/content-toolkit?rev=b2178d442af5d897a45 name = "atrinik-editor" version = "0.1.0" dependencies = [ + "atrinik-editor-commands", + "atrinik-editor-document", + "atrinik-editor-preview", "atrinik-editor-project", + "atrinik-editor-ui", "sdl3", ] diff --git a/crates/atrinik-editor-app/Cargo.toml b/crates/atrinik-editor-app/Cargo.toml index 6f83834..7685f33 100644 --- a/crates/atrinik-editor-app/Cargo.toml +++ b/crates/atrinik-editor-app/Cargo.toml @@ -7,7 +7,11 @@ 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] diff --git a/crates/atrinik-editor-app/src/main.rs b/crates/atrinik-editor-app/src/main.rs index b948ccf..1e29347 100644 --- a/crates/atrinik-editor-app/src/main.rs +++ b/crates/atrinik-editor-app/src/main.rs @@ -3,9 +3,6 @@ #![forbid(unsafe_code)] -const TOOLKIT_COMPATIBILITY: &str = "content-toolkit-v1/0.1.0"; -const RENDERER_COMPATIBILITY: &str = "scene-bundle-v1/0.1.0"; - fn main() { let command = std::env::args() .nth(1) @@ -14,15 +11,36 @@ fn main() { "version" | "--version" => println!( "atrinik-editor {} toolkit={} renderer={}", env!("CARGO_PKG_VERSION"), - TOOLKIT_COMPATIBILITY, - RENDERER_COMPATIBILITY + 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).expect("empty project state is available"); - println!("headless generation={}", state.generation()); + 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)); diff --git a/crates/atrinik-editor-document/src/lib.rs b/crates/atrinik-editor-document/src/lib.rs index b476d0b..b5aa47d 100644 --- a/crates/atrinik-editor-document/src/lib.rs +++ b/crates/atrinik-editor-document/src/lib.rs @@ -6,6 +6,8 @@ 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, diff --git a/crates/atrinik-editor-preview/src/lib.rs b/crates/atrinik-editor-preview/src/lib.rs index 3de1ca7..cf2d5f1 100644 --- a/crates/atrinik-editor-preview/src/lib.rs +++ b/crates/atrinik-editor-preview/src/lib.rs @@ -8,6 +8,8 @@ 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, diff --git a/tools/check-architecture.sh b/tools/check-architecture.sh index 5d0b7f5..986d8f7 100755 --- a/tools/check-architecture.sh +++ b/tools/check-architecture.sh @@ -13,7 +13,7 @@ jq -e ' 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-project","sdl3"] 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 | From 1198d45d839027595787a7280d714bcbba40d345 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 8 Aug 2026 15:30:40 +0000 Subject: [PATCH 6/6] fix(release): identify publisher repository --- .github/workflows/package-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 31883e3..d528d8d 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -134,6 +134,7 @@ jobs: - 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