diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.gemini/GEMINI.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. diff --git a/.github/workflows/agents-policy.yml b/.github/workflows/agents-policy.yml new file mode 100644 index 0000000..c1a9734 --- /dev/null +++ b/.github/workflows/agents-policy.yml @@ -0,0 +1,19 @@ +name: agents policy + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: agents-policy-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + validate: + uses: zed-pkg/zed-monorepo/.github/workflows/agents-policy-reusable.yml@fb2417b1a976459e3de740f788916d3d91d3669e + with: + policy_ref: fb2417b1a976459e3de740f788916d3d91d3669e diff --git a/.github/workflows/certify-zed-package.yml b/.github/workflows/certify-zed-package.yml new file mode 100644 index 0000000..d9ea4dd --- /dev/null +++ b/.github/workflows/certify-zed-package.yml @@ -0,0 +1,221 @@ +name: certify-and-publish-zed-package + +on: + workflow_call: + pull_request: + paths: + - ".zpkg.toml" + - ".zpkg.lock" + - ".gitignore" + - ".github/workflows/certify-zed-package.yml" + push: + branches: [main] + paths: + - ".zpkg.toml" + - ".zpkg.lock" + - ".gitignore" + - ".github/workflows/certify-zed-package.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: certify-zed-package-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + uses: zed-pkg/zed-interfaces/.github/workflows/validate-zed-package.yml@933155fc41dc6443424d06b618137b23dc434835 + + publish: + name: Pack and publish certified artifacts + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Check out package + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + show-progress: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + + - name: Pack canonical and target packages + shell: bash + env: + ZED_ARTIFACT_OUTPUT: ${{ runner.temp }}/zed-package-artifacts + run: | + set -euo pipefail + packer="$RUNNER_TEMP/zed-artifact-publisher" + rm -rf "$packer" "$ZED_ARTIFACT_OUTPUT" + mkdir -p "$packer/src" "$ZED_ARTIFACT_OUTPUT" + + cat > "$packer/Cargo.toml" <<'TOML' + [package] + name = "zed-artifact-publisher" + version = "0.0.0" + edition = "2024" + publish = false + + [dependencies] + serde_json = "1" + zed-cli = { git = "https://github.com/zed-pkg/zed-cli.git", rev = "fd3b08eb1ac170518cb795e662318ae2714b1176" } + zed-interfaces = { git = "https://github.com/zed-pkg/zed-interfaces.git", rev = "a23cdd2ac509a39ca6fd6d21a3774fdd3a0f7660" } + TOML + + cat > "$packer/src/main.rs" <<'RUST' + use std::{env, fs, path::PathBuf, process}; + + use serde_json::json; + use zed_interfaces::Manifest; + + fn fail(message: impl std::fmt::Display) -> ! { + eprintln!("zed artifact publication failed: {message}"); + process::exit(1); + } + + fn main() { + let root = env::args_os() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| fail("missing repository root argument")); + let output = env::var_os("ZED_ARTIFACT_OUTPUT") + .map(PathBuf::from) + .unwrap_or_else(|| fail("ZED_ARTIFACT_OUTPUT is not set")); + fs::create_dir_all(&output) + .unwrap_or_else(|error| fail(format!("creating output directory: {error}"))); + + let manifest_text = fs::read_to_string(root.join(".zpkg.toml")) + .unwrap_or_else(|error| fail(format!("reading .zpkg.toml: {error}"))); + let manifest = Manifest::parse(&manifest_text) + .unwrap_or_else(|error| fail(format!("parsing .zpkg.toml: {error}"))); + + let packages = zed_cli::pack::pack_all(&root, &manifest, Some(&output)) + .unwrap_or_else(|error| fail(format!("packing repository: {error:#}"))); + if packages.is_empty() { + fail("packer emitted no artifacts"); + } + + let canonical_file = format!( + "{}-{}-{}.tar.gz", + manifest.package.org, manifest.package.name, manifest.package.version + ); + if !output.join(&canonical_file).is_file() { + fail(format!("canonical artifact `{canonical_file}` was not emitted")); + } + + let mut records = Vec::with_capacity(packages.len()); + for package in packages { + let file = package + .packed + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_else(|| fail("artifact has no UTF-8 file name")) + .to_string(); + if package.packed.size == 0 || package.packed.file_count == 0 { + fail(format!("artifact `{file}` is empty")); + } + if package.packed.sha256.len() != 64 + || !package + .packed + .sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + fail(format!("artifact `{file}` has an invalid sha256")); + } + + records.push(json!({ + "target": package.target, + "org": package.manifest.package.org, + "name": package.manifest.package.name, + "version": package.manifest.package.version, + "file": file, + "sha256": package.packed.sha256, + "size": package.packed.size, + "file_count": package.packed.file_count, + "excluded_count": package.packed.excluded_count, + "format": package.packed.format.extension(), + })); + } + + records.sort_by(|left, right| { + left["file"] + .as_str() + .cmp(&right["file"].as_str()) + }); + + let mut checksums = String::new(); + for record in &records { + checksums.push_str(record["sha256"].as_str().unwrap()); + checksums.push_str(" "); + checksums.push_str(record["file"].as_str().unwrap()); + checksums.push('\n'); + } + fs::write(output.join("SHA256SUMS"), checksums) + .unwrap_or_else(|error| fail(format!("writing SHA256SUMS: {error}"))); + + let provenance = json!({ + "schema": "zed.packages.v1", + "repository": env::var("GITHUB_REPOSITORY").unwrap_or_default(), + "commit": env::var("GITHUB_SHA").unwrap_or_default(), + "run_id": env::var("GITHUB_RUN_ID").unwrap_or_default(), + "run_attempt": env::var("GITHUB_RUN_ATTEMPT").unwrap_or_default(), + "package": { + "org": manifest.package.org, + "name": manifest.package.name, + "version": manifest.package.version, + }, + "artifacts": records, + }); + fs::write( + output.join("zed-artifacts.json"), + serde_json::to_vec_pretty(&provenance).unwrap(), + ) + .unwrap_or_else(|error| fail(format!("writing artifact manifest: {error}"))); + } + RUST + + cargo generate-lockfile --manifest-path "$packer/Cargo.toml" + cargo run --quiet --locked --manifest-path "$packer/Cargo.toml" -- "$GITHUB_WORKSPACE" + test -s "$ZED_ARTIFACT_OUTPUT/SHA256SUMS" + test -s "$ZED_ARTIFACT_OUTPUT/zed-artifacts.json" + (cd "$ZED_ARTIFACT_OUTPUT" && sha256sum --check SHA256SUMS) + find "$ZED_ARTIFACT_OUTPUT" -maxdepth 1 -type f -printf '%f\n' | sort + + - name: Publish certified package artifacts + id: publish + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: zed-packages-${{ github.event.repository.name }}-${{ github.sha }} + path: ${{ runner.temp }}/zed-package-artifacts + if-no-files-found: error + retention-days: 30 + compression-level: 0 + overwrite: false + + - name: Verify publication metadata + shell: bash + env: + ARTIFACT_ID: ${{ steps.publish.outputs.artifact-id }} + ARTIFACT_URL: ${{ steps.publish.outputs.artifact-url }} + ARTIFACT_DIGEST: ${{ steps.publish.outputs.artifact-digest }} + run: | + set -euo pipefail + test -n "$ARTIFACT_ID" + test -n "$ARTIFACT_URL" + test -n "$ARTIFACT_DIGEST" + { + echo "### Certified Zed package artifacts" + echo + echo "- Artifact ID: \`$ARTIFACT_ID\`" + echo "- Artifact digest: \`$ARTIFACT_DIGEST\`" + echo "- Download: $ARTIFACT_URL" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4825bdd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + show-progress: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + components: rustfmt,clippy + + - name: Format + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --locked --all-targets -- -D warnings + + - name: Test + run: cargo test --locked + + - name: Doctest + run: cargo test --locked --doc + + - name: Schemas are up to date + run: | + cargo run --locked --example generate_schemas + git diff --exit-code schemas/ \ + || (echo "schemas/ is stale; run 'cargo run --locked --example generate_schemas' and commit" && exit 1) diff --git a/.github/workflows/environment-lock-contract.yml b/.github/workflows/environment-lock-contract.yml new file mode 100644 index 0000000..b834075 --- /dev/null +++ b/.github/workflows/environment-lock-contract.yml @@ -0,0 +1,113 @@ +name: environment lock contract + +on: + push: + branches: + - agent/environment-lock-v1-current-main + - main + paths: + - Cargo.toml + - Cargo.lock + - src/environment_lock.rs + - src/lib.rs + - examples/generate_schemas.rs + - schemas/environment-lock-v1.json + - .github/workflows/environment-lock-contract.yml + pull_request: + branches: + - main + paths: + - Cargo.toml + - Cargo.lock + - src/environment_lock.rs + - src/lib.rs + - examples/generate_schemas.rs + - schemas/environment-lock-v1.json + - .github/workflows/environment-lock-contract.yml + +permissions: + contents: read + +concurrency: + group: environment-lock-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + rust-contract: + name: Rust contract / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - macos-15 + - windows-2025 + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + show-progress: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt,clippy + + - name: Verify formatting, tests, docs, and lint + run: | + cargo fmt --all -- --check + cargo test --locked environment_lock + cargo test --locked + cargo test --locked --doc + cargo clippy --locked --all-targets -- -D warnings + + generated-schema: + name: Generated schema is deterministic + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + show-progress: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + + - name: Regenerate twice and reject drift + run: | + set -euo pipefail + cargo run --locked --example generate_schemas + cp schemas/environment-lock-v1.json /tmp/environment-lock-v1.json + cargo run --locked --example generate_schemas + cmp /tmp/environment-lock-v1.json schemas/environment-lock-v1.json + git diff --exit-code -- \ + schemas/environment-plan-v1.json \ + schemas/environment-plan.json \ + schemas/environment-lock-v1.json + + gate: + name: All EnvironmentLock checks passed + if: always() + needs: + - rust-contract + - generated-schema + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Enforce aggregate result + env: + RUST_RESULT: ${{ needs.rust-contract.result }} + SCHEMA_RESULT: ${{ needs.generated-schema.result }} + run: | + set -euo pipefail + printf 'rust=%s schema=%s\n' "$RUST_RESULT" "$SCHEMA_RESULT" + test "$RUST_RESULT" = success + test "$SCHEMA_RESULT" = success diff --git a/.github/workflows/native-registry.yml b/.github/workflows/native-registry.yml new file mode 100644 index 0000000..38cc781 --- /dev/null +++ b/.github/workflows/native-registry.yml @@ -0,0 +1,87 @@ +name: native registry contract + +on: + pull_request: + paths: + - docs/native-registry-contract.md + - examples/generate_schemas.rs + - schemas/native-registry-adapter-record.json + - src/lib.rs + - src/native_registry.rs + - tests/native_registry_schema_contract.rs + - .github/workflows/native-registry.yml + push: + branches: [main] + paths: + - docs/native-registry-contract.md + - examples/generate_schemas.rs + - schemas/native-registry-adapter-record.json + - src/lib.rs + - src/native_registry.rs + - tests/native_registry_schema_contract.rs + - .github/workflows/native-registry.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: native-registry-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + contract: + name: native registry / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + steps: + - name: Check out exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Normalize generated-schema line endings + run: | + set -euo pipefail + git config core.autocrlf false + git config core.eol lf + git reset --hard HEAD + + - name: Install Rust quality components + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Validate native publication contract + run: | + set -euo pipefail + cargo fmt --all --check + cargo test --locked native_registry + cargo test --locked --test native_registry_schema_contract + cargo clippy --locked --lib --test native_registry_schema_contract -- -D warnings + + - name: Require deterministic generated schema + run: | + set -euo pipefail + cargo run --locked --example generate_schemas + git diff --exit-code -- schemas + + - name: Require a clean checkout + run: | + set -euo pipefail + git diff --exit-code + git status --porcelain=v1 --untracked-files=all | tee "$RUNNER_TEMP/native-registry-status.txt" + test ! -s "$RUNNER_TEMP/native-registry-status.txt" diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..93a336d --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,58 @@ +name: nix + +on: + push: + branches: [main] + paths: + - Cargo.toml + - Cargo.lock + - src/** + - examples/** + - schemas/** + - flake.nix + - flake.lock + - .nix/** + - agents.md + - README.md + - .github/workflows/nix.yml + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - src/** + - examples/** + - schemas/** + - flake.nix + - flake.lock + - .nix/** + - agents.md + - README.md + - .github/workflows/nix.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + agent-check: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + show-progress: false + + - name: Install Nix + uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Run pinned contract checks + run: nix develop --no-update-lock-file -c agent-check diff --git a/.github/workflows/validate-zed-package.yml b/.github/workflows/validate-zed-package.yml new file mode 100644 index 0000000..94a30bb --- /dev/null +++ b/.github/workflows/validate-zed-package.yml @@ -0,0 +1,375 @@ +name: validate-zed-package + +on: + workflow_call: + pull_request: + paths: + - ".zpkg.toml" + - ".zpkg.lock" + - ".gitignore" + - ".github/workflows/validate-zed-package.yml" + push: + branches: [main] + paths: + - ".zpkg.toml" + - ".zpkg.lock" + - ".gitignore" + - ".github/workflows/validate-zed-package.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: validate-zed-package-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate and pack canonical package contract + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Check out package + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + show-progress: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + + - name: Validate and pack with pinned Zed libraries + shell: bash + run: | + set -euo pipefail + validator="$RUNNER_TEMP/zed-package-validator" + mkdir -p "$validator/src" + cat > "$validator/Cargo.toml" <<'TOML' + [package] + name = "zed-package-validator" + version = "0.0.0" + edition = "2024" + publish = false + + [dependencies] + flate2 = "1" + tar = "0.4" + tempfile = "3" + zed-cli = { git = "https://github.com/zed-pkg/zed-cli.git", rev = "0c1931bcdc2065279aea06abe9d66a0eb9e03315" } + zed-interfaces = { git = "https://github.com/zed-pkg/zed-interfaces.git", rev = "c2e049006453c26ca8ca291783f681fce75cb01f" } + TOML + cat > "$validator/src/main.rs" <<'RUST' + use std::{ + collections::{BTreeMap, BTreeSet}, + env, fs, + path::{Path, PathBuf}, + process, + }; + + use flate2::read::GzDecoder; + use zed_interfaces::{Lockfile, Manifest}; + + fn fail(message: impl std::fmt::Display) -> ! { + eprintln!("zed-package validation failed: {message}"); + process::exit(1); + } + + fn archive_files(path: &Path) -> BTreeSet { + let file = fs::File::open(path) + .unwrap_or_else(|error| fail(format!("opening {}: {error}", path.display()))); + let mut archive = tar::Archive::new(GzDecoder::new(file)); + archive + .entries() + .unwrap_or_else(|error| { + fail(format!("reading archive entries from {}: {error}", path.display())) + }) + .map(|entry| { + let entry = entry.unwrap_or_else(|error| { + fail(format!("reading archive entry from {}: {error}", path.display())) + }); + entry + .path() + .unwrap_or_else(|error| { + fail(format!("reading archive path from {}: {error}", path.display())) + }) + .to_string_lossy() + .to_string() + }) + .collect() + } + + fn validate_artifact( + root_manifest: &Manifest, + package: &zed_cli::pack::PackagedTarget, + expected_name: &str, + output: &Path, + ) { + if package.manifest.package.org != root_manifest.package.org { + fail(format!( + "packed package `{expected_name}` changed org from `{}` to `{}`", + root_manifest.package.org, package.manifest.package.org + )); + } + if package.manifest.package.version != root_manifest.package.version { + fail(format!( + "packed package `{expected_name}` changed version from `{}` to `{}`", + root_manifest.package.version, package.manifest.package.version + )); + } + if package.manifest.package.name != expected_name { + fail(format!( + "packed package name `{}` does not match expected `{expected_name}`", + package.manifest.package.name + )); + } + + let expected_file = format!( + "{}-{}-{}.tar.gz", + root_manifest.package.org, expected_name, root_manifest.package.version + ); + let actual_file = package + .packed + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_else(|| fail("packed artifact has no UTF-8 file name")); + if actual_file != expected_file { + fail(format!( + "artifact `{actual_file}` does not match expected `{expected_file}`" + )); + } + if package.packed.path.parent() != Some(output) { + fail(format!( + "artifact {} escaped output directory {}", + package.packed.path.display(), + output.display() + )); + } + if package.packed.size == 0 || package.packed.file_count == 0 { + fail(format!("artifact `{actual_file}` is empty")); + } + if package.packed.sha256.len() != 64 + || !package + .packed + .sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + fail(format!("artifact `{actual_file}` has an invalid sha256")); + } + + let files = archive_files(&package.packed.path); + if !files.contains("pkg/.zpkg.toml") { + fail(format!("artifact `{actual_file}` is missing pkg/.zpkg.toml")); + } + for forbidden in [ + "pkg/.git/", + "pkg/.github/", + "pkg/.zed/pack/", + "pkg/.zpkg-staging/", + "pkg/zed_modules/", + ] { + if files.iter().any(|path| path.starts_with(forbidden)) { + fail(format!( + "artifact `{actual_file}` contains forbidden path prefix `{forbidden}`" + )); + } + } + } + + fn main() { + let root = env::args_os() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| fail("missing repository root argument")); + let manifest_text = fs::read_to_string(root.join(".zpkg.toml")) + .unwrap_or_else(|error| fail(format!("reading .zpkg.toml: {error}"))); + let lock_text = fs::read_to_string(root.join(".zpkg.lock")) + .unwrap_or_else(|error| fail(format!("reading .zpkg.lock: {error}"))); + + let manifest = Manifest::parse(&manifest_text) + .unwrap_or_else(|error| fail(format!("parsing .zpkg.toml: {error}"))); + let lock = Lockfile::parse(&lock_text) + .unwrap_or_else(|error| fail(format!("parsing .zpkg.lock: {error}"))); + + if lock.version != Lockfile::CURRENT_VERSION { + fail(format!( + "lock version {} is not current version {}", + lock.version, + Lockfile::CURRENT_VERSION + )); + } + + let repository = env::var("GITHUB_REPOSITORY") + .unwrap_or_else(|error| fail(format!("reading GITHUB_REPOSITORY: {error}"))); + let (_, expected_name) = repository + .split_once('/') + .unwrap_or_else(|| fail("GITHUB_REPOSITORY is not owner/name")); + if manifest.package.name != expected_name { + fail(format!( + "package name `{}` does not match repository `{expected_name}`", + manifest.package.name + )); + } + + let expected_url = format!("https://github.com/{repository}"); + let actual_url = manifest + .package + .repository + .url + .strip_suffix(".git") + .unwrap_or(&manifest.package.repository.url); + if !actual_url.eq_ignore_ascii_case(&expected_url) { + fail(format!( + "repository URL `{actual_url}` does not match `{expected_url}`" + )); + } + + for dependency in manifest.dependencies.keys() { + let (org, name) = dependency + .split_once('/') + .unwrap_or_else(|| fail(format!("invalid dependency key `{dependency}`"))); + if lock.find(org, name).is_none() { + fail(format!("direct dependency `{dependency}` is absent from .zpkg.lock")); + } + } + + let mut repository_targets = Vec::new(); + for (name, target) in &manifest.targets { + let path = root.join(&target.dir); + if !path.is_dir() { + fail(format!( + "target `{name}` points at missing or non-directory path `{}`", + target.dir + )); + } + if target.dir == "." { + repository_targets.push(name.as_str()); + } + } + + if !manifest.targets.is_empty() { + if repository_targets.len() != 1 { + fail(format!( + "targeted packages must declare exactly one canonical `[targets.repository]` with `dir = \".\"`; found {} root target(s)", + repository_targets.len() + )); + } + if repository_targets[0] != "repository" { + fail(format!( + "canonical root target must be named `repository`, found `{}`", + repository_targets[0] + )); + } + } + + let ignore = fs::read_to_string(root.join(".gitignore")) + .unwrap_or_else(|error| fail(format!("reading .gitignore: {error}"))); + for required in ["/zed_modules/", "/.zed/pack/"] { + if !ignore.lines().any(|line| line.trim() == required) { + fail(format!(".gitignore is missing `{required}`")); + } + } + + for required in [".env", ".env.*", ".zed/**"] { + if !manifest.publish.exclude.iter().any(|entry| entry == required) { + fail(format!("publish.exclude is missing `{required}`")); + } + } + + let output = tempfile::tempdir() + .unwrap_or_else(|error| fail(format!("creating artifact output: {error}"))); + let packages = zed_cli::pack::pack_all(&root, &manifest, Some(output.path())) + .unwrap_or_else(|error| fail(format!("packing repository: {error:#}"))); + + let mut expected_packages = BTreeMap::::new(); + if manifest.targets.is_empty() { + expected_packages.insert(String::new(), manifest.package.name.clone()); + } else { + for (target, derived_name) in manifest.target_package_names() { + let section = manifest.targets.get(&target).unwrap_or_else(|| { + fail(format!("target `{target}` disappeared after parsing")) + }); + let package_name = if section.dir == "." { + manifest.package.name.clone() + } else { + derived_name + }; + expected_packages.insert(target, package_name); + } + } + + if packages.len() != expected_packages.len() { + fail(format!( + "packing emitted {} package(s), expected {}", + packages.len(), + expected_packages.len() + )); + } + + let mut emitted_names = BTreeSet::new(); + for package in &packages { + let target = package.target.as_deref().unwrap_or(""); + let expected_package_name = expected_packages.remove(target).unwrap_or_else(|| { + fail(format!("packing emitted unexpected target `{target}`")) + }); + if !emitted_names.insert(expected_package_name.clone()) { + fail(format!( + "packing emitted duplicate package name `{expected_package_name}`" + )); + } + validate_artifact( + &manifest, + package, + &expected_package_name, + output.path(), + ); + } + if !expected_packages.is_empty() { + fail(format!( + "packing omitted target(s): {}", + expected_packages.keys().cloned().collect::>().join(", ") + )); + } + + println!( + "validated and packed {}/{}@{} into {} artifact(s), with {} canonical root target(s) and {} locked package(s)", + manifest.package.org, + manifest.package.name, + manifest.package.version, + packages.len(), + repository_targets.len(), + lock.packages.len() + ); + } + RUST + cargo generate-lockfile --manifest-path "$validator/Cargo.toml" + cargo run --quiet --locked --manifest-path "$validator/Cargo.toml" -- "$GITHUB_WORKSPACE" + + - name: Enforce read-only immutable workflow references + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import re + from pathlib import Path + + wrapper = Path(".github/workflows/zed-package.yml") + central = Path(".github/workflows/validate-zed-package.yml") + workflow = wrapper if wrapper.exists() else central + text = workflow.read_text() + assert "permissions:\n contents: read" in text, workflow + forbidden_trigger = "pull_request" + "_target:" + assert forbidden_trigger not in text, workflow + actions = re.findall(r"^\s*uses:\s*(\S+)\s*$", text, re.MULTILINE) + assert actions, workflow + for action in actions: + assert re.search(r"@[0-9a-f]{40}$", action), action + PY + + - name: Reject whitespace errors + run: git diff --check diff --git a/.gitignore b/.gitignore index 2a283ae..6572b33 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,13 @@ tmp temp tmp/worktrees temp/worktrees + +# --- carried over from the grafted branch rescue/orphan-762071627a6a-feat-publish-isolated-rust-dart-and-type --- +.cache/ +/.vendor/ +/.zed/pack/ +/target +/zed_modules/ +src/dart/.dart_tool/ +src/dart/build/ +src/dart/pubspec.lock diff --git a/.nix/agent-check.sh b/.nix/agent-check.sh new file mode 100644 index 0000000..e0c193f --- /dev/null +++ b/.nix/agent-check.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +export CI="${CI:-1}" +export NO_COLOR="${NO_COLOR:-1}" + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +cache_root="${NIX_AGENT_CACHE_ROOT:-$repo_root/.cache/nix-agent}" +export CARGO_HOME="${CARGO_HOME:-$cache_root/cargo}" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$cache_root/target}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$cache_root/xdg}" +mkdir -p "$CARGO_HOME" "$CARGO_TARGET_DIR" "$XDG_CACHE_HOME" + +run_stage() { + local stage="$1" + printf '\n==> agent-check stage: %s\n' "$stage" + + case "$stage" in + preflight) + git diff --check + nixfmt --check flake.nix .nix/dev-shell.nix + shellcheck .nix/agent-check.sh + shfmt -i 2 -ci -d .nix/agent-check.sh + actionlint .github/workflows/*.yml + nix flake check --no-update-lock-file --show-trace + ;; + format) + cargo fmt --check + ;; + lint) + # Nixpkgs currently pins Rust/Clippy 1.95, whose nonminimal_bool advice + # conflicts with the explicit, security-auditable path rejection list. + # Current stable CI still runs every lint with -D warnings; only this + # toolchain-version-specific style lint is exempted in the pinned shell. + cargo clippy --locked --all-targets -- \ + -D warnings \ + -A clippy::nonminimal_bool + ;; + test) + cargo test --locked + cargo test --locked --doc + ;; + schemas) + cargo run --locked --example generate_schemas + git diff --exit-code -- schemas/ + ;; + all) + local child + for child in preflight format lint test schemas; do + run_stage "$child" + done + ;; + *) + printf 'unknown agent-check stage: %s\n' "$stage" >&2 + return 64 + ;; + esac +} + +case "${1:-all}" in + all | preflight | format | lint | test | schemas) + run_stage "${1:-all}" + ;; + *) + printf 'usage: agent-check [all|preflight|format|lint|test|schemas]\n' >&2 + exit 64 + ;; +esac diff --git a/.nix/dev-shell.nix b/.nix/dev-shell.nix new file mode 100644 index 0000000..2eb0896 --- /dev/null +++ b/.nix/dev-shell.nix @@ -0,0 +1,20 @@ +{ + pkgs, + agentCheck, + toolchain, +}: +pkgs.mkShell { + packages = toolchain ++ [ agentCheck ]; + + LANG = if pkgs.stdenv.hostPlatform.isDarwin then "en_US.UTF-8" else "C.UTF-8"; + LC_ALL = if pkgs.stdenv.hostPlatform.isDarwin then "en_US.UTF-8" else "C.UTF-8"; + + shellHook = '' + export NIX_DEV_SHELL=zed-interfaces + export NIX_AGENT_CACHE_ROOT="''${NIX_AGENT_CACHE_ROOT:-$PWD/.cache/nix-agent}" + export CARGO_HOME="''${CARGO_HOME:-$NIX_AGENT_CACHE_ROOT/cargo}" + export CARGO_TARGET_DIR="''${CARGO_TARGET_DIR:-$NIX_AGENT_CACHE_ROOT/target}" + export XDG_CACHE_HOME="''${XDG_CACHE_HOME:-$NIX_AGENT_CACHE_ROOT/xdg}" + mkdir -p "$CARGO_HOME" "$CARGO_TARGET_DIR" "$XDG_CACHE_HOME" + ''; +} diff --git a/.openai/AGENTS.md b/.openai/AGENTS.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.openai/AGENTS.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. diff --git a/.zpkg.lock b/.zpkg.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/.zpkg.lock @@ -0,0 +1 @@ +version = 1 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d4c2c62 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,381 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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 = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[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 = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[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 = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zed-interfaces" +version = "0.1.0" +dependencies = [ + "hex", + "schemars", + "semver", + "serde", + "serde_json", + "sha2", + "thiserror", + "toml", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3521398 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +# Virtual workspace. The `zed-interfaces` crate itself lives in `src/rust/`, +# beside the generated `src/dart/` and `src/ts/` slices. +# +# The root stays a workspace (rather than the crate) for two reasons: +# * `.zpkg.toml` publishes each language slice as its own zed-package, and a +# target may not own `dir = "."`, so the Rust slice needs its own subtree. +# * `zed-interfaces = { git = "…/zed-interfaces" }` consumers keep resolving, +# because Cargo finds the package through this workspace's members. +# +# Sibling repos that used `{ path = "../zed-interfaces" }` must now point at +# `{ path = "../zed-interfaces/src/rust" }`. +[workspace] +resolver = "3" +members = ["src/rust"] +default-members = ["src/rust"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..613f14a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 zed-pkg contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..6983aed --- /dev/null +++ b/agents.md @@ -0,0 +1,46 @@ +# Agent instructions + +## Scope and hierarchy + +- These instructions apply to the whole `zed-pkg/zed-interfaces` repository unless a deeper lowercase `agents.md` adds narrower rules. +- Before editing, resolve the current working directory and load every readable ancestor `agents.md` from the filesystem root to the working directory. Do not search siblings. Resolve symlinks, deduplicate resolved files, and report unreadable or cyclic instruction files. +- `.claude/CLAUDE.md`, `.gemini/GEMINI.md`, and `.openai/AGENTS.md` are pointers only. Never duplicate instructions in tool-specific files. + +## Repository role + +This crate is the shared contract boundary for Zed manifests, lockfiles, registry models, language targets, paths, version requirements, synchronization, and VCS metadata. Changes here affect the CLI, API server, web server, sync engine, and generated clients. + +## Working rules + +- Preserve serialization compatibility and deterministic ordering unless an explicitly versioned migration says otherwise. +- Treat public Rust types, JSON schemas, TOML fields, lockfile formats, and path conventions as cross-repository APIs. +- Add round-trip and negative tests for every parser or schema change; do not weaken validation to make one consumer pass. +- Update interface consumers and monorepo pins in contract-first order when a change spans repositories. +- Keep the crate free of service-specific network, credential, database, and deployment policy. +- Never commit registry tokens, cloud credentials, generated secrets, or production environment files. +- Keep `Cargo.lock` and `flake.lock` committed; do not allow CI to update either lock implicitly. +- Pin GitHub Actions by immutable commit SHA and keep workflow permissions read-only unless a documented write is required. +- Resolve conflicts by preserving serialization compatibility, validation strength, generated-schema determinism, and consumer expectations rather than selecting an entire side. + +## Reproducible validation + +Use the pinned shell rather than mutable host toolchains: + +```sh +nix develop -c agent-check +``` + +Focused stages are available while iterating: + +```sh +nix develop -c agent-check format +nix develop -c agent-check lint +nix develop -c agent-check test +nix develop -c agent-check schemas +``` + +The default command runs Nix/workflow preflight, rustfmt, Clippy with warnings denied, unit tests, doctests, schema generation, and a clean-tree schema drift check. + +## Validation + +The pinned `agents policy` workflow validates this hierarchy and the three tool pointers. Run `nix develop -c agent-check` before requesting review. diff --git a/codegen/generate.mjs b/codegen/generate.mjs new file mode 100644 index 0000000..39e4210 --- /dev/null +++ b/codegen/generate.mjs @@ -0,0 +1,727 @@ +#!/usr/bin/env node +// JSON Schema -> front-end language slices for zed-interfaces. +// +// node codegen/generate.mjs # write src/dart/** and src/ts/** +// node codegen/generate.mjs --check # fail if anything is out of date (CI) +// +// Direction of truth, which is the opposite of most codegen setups: +// +// src/rust/*.rs --(cargo run --example generate_schemas)--> schemas/*.json +// schemas/*.json --(this script)--> src/dart/lib/*.dart, src/ts/*.ts +// +// So Rust is hand-written and Dart/TS are derived. Only the schemas marked +// front-end-facing in schemas/index.json are emitted: Dart and TS exist for +// browser and Flutter clients, and the toolchain formats (manifest, lockfile, +// environment plans) have no front-end consumer. Never hand-edit the output. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, ".."); +const schemaDir = path.join(root, "schemas"); +const outDir = path.join(root, "src"); + +const INDEX_FILE = "index.json"; +const KNOWN_TARGETS = ["dart", "ts"]; +const DART_PACKAGE = "zed_interfaces"; +const BANNER = + "GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand.\n" + + "Regenerate with `npm run codegen` after changing the Rust types."; + +export class GenError extends Error {} +const fail = (msg) => { + throw new GenError(msg); +}; + +// --- naming ------------------------------------------------------------------ + +const oneLine = (s) => String(s ?? "").replace(/\s+/g, " ").trim(); +// Splits on separators *and* camelCase humps, so `VersionScheme` becomes +// VERSION_SCHEME rather than VERSIONSCHEME. +const words = (s) => + String(s).replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean); +const pascal = (s) => words(s).map((w) => w[0].toUpperCase() + w.slice(1)).join(""); +const camel = (s) => { + const p = pascal(s); + return p ? p[0].toLowerCase() + p.slice(1) : p; +}; +const snake = (s) => words(s).join("_").toLowerCase(); + +// Reserved words that cannot be identifiers at all. +const DART_KEYWORDS = new Set([ + "abstract", "as", "assert", "async", "await", "break", "case", "catch", "class", "const", + "continue", "covariant", "default", "deferred", "do", "dynamic", "else", "enum", "export", + "extends", "extension", "external", "factory", "false", "final", "finally", "for", "function", + "get", "hide", "if", "implements", "import", "in", "interface", "is", "late", "library", "mixin", + "new", "null", "on", "operator", "part", "required", "rethrow", "return", "sealed", "set", + "show", "static", "super", "switch", "sync", "this", "throw", "true", "try", "typedef", "var", + "void", "when", "while", "with", "yield", +]); +// Members every Object already has, plus the ones this generator adds. A field +// named `hashCode` or `toJson` would not compile. +const DART_CLASS_MEMBERS = new Set([ + "hashCode", "runtimeType", "toString", "noSuchMethod", "fromJson", "toJson", +]); +// Enums carry more: `name`, `index` and `values` are real members there, so an +// enum value called `name` collides even though a *field* called `name` is fine. +const DART_ENUM_MEMBERS = new Set([ + ...DART_CLASS_MEMBERS, "index", "values", "name", "compareTo", "wire", +]); +const dartIdent = (raw, reserved = DART_CLASS_MEMBERS) => { + let id = camel(raw); + if (!id) fail(`cannot derive a Dart identifier from ${JSON.stringify(raw)}`); + if (/^[0-9]/.test(id)) id = `v${id}`; + return DART_KEYWORDS.has(id) || reserved.has(id) ? `${id}_` : id; +}; +const dartEnumIdent = (raw) => dartIdent(raw, DART_ENUM_MEMBERS); +// Dart's own lints prefer single quotes; `$` starts an interpolation, so it +// has to be escaped even inside a plain literal. +const dartStr = (value) => + `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\$/g, "\\$").replace(/\n/g, "\\n")}'`; +// TS keeps wire keys verbatim so a decoded response is the interface; only +// non-identifier keys need quoting. +const TS_IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const tsKey = (wire) => (TS_IDENT_RE.test(wire) ? wire : JSON.stringify(wire)); + +// Doc-comment escapers: a description can never break out of the comment. +const dartDoc = (s, indent = "") => + oneLine(s) + ? oneLine(s).replace(/\r?\n/g, " ").match(/.{1,88}(\s|$)/g).map((line) => `${indent}/// ${line.trim()}`).join("\n") + "\n" + : ""; +const tsDoc = (s, indent = "") => { + const text = oneLine(s).replace(/\*\//g, "*\\/"); + if (!text) return ""; + return `${indent}/** ${text} */\n`; +}; + +// --- schema -> IR ------------------------------------------------------------ + +const refName = (schema) => + schema && typeof schema.$ref === "string" ? schema.$ref.split("/").pop() : null; + +/** Split `["string","null"]` / `anyOf:[T,{type:"null"}]` into (type, nullable). */ +function splitNullable(schema, where) { + if (Array.isArray(schema.anyOf)) { + const variants = schema.anyOf; + const nonNull = variants.filter((v) => !(v && v.type === "null")); + if (nonNull.length !== variants.length - 1 || nonNull.length !== 1) { + fail(`${where}: only \`anyOf: [T, {"type":"null"}]\` is supported, got ${variants.length} variants`); + } + return { schema: { ...nonNull[0] }, nullable: true }; + } + if (Array.isArray(schema.type)) { + const rest = schema.type.filter((t) => t !== "null"); + if (rest.length !== 1) { + fail(`${where}: only one concrete type plus "null" is supported, got ${JSON.stringify(schema.type)}`); + } + return { schema: { ...schema, type: rest[0] }, nullable: schema.type.includes("null") }; + } + return { schema, nullable: false }; +} + +/** String enums arrive as `enum: [...]` or as a `oneOf` of `const` strings. */ +function enumValues(schema) { + if (schema.type === "string" && Array.isArray(schema.enum) && schema.enum.length) { + return schema.enum.map((wire) => ({ wire, description: "" })); + } + if (Array.isArray(schema.oneOf) && schema.oneOf.length) { + const values = schema.oneOf.map((v) => + v && v.type === "string" && typeof v.const === "string" + ? { wire: v.const, description: v.description || "" } + : null, + ); + if (values.every(Boolean)) return values; + } + return null; +} + +/** + * Resolve a property schema to a type reference. `emit` registers synthesized + * inline enums so a schema can never silently degrade to a bare string. + */ +function typeRef(schema, where, emit) { + if (schema === true || schema === undefined || schema === null) return { kind: "any", nullable: true }; + if (schema === false) fail(`${where}: \`false\` schemas have no inhabitants`); + if (typeof schema !== "object") fail(`${where}: expected a schema object`); + + const { schema: inner, nullable } = splitNullable(schema, where); + + const ref = refName(inner); + if (ref) return { kind: "ref", name: ref, nullable }; + + const values = enumValues(inner); + if (values) { + // Inline enum on a property: give it a deterministic name and hoist it. + const name = emit ? emit(values, inner.description || "") : null; + if (!name) fail(`${where}: inline enums are only supported on object properties`); + return { kind: "ref", name, nullable }; + } + + switch (inner.type) { + case "string": + return { kind: "string", nullable }; + case "integer": + return { kind: "int", nullable }; + case "number": + return { kind: "double", nullable }; + case "boolean": + return { kind: "bool", nullable }; + case "array": + return { kind: "list", item: typeRef(inner.items, `${where}[]`, null), nullable }; + case "object": { + if (inner.properties) fail(`${where}: inline object types are not supported — give it a $def`); + return { kind: "map", value: typeRef(inner.additionalProperties, `${where}{}`, null), nullable }; + } + case undefined: + return { kind: "any", nullable: true }; + default: + fail(`${where}: unsupported JSON Schema type ${JSON.stringify(inner.type)}`); + } +} + +/** One named type: a class or an enum. */ +function buildType(name, schema, sourceFile, sink) { + const values = enumValues(schema); + if (values) { + return { kind: "enum", name, description: schema.description || "", values, sourceFile }; + } + if (schema.type !== "object" && schema.properties === undefined) { + fail(`${sourceFile}:${name}: only object and string-enum types can be generated`); + } + const required = new Set(schema.required || []); + const props = Object.entries(schema.properties || {}).map(([wire, propSchema]) => { + const where = `${sourceFile}:${name}.${wire}`; + const emitInline = (values_, description) => { + const inlineName = `${name}${pascal(wire)}`; + sink.push({ kind: "enum", name: inlineName, description, values: values_, sourceFile }); + return inlineName; + }; + const type = typeRef(propSchema, where, emitInline); + const hasDefault = propSchema && typeof propSchema === "object" && "default" in propSchema; + return { + wire, + dart: dartIdent(wire), + type, + required: required.has(wire), + // A serde default means the field is optional on the wire but always + // meaningful, so Dart keeps it non-nullable and falls back to the default. + default: hasDefault ? propSchema.default : undefined, + hasDefault, + description: (propSchema && propSchema.description) || "", + }; + }); + return { kind: "object", name, description: schema.description || "", props, sourceFile }; +} + +// --- load + validate --------------------------------------------------------- + +export function loadIndex(dir = schemaDir) { + const indexPath = path.join(dir, INDEX_FILE); + if (!fs.existsSync(indexPath)) fail(`missing schemas/${INDEX_FILE}`); + let index; + try { + index = JSON.parse(fs.readFileSync(indexPath, "utf8")); + } catch (error) { + fail(`schemas/${INDEX_FILE} is not valid JSON: ${error.message}`); + } + if (!Array.isArray(index.schemas)) fail(`schemas/${INDEX_FILE} must have a \`schemas\` array`); + + const listed = new Map(); + for (const entry of index.schemas) { + if (!entry || typeof entry.file !== "string") fail(`schemas/${INDEX_FILE}: every entry needs a \`file\``); + if (listed.has(entry.file)) fail(`schemas/${INDEX_FILE}: ${entry.file} is listed twice`); + if (!Array.isArray(entry.targets)) fail(`schemas/${INDEX_FILE}: ${entry.file} needs a \`targets\` array`); + for (const target of entry.targets) { + if (!KNOWN_TARGETS.includes(target)) { + fail(`schemas/${INDEX_FILE}: ${entry.file} requests unknown target ${JSON.stringify(target)}`); + } + } + if (!fs.existsSync(path.join(dir, entry.file))) { + fail(`schemas/${INDEX_FILE} lists ${entry.file}, which does not exist`); + } + listed.set(entry.file, entry); + } + + // A new schema must be classified, not silently skipped: that is the whole + // point of the index, so an unlisted file is an error rather than a default. + const onDisk = fs.readdirSync(dir).filter((f) => f.endsWith(".json") && f !== INDEX_FILE); + const missing = onDisk.filter((f) => !listed.has(f)); + if (missing.length) { + fail( + `schemas/${INDEX_FILE} does not classify: ${missing.join(", ")}\n` + + ` add each file with "targets": ["dart","ts"] (front-end-facing) or "targets": [] (Rust-only)`, + ); + } + return [...listed.values()]; +} + +/** + * Build the emit plan: every generated type, assigned to a module. A type used + * by two schemas is hoisted into `common` so neither slice defines it twice. + */ +export function plan() { + const entries = loadIndex().filter((entry) => entry.targets.length > 0); + const modules = new Map(); // module name -> { title, types[] } + const byName = new Map(); // type name -> { type, modules:Set } + + for (const entry of entries) { + const moduleName = entry.file.replace(/\.json$/, ""); + let doc; + try { + doc = JSON.parse(fs.readFileSync(path.join(schemaDir, entry.file), "utf8")); + } catch (error) { + fail(`${entry.file} is not valid JSON: ${error.message}`); + } + if (typeof doc.title !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(doc.title)) { + fail(`${entry.file}: needs a PascalCase \`title\` (it names the generated type)`); + } + + const collected = []; + for (const [name, def] of Object.entries(doc.$defs || {})) { + collected.push(buildType(name, def, entry.file, collected)); + } + collected.push(buildType(doc.title, doc, entry.file, collected)); + + modules.set(moduleName, { file: entry.file, targets: entry.targets, types: [], root: doc.title }); + for (const type of collected) { + const seen = byName.get(type.name); + if (seen) { + // Same name from two schemas is fine when the shape matches (schemars + // re-emits a shared Rust type into each schema); a mismatch is a real + // contract bug and must not be papered over by picking one. + if (JSON.stringify(stable(type)) !== JSON.stringify(stable(seen.type))) { + fail( + `type \`${type.name}\` is defined differently in ${seen.type.sourceFile} and ${type.sourceFile}`, + ); + } + seen.modules.add(moduleName); + } else { + byName.set(type.name, { type, modules: new Set([moduleName]) }); + } + } + } + + const home = new Map(); // type name -> module that defines it + for (const [name, { type, modules: owners }] of byName) { + home.set(name, owners.size > 1 ? "common" : [...owners][0]); + void type; + } + if ([...home.values()].includes("common")) { + modules.set("common", { file: null, targets: KNOWN_TARGETS, types: [], root: null }); + } + for (const [name, { type }] of byName) modules.get(home.get(name)).types.push(type); + + for (const module of modules.values()) { + module.types.sort((a, b) => a.name.localeCompare(b.name)); + } + // Deterministic module order so the barrels never churn. + const ordered = [...modules.entries()].sort((a, b) => a[0].localeCompare(b[0])); + return { modules: new Map(ordered), home, byName }; +} + +const stable = (value) => { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) { + if (key === "sourceFile") continue; + out[key] = stable(value[key]); + } + return out; + } + return value; +}; + +// --- Dart emitter ------------------------------------------------------------ + +const dartFileName = (module) => `${snake(module)}.dart`; + +function dartType(ref, kinds) { + const q = ref.nullable ? "?" : ""; + switch (ref.kind) { + case "string": return `String${q}`; + case "int": return `int${q}`; + case "double": return `double${q}`; + case "bool": return `bool${q}`; + case "any": return "Object?"; + case "ref": return `${ref.name}${q}`; + case "list": return `List<${dartType({ ...ref.item, nullable: ref.item.nullable }, kinds)}>${q}`; + case "map": return `Map${q}`; + default: return fail(`unsupported type ${ref.kind}`); + } +} + +/** + * Expression decoding `expr` (a `dynamic`) into the Dart type of `ref`. + * `nullable` emits null-aware access rather than a `== null ? null : …` + * ternary, so the output reads like hand-written Dart. + */ +function dartDecode(ref, expr, kinds, nullable = false) { + const q = nullable ? "?" : ""; + switch (ref.kind) { + case "string": return `${expr} as String${q}`; + case "int": return `(${expr} as num${q})${q}.toInt()`; + case "double": return `(${expr} as num${q})${q}.toDouble()`; + case "bool": return `${expr} as bool${q}`; + case "any": return expr; + case "ref": + if (kinds.get(ref.name) === "enum") { + return nullable + ? `${ref.name}.maybeFromJson(${expr} as String?)` + : `${ref.name}.fromJson(${expr} as String)`; + } + return nullable + ? `${expr} == null ? null : ${ref.name}.fromJson(${expr} as Map)` + : `${ref.name}.fromJson(${expr} as Map)`; + case "list": + return `(${expr} as List${q})${q}.map((e) => ${dartDecode(ref.item, "e", kinds, ref.item.nullable)}).toList()`; + case "map": + return `(${expr} as Map${q})${q}.map((k, v) => MapEntry(k, ${dartDecode(ref.value, "v", kinds, ref.value.nullable)}))`; + default: return fail(`unsupported type ${ref.kind}`); + } +} + +/** Expression encoding the Dart field `name` back to JSON. */ +function dartEncode(ref, name, kinds) { + const q = ref.nullable ? "?" : ""; + switch (ref.kind) { + case "ref": + // Both classes and enums expose `toJson()`, so the call is the same. + return `${name}${q}.toJson()`; + case "list": { + const inner = dartEncode(ref.item, "e", kinds); + return inner === "e" ? name : `${name}${q}.map((e) => ${inner}).toList()`; + } + case "map": { + const inner = dartEncode(ref.value, "v", kinds); + return inner === "v" ? name : `${name}${q}.map((k, v) => MapEntry(k, ${inner}))`; + } + default: + return name; + } +} + +function dartLiteral(ref, value, kinds) { + if (value === null || value === undefined) return "null"; + if (ref.kind === "ref" && kinds.get(ref.name) === "enum") { + return `${ref.name}.${dartEnumIdent(String(value))}`; + } + if (ref.kind === "list") return "const []"; + if (ref.kind === "map") return "const {}"; + if (typeof value === "string") return dartStr(value); + if (typeof value === "boolean" || typeof value === "number") return String(value); + return fail(`unsupported default ${JSON.stringify(value)}`); +} + +function dartEnum(type) { + const members = type.values.map((v) => ({ ...v, dart: dartEnumIdent(v.wire) })); + const seen = new Set(); + for (const m of members) { + if (seen.has(m.dart)) fail(`enum ${type.name}: values ${m.wire} collide on Dart name ${m.dart}`); + seen.add(m.dart); + } + const body = members + .map((m, i) => `${dartDoc(m.description, " ")} ${m.dart}(${dartStr(m.wire)})${i === members.length - 1 ? ";" : ","}`) + .join("\n"); + return `${dartDoc(type.description)}enum ${type.name} { +${body} + + const ${type.name}(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static ${type.name} fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown ${type.name}: $value'), + ); + + static ${type.name}? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +}`; +} + +/** How a property is modelled in Dart: nullable unless required or defaulted. */ +function dartFieldRef(prop) { + if (prop.type.kind === "any") return { ...prop.type, nullable: true }; + // A non-required field with a serde default is still always meaningful — the + // default fills in — so it stays non-nullable. + const nullable = prop.type.nullable || (!prop.required && !prop.hasDefault); + return { ...prop.type, nullable }; +} + +function dartClass(type, kinds) { + const ctorParams = type.props + .map((p) => { + const ref = dartFieldRef(p); + if (!ref.nullable && p.hasDefault && !p.required) { + return ` this.${p.dart} = ${dartLiteral(p.type, p.default, kinds)},`; + } + return ref.nullable ? ` this.${p.dart},` : ` required this.${p.dart},`; + }) + .join("\n"); + + const decode = type.props + .map((p) => { + const ref = dartFieldRef(p); + const raw = `json[${dartStr(p.wire)}]`; + if (p.type.kind === "any") return ` ${p.dart}: ${raw},`; + if (!ref.nullable && p.hasDefault && !p.required) { + // Absent means "take the default", which is what the Rust side does. + return ` ${p.dart}: ${raw} == null\n ? ${dartLiteral(p.type, p.default, kinds)}\n : ${dartDecode(p.type, raw, kinds)},`; + } + return ` ${p.dart}: ${dartDecode(p.type, raw, kinds, ref.nullable)},`; + }) + .join("\n"); + + const fields = type.props + .map((p) => `${dartDoc(p.description, " ")} final ${dartType(dartFieldRef(p), kinds)} ${p.dart};`) + .join("\n\n"); + + // Every key is written, with an explicit null for absent optionals: serde + // decodes null into None, so the round-trip back into Rust is lossless. + const encode = type.props + .map((p) => ` ${dartStr(p.wire)}: ${dartEncode(dartFieldRef(p), p.dart, kinds)},`) + .join("\n"); + + return `${dartDoc(type.description)}class ${type.name} { + const ${type.name}({ +${ctorParams} + }); + + factory ${type.name}.fromJson(Map json) => ${type.name}( +${decode} + ); + +${fields} + + Map toJson() => { +${encode} + }; +}`; +} + +function emitDart(built) { + const { modules, home } = built; + const kinds = new Map([...built.byName].map(([name, v]) => [name, v.type.kind])); + const files = {}; + const libFiles = []; + + for (const [moduleName, module] of modules) { + if (!module.targets.includes("dart")) continue; + const imports = new Set(); + for (const type of module.types) { + const refs = type.kind === "object" ? type.props.map((p) => p.type) : []; + for (const ref of refs) collectRefs(ref).forEach((name) => { + const owner = home.get(name); + if (owner && owner !== moduleName) imports.add(dartFileName(owner)); + }); + } + const header = + BANNER.split("\n").map((line) => `// ${line}`).join("\n") + + (module.file ? `\n// Source: schemas/${module.file}` : "\n// Source: types shared by more than one schema") + + "\n"; + const importBlock = [...imports].sort().map((f) => `import '${f}';`).join("\n"); + const body = module.types + .map((type) => (type.kind === "enum" ? dartEnum(type) : dartClass(type, kinds))) + .join("\n\n"); + const file = dartFileName(moduleName); + files[`dart/lib/${file}`] = `${header}${importBlock ? `\n${importBlock}\n` : ""}\n${body}\n`; + libFiles.push(file); + } + + const barrel = + BANNER.split("\n").map((line) => `// ${line}`).join("\n") + + `\n\n/// Front-end contract types for the zed-pkg registry, generated from the\n` + + `/// JSON Schemas that \`zed-interfaces\` (Rust) is the source of truth for.\n` + + `library ${DART_PACKAGE};\n\n` + + libFiles.sort().map((f) => `export '${f}';`).join("\n") + + "\n"; + files[`dart/lib/${DART_PACKAGE}.dart`] = barrel; + return files; +} + +function collectRefs(ref, acc = []) { + if (!ref) return acc; + if (ref.kind === "ref") acc.push(ref.name); + if (ref.kind === "list") collectRefs(ref.item, acc); + if (ref.kind === "map") collectRefs(ref.value, acc); + return acc; +} + +// --- TypeScript emitter ------------------------------------------------------ + +function tsType(ref, kinds) { + const base = (() => { + switch (ref.kind) { + case "string": return "string"; + case "int": return "number"; + case "double": return "number"; + case "bool": return "boolean"; + case "any": return "unknown"; + case "ref": return ref.name; + case "list": return `readonly ${tsType(ref.item, kinds)}[]`; + case "map": return `Readonly>`; + default: return fail(`unsupported type ${ref.kind}`); + } + })(); + // `unknown` already admits null; `unknown | null` is the same type written + // twice, and tsc's own lint rules flag it. + return ref.nullable && ref.kind !== "any" ? `${base} | null` : base; +} + +function tsEnum(type) { + const union = type.values.map((v) => JSON.stringify(v.wire)).join(" | "); + const values = type.values.map((v) => JSON.stringify(v.wire)).join(", "); + const docLines = type.values + .filter((v) => v.description) + .map((v) => ` * - \`${v.wire}\`: ${oneLine(v.description).replace(/\*\//g, "*\\/")}`) + .join("\n"); + const doc = type.description || docLines + ? `/**\n${oneLine(type.description) ? ` * ${oneLine(type.description).replace(/\*\//g, "*\\/")}\n` : ""}${docLines ? `${docLines}\n` : ""} */\n` + : ""; + return `${doc}export type ${type.name} = ${union}; + +/** Every \`${type.name}\` value, in schema order — for validation and pickers. */ +export const ${snake(type.name).toUpperCase()}_VALUES = [${values}] as const;`; +} + +function tsInterface(type, kinds) { + const fields = type.props + .map((p) => { + const optional = !p.required; + return `${tsDoc(p.description, " ")} readonly ${tsKey(p.wire)}${optional ? "?" : ""}: ${tsType(p.type, kinds)};`; + }) + .join("\n"); + return `${tsDoc(type.description)}export interface ${type.name} { +${fields} +}`; +} + +function emitTs(built) { + const { modules, home } = built; + const kinds = new Map([...built.byName].map(([name, v]) => [name, v.type.kind])); + const files = {}; + const moduleFiles = []; + + for (const [moduleName, module] of modules) { + if (!module.targets.includes("ts")) continue; + const imports = new Map(); // module -> Set(names) + for (const type of module.types) { + if (type.kind !== "object") continue; + for (const prop of type.props) { + for (const name of collectRefs(prop.type)) { + const owner = home.get(name); + if (owner && owner !== moduleName) { + if (!imports.has(owner)) imports.set(owner, new Set()); + imports.get(owner).add(name); + } + } + } + } + const header = + BANNER.split("\n").map((line) => `// ${line}`).join("\n") + + (module.file ? `\n// Source: schemas/${module.file}` : "\n// Source: types shared by more than one schema") + + "\n"; + const importBlock = [...imports.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([owner, names]) => `import type { ${[...names].sort().join(", ")} } from "./${owner}";`) + .join("\n"); + const body = module.types + .map((type) => (type.kind === "enum" ? tsEnum(type) : tsInterface(type, kinds))) + .join("\n\n"); + files[`ts/${moduleName}.ts`] = `${header}${importBlock ? `\n${importBlock}\n` : ""}\n${body}\n`; + moduleFiles.push(moduleName); + } + + files["ts/index.ts"] = + BANNER.split("\n").map((line) => `// ${line}`).join("\n") + + "\n\n" + + moduleFiles.sort().map((m) => `export * from "./${m}";`).join("\n") + + "\n"; + return files; +} + +// --- build + write ----------------------------------------------------------- + +export function build() { + const built = plan(); + return { ...emitDart(built), ...emitTs(built) }; +} + +/** Exposed for codegen/generate.test.mjs — not part of any published surface. */ +export const internals = { + buildType, typeRef, emitDart, emitTs, dartIdent, dartEnumIdent, dartStr, snake, pascal, +}; + +/** Generated files that are no longer produced must not linger. */ +function staleFiles(expected) { + const stale = []; + for (const dir of ["dart/lib", "ts"]) { + const abs = path.join(outDir, dir); + if (!fs.existsSync(abs)) continue; + for (const name of fs.readdirSync(abs)) { + const rel = `${dir}/${name}`; + const full = path.join(abs, name); + if (fs.statSync(full).isDirectory()) continue; + if (!/\.(dart|ts)$/.test(name)) continue; + if (!(rel in expected)) stale.push(rel); + } + } + return stale; +} + +function main() { + const check = process.argv.includes("--check"); + let files; + try { + files = build(); + } catch (error) { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(2); + } + + let drift = 0; + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(outDir, rel); + if (check) { + const current = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null; + if (current !== content) { + console.error(`drift: src/${rel}`); + if (process.env.GITHUB_ACTIONS === "true") { + console.error(`::error file=src/${rel}::generated slice is out of date; run \`npm run codegen\``); + } + drift += 1; + } + } else { + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + console.log(`wrote src/${rel}`); + } + } + + for (const rel of staleFiles(files)) { + if (check) { + console.error(`stale: src/${rel} is no longer generated`); + drift += 1; + } else { + fs.rmSync(path.join(outDir, rel)); + console.log(`removed src/${rel}`); + } + } + + if (check && drift > 0) { + console.error(`${drift} file(s) out of date — run: npm run codegen`); + process.exit(1); + } + if (check) console.log(`generated slices up to date (${Object.keys(files).length} files)`); +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) main(); diff --git a/codegen/generate.test.mjs b/codegen/generate.test.mjs new file mode 100644 index 0000000..a12cddb --- /dev/null +++ b/codegen/generate.test.mjs @@ -0,0 +1,218 @@ +// Tests for the JSON Schema -> Dart/TS generator. +// +// node --test codegen/generate.test.mjs +// +// The generator is the only thing standing between a Rust type change and a +// front-end that silently decodes the wrong shape, so the cases below pin the +// decisions that are easy to get wrong: nullability, serde defaults, reserved +// identifiers, shared types, and the "nothing is skipped silently" rule. + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { GenError, build, internals, loadIndex, plan } from "./generate.mjs"; + +const { buildType, emitDart, emitTs, dartIdent, dartEnumIdent, dartStr, snake } = internals; + +/** Minimal single-module plan so the emitters can be exercised in isolation. */ +function planOf(name, schema, defs = {}) { + const collected = []; + for (const [defName, def] of Object.entries(defs)) collected.push(buildType(defName, def, "t.json", collected)); + collected.push(buildType(name, schema, "t.json", collected)); + const byName = new Map(collected.map((type) => [type.name, { type, modules: new Set(["t"]) }])); + const home = new Map([...byName.keys()].map((k) => [k, "t"])); + const modules = new Map([["t", { file: "t.json", targets: ["dart", "ts"], types: collected, root: name }]]); + return { modules, home, byName }; +} + +const dartOf = (...args) => emitDart(planOf(...args))["dart/lib/t.dart"]; +const tsOf = (...args) => emitTs(planOf(...args))["ts/t.ts"]; + +const OBJ = (properties, required = []) => ({ type: "object", properties, required }); + +test("required scalars are non-nullable and decoded directly", () => { + const dart = dartOf("Thing", OBJ({ org: { type: "string" } }, ["org"])); + assert.match(dart, /required this\.org,/); + assert.match(dart, /final String org;/); + assert.match(dart, /org: json\['org'\] as String,/); + assert.match(tsOf("Thing", OBJ({ org: { type: "string" } }, ["org"])), /readonly org: string;/); +}); + +test("a nullable type decodes null-aware rather than through a ternary", () => { + const dart = dartOf("Thing", OBJ({ latest: { type: ["string", "null"] } }, ["latest"])); + assert.match(dart, /final String\? latest;/); + assert.match(dart, /latest: json\['latest'\] as String\?,/); + assert.doesNotMatch(dart, /latest: json\['latest'\] == null/); +}); + +test("an optional field with no default is nullable in Dart and optional in TS", () => { + const schema = OBJ({ tags: { type: "array", items: { type: "string" } } }); + assert.match(dartOf("Thing", schema), /final List\? tags;/); + assert.match(tsOf("Thing", schema), /readonly tags\?: readonly string\[\];/); +}); + +test("a serde default keeps the Dart field non-nullable and fills the default in", () => { + const schema = OBJ({ yanked: { type: "boolean", default: false } }); + const dart = dartOf("Thing", schema); + assert.match(dart, /this\.yanked = false,/); + assert.match(dart, /final bool yanked;/); + assert.match(dart, /yanked: json\['yanked'\] == null/); + // TS models the wire, where the key really can be absent. + assert.match(tsOf("Thing", schema), /readonly yanked\?: boolean;/); +}); + +test("an enum $ref defaults to the matching Dart enum value", () => { + const dart = dartOf( + "Thing", + OBJ({ format: { $ref: "#/$defs/ArtifactFormat", default: "tar.gz" } }), + { ArtifactFormat: { type: "string", enum: ["tar.gz", "zip"] } }, + ); + assert.match(dart, /this\.format = ArtifactFormat\.tarGz,/); + assert.match(dart, /tarGz\('tar\.gz'\),/); + assert.match(dart, /format: json\['format'\] == null\n\s+\? ArtifactFormat\.tarGz/); +}); + +test("oneOf-of-const is an enum, and per-variant docs survive into both languages", () => { + const defs = { + AuditAction: { + oneOf: [ + { type: "string", const: "publish", description: "A version was published." }, + { type: "string", const: "org_claim", description: "The org was claimed." }, + ], + }, + }; + const dart = dartOf("Thing", OBJ({ action: { $ref: "#/$defs/AuditAction" } }, ["action"]), defs); + assert.match(dart, /\/\/\/ A version was published\.\n\s+publish\('publish'\),/); + assert.match(dart, /orgClaim\('org_claim'\);/); + const ts = tsOf("Thing", OBJ({ action: { $ref: "#/$defs/AuditAction" } }, ["action"]), defs); + assert.match(ts, /export type AuditAction = "publish" \| "org_claim";/); + assert.match(ts, /AUDIT_ACTION_VALUES = \["publish", "org_claim"\] as const;/); +}); + +test("a nullable enum ref uses maybeFromJson instead of an unchecked cast", () => { + const dart = dartOf( + "Thing", + OBJ({ kind: { anyOf: [{ $ref: "#/$defs/Op" }, { type: "null" }] } }, ["kind"]), + { Op: { type: "string", enum: ["upsert", "delete"] } }, + ); + assert.match(dart, /kind: Op\.maybeFromJson\(json\['kind'\] as String\?\),/); + assert.match(dart, /final Op\? kind;/); +}); + +test("lists of refs round-trip through fromJson/toJson", () => { + const dart = dartOf( + "Thing", + OBJ({ entries: { type: "array", items: { $ref: "#/$defs/Entry" } } }, ["entries"]), + { Entry: OBJ({ id: { type: "string" } }, ["id"]) }, + ); + assert.match(dart, /entries: \(json\['entries'\] as List\)\.map\(\(e\) => Entry\.fromJson\(e as Map\)\)\.toList\(\),/); + assert.match(dart, /'entries': entries\.map\(\(e\) => e\.toJson\(\)\)\.toList\(\),/); +}); + +test("maps become Map/Record of the value type", () => { + const schema = OBJ({ env: { type: "object", additionalProperties: { type: "string" } } }, ["env"]); + assert.match(dartOf("Thing", schema), /final Map env;/); + assert.match(tsOf("Thing", schema), /readonly env: Readonly>;/); +}); + +test("a `true` schema is opaque JSON, not a guessed type", () => { + const schema = OBJ({ row: true }, ["row"]); + assert.match(dartOf("Thing", schema), /final Object\? row;/); + assert.match(tsOf("Thing", schema), /readonly row: unknown;/); +}); + +test("`name` is a legal field but an illegal enum value, and only the latter is escaped", () => { + assert.equal(dartIdent("name"), "name"); + assert.equal(dartEnumIdent("name"), "name_"); + assert.equal(dartIdent("default"), "default_"); + assert.equal(dartIdent("hashCode"), "hashCode_"); + assert.equal(dartIdent("vcs_commit"), "vcsCommit"); + assert.equal(snake("VersionScheme"), "version_scheme"); +}); + +test("Dart string literals escape quotes and interpolation", () => { + assert.equal(dartStr("it's"), "'it\\'s'"); + assert.equal(dartStr("$value"), "'\\$value'"); +}); + +test("an unsupported construct fails loudly instead of emitting a wrong type", () => { + assert.throws(() => buildType("T", OBJ({ x: { type: ["string", "integer"] } }, ["x"]), "t.json", []), GenError); + assert.throws( + () => buildType("T", OBJ({ x: { anyOf: [{ type: "string" }, { type: "integer" }] } }, ["x"]), "t.json", []), + GenError, + ); + assert.throws( + () => buildType("T", OBJ({ x: { type: "object", properties: { y: { type: "string" } } } }, ["x"]), "t.json", []), + GenError, + ); +}); + +// --- the index is the demarcation, so its rules get their own coverage ------- + +function withSchemaDir(files, fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "zed-codegen-")); + try { + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), typeof content === "string" ? content : JSON.stringify(content)); + } + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test("a schema that is not classified in index.json is an error, not a silent skip", () => { + withSchemaDir( + { + "index.json": { schemas: [{ file: "a.json", targets: ["ts"] }] }, + "a.json": { title: "A", type: "object", properties: {} }, + "b.json": { title: "B", type: "object", properties: {} }, + }, + (dir) => { + assert.throws(() => loadIndex(dir), (error) => error instanceof GenError && /does not classify: b\.json/.test(error.message)); + }, + ); +}); + +test("index.json rejects unknown targets, missing files, and duplicates", () => { + withSchemaDir({ "index.json": { schemas: [{ file: "a.json", targets: ["swift"] }] }, "a.json": {} }, (dir) => + assert.throws(() => loadIndex(dir), /unknown target "swift"/)); + withSchemaDir({ "index.json": { schemas: [{ file: "gone.json", targets: [] }] } }, (dir) => + assert.throws(() => loadIndex(dir), /lists gone\.json, which does not exist/)); + withSchemaDir( + { "index.json": { schemas: [{ file: "a.json", targets: [] }, { file: "a.json", targets: [] }] }, "a.json": {} }, + (dir) => assert.throws(() => loadIndex(dir), /a\.json is listed twice/), + ); +}); + +// --- properties of the real repository --------------------------------------- + +test("every schema in the repository is classified", () => { + const entries = loadIndex(); + const files = fs.readdirSync(path.join(import.meta.dirname, "..", "schemas")).filter((f) => f.endsWith(".json") && f !== "index.json"); + assert.equal(entries.length, files.length); +}); + +test("Rust-only schemas emit nothing, front-end schemas emit both slices", () => { + const emitted = build(); + const frontEnd = loadIndex().filter((entry) => entry.targets.length); + for (const entry of frontEnd) { + const module = entry.file.replace(/\.json$/, ""); + assert.ok(`ts/${module}.ts` in emitted, `missing ts slice for ${entry.file}`); + assert.ok(`dart/lib/${snake(module)}.dart` in emitted, `missing dart slice for ${entry.file}`); + } + for (const entry of loadIndex().filter((e) => !e.targets.length)) { + const module = entry.file.replace(/\.json$/, ""); + assert.ok(!(`ts/${module}.ts` in emitted), `${entry.file} is Rust-only but emitted a TS slice`); + } +}); + +test("a type used by two schemas is defined once, in common", () => { + const { home } = plan(); + // PackageSummary is reachable from both the browse listing and search. + assert.equal(home.get("PackageSummary"), "common"); + assert.equal(home.get("AuditEntry"), "audit-log-response"); +}); diff --git a/docs/frozen-lock-integrity.md b/docs/frozen-lock-integrity.md new file mode 100644 index 0000000..229f649 --- /dev/null +++ b/docs/frozen-lock-integrity.md @@ -0,0 +1,66 @@ +# Frozen lock integrity contract + +A frozen Zed install treats every `[[package]]` entry in `.zpkg.lock` as an +immutable artifact identity. It must reject an incomplete or ambiguous entry +before creating a staging directory, downloading an archive, or changing the +active installation. + +## Required package fields + +Each locked package must contain all of the following: + +- non-empty `org`, `name`, and resolved `version`; +- a canonical 64-character lowercase hexadecimal `sha256` that is not the + all-zero digest; +- a nonzero artifact `size`; +- an explicit `format` (`tar.gz` or `zip`), never an inferred default; +- non-empty `vcs_tag` and an explicit immutable `vcs_commit`; +- a non-empty registry `source`. + +`vcs_commit` is named for compatibility with the existing wire format, but it +may identify an immutable revision from Git, Mercurial, Fossil, Pijul, or +another VCS. Moving names such as `HEAD`, `main`, `master`, `trunk`, `latest`, +`refs/heads/*`, and `heads/*` are invalid. + +The public Rust field remains `Option` so existing lock builders and +legacy registry responses can be migrated without an immediate source break. +Parsing and JSON Schema validation still require the value to be explicitly +present in every committed lockfile. + +`Lockfile::to_toml_string` is the one compatibility boundary: when an in-memory +package has no stronger source revision, the writer emits +`artifact-sha256:`. This is not a guessed Git commit. It is an explicit, +content-addressed revision that identifies the exact published archive bytes. +The writer derives it only from a canonical, nonzero artifact digest and leaves +the caller's in-memory structure unchanged. Empty, malformed, all-zero, or +mutable revisions still fail. + +This fallback supports packages published by older registries or through an +explicit VCS-check bypass while preserving the stronger invariant that every +serialized and frozen lock carries immutable provenance. Publishers with a +verified source revision continue to retain that exact revision unchanged. + +## Schema consumers + +`schemas/lockfile.json` places every package identity field—including +`format` and `vcs_commit`—in the object-level `required` array. API clients and +editors should validate against that checked-in schema instead of deriving +requiredness from the Rust `Option` representation. + +## Duplicate identity + +A lockfile may contain only one entry for an `org/name` identity. Resolution +must use `Lockfile::upsert` when replacing a package version; hand-authored +duplicate tables are rejected rather than resolved by order. + +## Migration + +Regenerate an older or incomplete lockfile with a non-frozen resolution command +that can retrieve the registry artifact metadata. The canonical writer may use +the exact artifact digest as the immutable revision only when no stronger +source revision exists. Do not repair a frozen lock by guessing an archive +format, size, checksum, tag, source, or revision. + +Consumers should validate with `Lockfile::parse` before beginning any install +transaction and should emit committed lockfiles only through +`Lockfile::to_toml_string`. diff --git a/docs/native-dependency-lock.md b/docs/native-dependency-lock.md new file mode 100644 index 0000000..de1b3d7 --- /dev/null +++ b/docs/native-dependency-lock.md @@ -0,0 +1,90 @@ +# Native dependency requirement and exact-lock contract + +`NativeDependencyLock` records how a dependency declaration from npm or Cargo +was translated into Zed's shared SemVer algebra and which exact immutable +artifact satisfied it. + +The source registry is part of the requirement identity. Identical text is not +assumed to have identical semantics: + +| Declaration | npm | Cargo | +| --- | --- | --- | +| `1.2.3` | exact `=1.2.3` | default caret `^1.2.3` | +| `1.2` | x-range `1.2.*` | default caret `^1.2` | +| `1` | x-range `1.*` | default caret `^1` | +| `^0.2.3` | caret | caret | +| `~1.2.3` | patch-compatible | patch-compatible | +| `>1` | `>=2.0.0` | Cargo comparison requirement | +| `<=1.2` | `<1.3.0` | Cargo comparison requirement | + +The original declaration remains in the record for auditability. The canonical +requirement is recomputed during validation, so editing it independently causes +a fail-closed drift error. + +## Frozen identity + +A requirement is not a frozen dependency. A valid v1 lock also requires: + +- one exact strict-SemVer package version; +- a native package name valid for the selected registry; +- lowercase, nonzero SHA-256 of the exact artifact bytes; +- a nonzero artifact size; and +- the artifact archive format. + +The resolved version must satisfy the recomputed canonical requirement. SemVer +build metadata is rejected for declarations, candidates, and exact lock +versions because it does not participate in precedence and cannot safely name +different artifacts. + +For npm identities, numeric components must also fit JavaScript's exact integer +range. Leading-zero partial components and values above +`Number.MAX_SAFE_INTEGER` are rejected rather than accepted by Rust and later +reinterpreted differently by npm tooling. + +## Supported npm subset + +Version 1 supports exact versions, partial/x-ranges, `x`/`X`/`*` wildcards, +caret and tilde ranges, explicit comparators, whitespace-separated comparator +intersections, and explicit prerelease requirements. A full bare version is +made explicitly exact. + +Whitespace between one comparator operator and its version is normalized, so +`>= 1.2.3 < 2.0.0` produces the same canonical intersection as +`>=1.2.3 <2.0.0`. Partial inequality comparators are desugared using npm's +boundaries: `>1` becomes `>=2.0.0`, `>1.2` becomes `>=1.3.0`, and `<=1.2` +becomes `<1.3.0`. + +Logical unions, hyphen ranges, dist-tags, aliases, workspace requirements, +local paths, Git sources, and URL sources are rejected rather than approximated +or silently converted to opaque strings. Cargo-style comma input is also +rejected for npm in strict v1. + +## Supported Cargo subset + +Version 1 supports Cargo's default-caret bare requirements, explicit caret, +tilde, exact and inequality comparators, `*` wildcards, comma-separated +comparator intersections, and explicit prerelease requirements. Whitespace +between one operator and its version is accepted, including +`>= 1.2, < 1.5`; multiple comparators still require commas. + +Npm-style `x` wildcards, whitespace-only comparator intersections, unions, +source protocols, and mutable or non-SemVer selectors are rejected. + +## Resolution boundary + +`NativeDependencyLock::resolve` validates every supplied candidate, rejects +duplicate exact versions, and selects the highest satisfying version independent +of input order. Registry policy such as filtering yanked releases remains the +caller's responsibility; the shared contract operates only on eligible +candidates. + +Frozen restore consumes the exact lock result. It does not ask npm, Cargo, Nix, +Flox, Devbox, mise, or asdf to reinterpret or re-resolve the declaration. + +## Relationship to publication records + +`NativeRegistryAdapterRecord` binds Zed publication identity to one native +publication family, including platform package selection and immutable archive +identity. `NativeDependencyLock` binds one native dependency declaration to one +exact member and artifact. They share registry and artifact types but remain +separate versioned contracts. diff --git a/docs/native-dependency-lockfile.md b/docs/native-dependency-lockfile.md new file mode 100644 index 0000000..58370e6 --- /dev/null +++ b/docs/native-dependency-lockfile.md @@ -0,0 +1,92 @@ +# Native dependency provenance in `.zpkg.lock` + +Lockfile version 1 may contain zero or more `[[native-dependency]]` tables. Each +table is a complete `NativeDependencyLock`: source registry, original native +requirement, deterministic canonical requirement, exact resolved version, and +immutable artifact identity. + +Existing lockfiles remain valid because the field is additive and defaults to +an empty list. + +## TOML shape + +```toml +version = 1 + +[[native-dependency]] +schema = "zed.native-dependency-lock/v1" + +[native-dependency.requirement] +registry = "npm" +declared = "^1.2.3" +canonical = "^1.2.3" + +[native-dependency.package] +name = "@fiducia/core" +version = "1.9.0" + +[native-dependency.artifact] +sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +size = 1024 +format = "tar.gz" +``` + +The lockfile owns exact restore identity. Nix, Flox, Devbox, mise, asdf, +container export, and future npm/Cargo adapters consume the frozen package +version and artifact hash; they do not reinterpret `declared` or rerun native +range resolution. + +## Identity and duplicates + +Native lock uniqueness is `(registry, package.name)`. Npm and Cargo entries with +the same textual name may coexist because their naming and requirement semantics +are independent. Two entries for the same registry and package name are rejected +while parsing or writing rather than resolved by array order. + +Protocol aliases and workspace, path, Git, or URL sources are not represented by +`NativeDependencyLock` v1 and cannot create hidden duplicate identities. + +## Validation + +`Lockfile::parse`, `Lockfile::to_toml_string`, and +`Lockfile::upsert_native_dependency` call `NativeDependencyLock::validate`. +That validation: + +- recomputes the source-aware canonical requirement; +- rejects requirement-receipt drift; +- checks that the exact resolved version satisfies the requirement; +- validates npm or Cargo package naming; +- rejects SemVer build metadata; and +- validates lowercase nonzero SHA-256, nonzero size, and archive format. + +The lockfile layer then rejects duplicate native keys. + +## Determinism + +Before serialization, the lockfile normalizes: + +1. ordinary Zed packages by `(org, name)`; +2. native dependencies by `(registry, package.name)`; and +3. Nix adapters by their existing package, direction, system, and output key. + +Insertion order therefore does not change emitted TOML. + +## Public helpers + +```rust +lockfile.find_native_dependency(NativeRegistry::Npm, "@fiducia/core"); +lockfile.upsert_native_dependency(exact_lock)?; +``` + +Upsert validates the new entry, replaces only the same native identity, and +restores deterministic ordering. A Cargo package with the same textual name is +not replaced by an npm entry. + +## Compatibility + +The serialized change is additive within lockfile version 1. The Rust `Lockfile` +struct gains a public `native_dependencies` vector, so downstream crates that +construct it with struct literals must add `native_dependencies: Vec::new()` or +prefer `Lockfile::default()` plus the public upsert methods. The independent +canary compiles the current downstream CLI against the exact interface commit to +make this source-level change explicit rather than assuming compatibility. diff --git a/docs/native-registry-contract.md b/docs/native-registry-contract.md new file mode 100644 index 0000000..db65126 --- /dev/null +++ b/docs/native-registry-contract.md @@ -0,0 +1,38 @@ +# Native registry publication contract + +`NativeRegistryAdapterRecord` binds one strict-SemVer Zed package target to the +exact immutable archives published to npm or a Cargo-compatible registry. + +The contract deliberately separates three identities: + +- **API compatibility:** `MAJOR.MINOR.PATCH[-prerelease]`; +- **platform:** explicit `os`, `arch`, and optional `libc` selectors; and +- **bytes:** lowercase SHA-256 plus archive size and format. + +SemVer build metadata is rejected at this boundary. It does not participate in +SemVer precedence, and Cargo registry indexes explicitly treat versions that +differ only in build metadata as one version. Architecture-specific artifacts +therefore use distinct package names or manager-native platform selectors while +sharing one strict version. + +A publication family may contain: + +- at most one portable package; +- at most one generic meta package, with at least one platform edge; and +- one package for each unique platform selector. + +When a meta package is present it must select every platform publication in the +record exactly once. Platform-only families remain valid when consumers select +native packages directly without a generic wrapper. + +Validation fails closed on version drift, duplicate package identities, +duplicate platforms, dangling or mismatched meta-package edges, malformed +native package names, zero-byte artifacts, uppercase or malformed digests, and +unsupported schema versions. `canonical_json_bytes()` validates first and then +sorts publications and platform edges for deterministic signing and lockfile +provenance. + +The contract is transport-neutral. It performs no npm, Cargo-registry, OCI, or +Zed-registry operation and consumes no credential. Independent certification in +`zed-pkg-test/zed-pkg-e2e` regenerates the schema, compiles an external consumer +offline, and pins the exact `zed-interfaces` product commit before promotion. diff --git a/docs/nix-export-plan.md b/docs/nix-export-plan.md new file mode 100644 index 0000000..5cf7551 --- /dev/null +++ b/docs/nix-export-plan.md @@ -0,0 +1,53 @@ +# `zed.nix-export-plan/v1` + +`NixExportPlan` is the execution-independent wire contract between Zed package +planning, standalone-flake generation, realization, provenance recording, and +the non-Rust clients generated from `schemas/nix-export-plan.json`. + +The plan contains only public, immutable evidence: + +- Zed organization, package, version, and optional polyglot target; +- package class (`data` or `prebuilt-bin`); +- resolved artifact-only Nix attribute, systems, and outputs; +- exact immutable artifact format, SHA-256, byte size, and canonical filename; +- SHA-256 of exact `.zpkg.toml` and `.zpkg.lock` bytes; +- sorted executable name/path mappings; +- an explicitly empty v1 dependency graph; and +- strict policy evidence. + +It has no fields for a registry URL, token, authentication endpoint, Supabase +key, workspace path, output path, temporary directory, username, hostname, +timestamp, cache key, command, or mutable version requirement. Unknown JSON +fields fail deserialization. + +## Canonicalization + +`NixExportPlan::canonical_json_bytes` clones and normalizes the plan before +validation and compact JSON serialization. It sorts systems, outputs, and the +reserved dependency vector. Maps use `BTreeMap`, so executable ordering is +stable. + +Validation requires: + +- schema exactly `zed.nix-export-plan/v1`; +- a valid public Zed package identity; +- artifact-only export mode; +- explicit valid systems and outputs; +- canonical artifact filename `--.`; +- lowercase 64-character SHA-256 values; +- a safe artifact-relative path for every executable; +- class/inventory agreement (`data` has no bins, `prebuilt-bin` has bins); +- no dependency edges in strict v1; and +- publishable `strict-v1` policy evidence. + +## Versioning boundary + +Adding optional evidence within the same semantics may be considered for a +minor-compatible reader update, but changing package classes, dependency +semantics, source-build behavior, path rules, policy requirements, or execution +meaning requires a new major schema such as `zed.nix-export-plan/v2`. Unknown +major versions fail closed. + +The plan is not a completed `zed.nix-adapter/v1` record. A completed adapter +record additionally binds a generated standalone-flake inventory and realized +Nix output evidence. Planning must not fabricate those later-stage values. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a94690d --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1782467914, + "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..8af92a7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,79 @@ +{ + description = "Agent-first development environment for the zed-pkg interface contract"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs, ... }: + let + systems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-darwin" + "x86_64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + pkgsFor = system: import nixpkgs { inherit system; }; + toolchainFor = + pkgs: with pkgs; [ + actionlint + bash + cargo + clippy + git + nix + nixfmt + rustc + rustfmt + shellcheck + shfmt + ]; + in + { + formatter = forAllSystems (system: (pkgsFor system).nixfmt); + + packages = forAllSystems ( + system: + let + pkgs = pkgsFor system; + agentCheck = pkgs.writeShellApplication { + name = "agent-check"; + runtimeInputs = toolchainFor pkgs; + text = builtins.readFile ./.nix/agent-check.sh; + }; + in + { + inherit agentCheck; + default = agentCheck; + } + ); + + apps = forAllSystems (system: { + agent-check = { + type = "app"; + program = "${self.packages.${system}.agentCheck}/bin/agent-check"; + }; + default = self.apps.${system}.agent-check; + }); + + checks = forAllSystems (system: { + agentCheck = self.packages.${system}.agentCheck; + }); + + devShells = forAllSystems ( + system: + let + pkgs = pkgsFor system; + in + { + default = import ./.nix/dev-shell.nix { + inherit pkgs; + agentCheck = self.packages.${system}.agentCheck; + toolchain = toolchainFor pkgs; + }; + } + ); + }; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..643a49a --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "@zed-pkg/zed-interfaces-codegen", + "version": "0.1.0", + "private": true, + "description": "Repository tooling for zed-interfaces: turns the front-end-facing JSON Schemas into the src/dart and src/ts slices. The publishable TypeScript package is src/ts, not this one.", + "type": "module", + "scripts": { + "codegen": "node codegen/generate.mjs", + "codegen:check": "node codegen/generate.mjs --check", + "test": "node --test codegen/*.test.mjs && node codegen/generate.mjs --check" + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..e140726 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +# Pin the toolchain so CI cache keys stay stable and every machine builds the +# same way (zed-docs issue #8). +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/schemas/api-error.json b/schemas/api-error.json new file mode 100644 index 0000000..0c8f479 --- /dev/null +++ b/schemas/api-error.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ApiError", + "description": "Error body returned with any non-2xx status.", + "type": "object", + "properties": { + "code": { + "description": "Stable machine-readable code, e.g. `not_found`, `sha256_mismatch`,\n`tag_not_found`, `unauthorized`, `org_taken`.", + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] +} diff --git a/schemas/audit-integrity-response.json b/schemas/audit-integrity-response.json new file mode 100644 index 0000000..63a7205 --- /dev/null +++ b/schemas/audit-integrity-response.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AuditIntegrityResponse", + "description": "The result of walking an org's audit chain end to end.", + "type": "object", + "properties": { + "entries_checked": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "first_bad_seq": { + "description": "The `seq` where verification first failed, if any.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "head_hash": { + "description": "The newest entry's hash — an anchor an operator can record externally\nso that later truncation of the whole tail is also detectable.", + "type": [ + "string", + "null" + ] + }, + "intact": { + "description": "True only when every entry's hash recomputes and every link matches.", + "type": "boolean" + }, + "org": { + "type": "string" + }, + "problem": { + "description": "Machine-readable failure kind: `hash_mismatch` (an entry was edited),\n`broken_link` (an entry's `prev_hash` does not match its predecessor),\nor `sequence_gap` (an entry was deleted).", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "org", + "intact", + "entries_checked" + ] +} diff --git a/schemas/audit-log-response.json b/schemas/audit-log-response.json new file mode 100644 index 0000000..fc01664 --- /dev/null +++ b/schemas/audit-log-response.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AuditLogResponse", + "type": "object", + "properties": { + "entries": { + "description": "Most recent first.", + "type": "array", + "items": { + "$ref": "#/$defs/AuditEntry" + } + }, + "org": { + "type": "string" + } + }, + "required": [ + "org", + "entries" + ], + "$defs": { + "AuditAction": { + "description": "A state-changing action recorded in an org's audit log. Reads are never\naudited — only mutations of published state and of the namespace itself, so\nthe log answers \"who changed what\" without drowning in traffic.", + "oneOf": [ + { + "description": "A version was published.", + "type": "string", + "const": "publish" + }, + { + "description": "A version was yanked (hidden from fresh resolution).", + "type": "string", + "const": "yank" + }, + { + "description": "A previously yanked version was restored.", + "type": "string", + "const": "unyank" + }, + { + "description": "The org namespace was claimed.", + "type": "string", + "const": "org_claim" + } + ] + }, + "AuditEntry": { + "description": "One audit-log record. The actor is identified by the *token* that acted —\nits name and role, never its secret — which is the identity a registry\nactually has (zed-docs issue #7 governance).", + "type": "object", + "properties": { + "action": { + "description": "Raw action string; `action_kind` is the parsed form when recognized.", + "type": "string" + }, + "action_kind": { + "description": "Parsed action, absent when this server build doesn't recognize it.", + "anyOf": [ + { + "$ref": "#/$defs/AuditAction" + }, + { + "type": "null" + } + ] + }, + "actor_role": { + "description": "The acting token's role (`owner`/`publisher`/`reader`, or `admin` for\nunscoped tokens).", + "type": "string" + }, + "actor_token_name": { + "description": "Human-readable name of the token that acted.", + "type": "string" + }, + "at": { + "description": "RFC 3339 timestamp of the action.", + "type": "string" + }, + "detail": { + "description": "Extra context, e.g. the artifact sha256 for a publish.", + "type": [ + "string", + "null" + ] + }, + "entry_hash": { + "description": "`sha256(audit_chain_preimage(..))` for this entry, lowercase hex. Empty\nfrom a pre-chain server.", + "type": "string" + }, + "prev_hash": { + "description": "The previous entry's `entry_hash`; `None` for the first entry in an\norg's chain. Linking each entry to its predecessor is what makes a\nsilent deletion or edit detectable.", + "type": [ + "string", + "null" + ] + }, + "seq": { + "description": "Position in the org's append-only chain, starting at 1. Gaps mean\nentries were deleted. Defaults to 0 when read from a server that\npredates the chain.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }, + "subject": { + "description": "What was acted on, e.g. `acme/http-kit@1.2.0` or the org slug.", + "type": "string" + } + }, + "required": [ + "at", + "action", + "subject", + "actor_token_name", + "actor_role" + ] + } + } +} diff --git a/schemas/claim-org-request.json b/schemas/claim-org-request.json new file mode 100644 index 0000000..cf6c1d6 --- /dev/null +++ b/schemas/claim-org-request.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ClaimOrgRequest", + "type": "object", + "properties": { + "slug": { + "type": "string" + } + }, + "required": [ + "slug" + ] +} diff --git a/schemas/claim-org-response.json b/schemas/claim-org-response.json new file mode 100644 index 0000000..a96c2b9 --- /dev/null +++ b/schemas/claim-org-response.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ClaimOrgResponse", + "type": "object", + "properties": { + "created": { + "description": "False when the caller already owned the org.", + "type": "boolean" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "slug", + "created" + ] +} diff --git a/schemas/embedding-upsert-request.json b/schemas/embedding-upsert-request.json new file mode 100644 index 0000000..f6c51c2 --- /dev/null +++ b/schemas/embedding-upsert-request.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EmbeddingUpsertRequest", + "description": "Body of `PUT /v1/packages/{org}/{name}/embedding` — upsert a package's\nembedding for one model (re-embedding replaces in place).", + "type": "object", + "properties": { + "content": { + "description": "The text that was embedded (kept for provenance / re-embedding).", + "type": "string" + }, + "embedding": { + "description": "The embedding (native width; padded to 2050 server-side).", + "type": "array", + "items": { + "type": "number", + "format": "float" + } + }, + "model": { + "type": "string" + } + }, + "required": [ + "model", + "embedding", + "content" + ] +} diff --git a/schemas/environment-lock-v1.json b/schemas/environment-lock-v1.json new file mode 100644 index 0000000..9cf3c14 --- /dev/null +++ b/schemas/environment-lock-v1.json @@ -0,0 +1,326 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EnvironmentLock", + "description": "Exact resolved state for all tools in one development environment.", + "type": "object", + "properties": { + "extensions": { + "description": "Lossless future/backend fields. Unknown state must never disappear\nmerely because an older client rewrote a lock.", + "type": "object", + "additionalProperties": true + }, + "plan_digest_sha256": { + "description": "SHA-256 of the normalized environment plan that produced this lock.", + "type": "string" + }, + "schema_version": { + "type": "integer", + "format": "uint32", + "default": 1, + "minimum": 0 + }, + "tools": { + "description": "Logical tool name to one or more exact version/platform variants.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/$defs/LockedTool" + } + } + } + }, + "required": [ + "plan_digest_sha256" + ], + "$defs": { + "LockedArtifact": { + "description": "Archive/blob identity downloaded into the content-addressed store.", + "type": "object", + "properties": { + "extensions": { + "type": "object", + "additionalProperties": true + }, + "format": { + "$ref": "#/$defs/LockedArtifactFormat" + }, + "mirrors": { + "description": "Alternate immutable download locations. Order is not semantic.", + "type": "array", + "items": { + "type": "string" + } + }, + "sha256": { + "description": "Exact lowercase hexadecimal SHA-256, without a `sha256:` prefix.", + "type": "string" + }, + "signatures": { + "description": "Signature, transparency-log, or attestation identities. Verification\npolicy remains a caller concern; the lock records what was verified.", + "type": "array", + "items": { + "$ref": "#/$defs/LockedSignature" + } + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size", + "format" + ] + }, + "LockedArtifactFormat": { + "type": "string", + "enum": [ + "tar", + "tar_gz", + "tar_xz", + "tar_zstd", + "zip", + "raw", + "directory" + ] + }, + "LockedExecutable": { + "type": "object", + "properties": { + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "path": { + "description": "Relative to [`LockedInstall::root`].", + "type": "string" + } + }, + "required": [ + "name", + "path" + ] + }, + "LockedInstall": { + "description": "Verified install layout within an extracted artifact/store entry.", + "type": "object", + "properties": { + "bin_dirs": { + "description": "Relative PATH directories below `root`. Order is not semantic here;\nactivation precedence is defined by the environment plan/runtime.", + "type": "array", + "items": { + "type": "string" + } + }, + "executables": { + "description": "Executables and aliases exposed by this variant.", + "type": "array", + "items": { + "$ref": "#/$defs/LockedExecutable" + } + }, + "extensions": { + "type": "object", + "additionalProperties": true + }, + "layout_digest_sha256": { + "description": "Digest of normalized layout metadata when a backend has additional\ndeterministic install-layout decisions.", + "type": [ + "string", + "null" + ] + }, + "root": { + "description": "Relative root selected from the extracted artifact. `.` means the\nartifact root.", + "type": "string" + } + }, + "required": [ + "root" + ] + }, + "LockedPlatform": { + "description": "Exact target identity for one locked variant.", + "type": "object", + "properties": { + "abi": { + "type": [ + "string", + "null" + ] + }, + "arch": { + "type": [ + "string", + "null" + ] + }, + "libc": { + "type": [ + "string", + "null" + ] + }, + "os": { + "type": [ + "string", + "null" + ] + }, + "target": { + "description": "Canonical target triple or backend target identifier.", + "type": "string" + } + }, + "required": [ + "target" + ] + }, + "LockedSignature": { + "type": "object", + "properties": { + "identity": { + "description": "Key, certificate, transparency-log, or issuer/subject identity.", + "type": "string" + }, + "kind": { + "description": "Signature system (`cosign`, `minisign`, `gpg`, `sigstore-bundle`, ...).", + "type": "string" + }, + "sha256": { + "description": "Optional SHA-256 of detached signature or attestation bytes.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "identity" + ] + }, + "LockedSource": { + "description": "Exact source identity used before artifact verification.", + "type": "object", + "properties": { + "extensions": { + "type": "object", + "additionalProperties": true + }, + "immutable": { + "description": "True when backend semantics guarantee that `revision` cannot move.", + "type": "boolean", + "default": false + }, + "kind": { + "$ref": "#/$defs/LockedSourceKind" + }, + "locator": { + "description": "Registry coordinates, repository URL, HTTP URL, OCI reference, or a\nproject-relative path.", + "type": "string" + }, + "portable": { + "description": "A relative path source may be marked portable only when its complete\ntree identity is locked and intended to travel with the project.", + "type": "boolean", + "default": false + }, + "revision": { + "description": "Exact package revision, VCS object, OCI digest, or backend identity.", + "type": [ + "string", + "null" + ] + }, + "tree_sha256": { + "description": "SHA-256 of a local directory tree for `path` sources.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "locator" + ] + }, + "LockedSourceKind": { + "description": "Source category for an exact tool artifact.", + "type": "string", + "enum": [ + "registry", + "vcs", + "http", + "oci", + "path", + "other" + ] + }, + "LockedTool": { + "description": "One exact backend/version/platform selection for a logical tool.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/LockedArtifact" + }, + "backend": { + "description": "Backend/provider identity (`core`, `aqua`, `github`, `npm`, `cargo`,\n`ubi`, `asdf`, `vfox`, `http`, `zed`, ...).", + "type": "string" + }, + "backend_options_digest_sha256": { + "description": "Digest of normalized backend options, excluding credentials/secrets.", + "type": [ + "string", + "null" + ] + }, + "backend_version": { + "description": "Exact backend/plugin implementation version when backend behavior can\naffect resolution or installation.", + "type": [ + "string", + "null" + ] + }, + "extensions": { + "type": "object", + "additionalProperties": true + }, + "install": { + "$ref": "#/$defs/LockedInstall" + }, + "platform": { + "$ref": "#/$defs/LockedPlatform" + }, + "requirement": { + "description": "Original human-authored requirement retained for diagnostics.", + "type": "string" + }, + "resolved": { + "description": "Exact, non-moving version selected by the backend.", + "type": "string" + }, + "source": { + "$ref": "#/$defs/LockedSource" + } + }, + "required": [ + "requirement", + "resolved", + "backend", + "source", + "artifact", + "platform", + "install" + ] + } + } +} diff --git a/schemas/environment-plan-v1.json b/schemas/environment-plan-v1.json new file mode 100644 index 0000000..d28e096 --- /dev/null +++ b/schemas/environment-plan-v1.json @@ -0,0 +1,267 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EnvironmentPlan", + "description": "Manager-neutral desired and resolved developer-environment state.", + "type": "object", + "properties": { + "activation": { + "$ref": "#/$defs/ActivationPolicy", + "default": "none" + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "schema": { + "type": "integer", + "format": "uint32", + "default": 1, + "minimum": 0 + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/EnvironmentSource" + } + }, + "system-packages": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SystemPackageRequirement" + } + }, + "tools": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ToolRequirement" + } + } + }, + "$defs": { + "ActivationPolicy": { + "description": "The only activation behavior a Zed environment adapter may add.", + "type": "string", + "enum": [ + "none", + "frozen-install" + ] + }, + "Checksum": { + "description": "One lowercase hexadecimal content checksum in canonical output.", + "type": "object", + "properties": { + "algorithm": { + "$ref": "#/$defs/ChecksumAlgorithm" + }, + "value": { + "type": "string" + } + }, + "required": [ + "algorithm", + "value" + ] + }, + "ChecksumAlgorithm": { + "description": "Supported checksum algorithms in environment provenance.", + "type": "string", + "enum": [ + "sha256", + "sha512", + "blake3" + ] + }, + "EnvironmentManager": { + "description": "Environment managers with a first-class adapter contract.", + "oneOf": [ + { + "type": "string", + "enum": [ + "mise", + "asdf", + "devbox", + "flox" + ] + }, + { + "description": "Development-shell provenance only. Nix package/derivation import and\nexport is a separate interoperability boundary.", + "type": "string", + "const": "nix" + } + ] + }, + "EnvironmentSource": { + "description": "Provenance for one manager-native input and optional lock file.", + "type": "object", + "properties": { + "digest": { + "description": "Digest of the normalized manager-native lock/input state.", + "anyOf": [ + { + "$ref": "#/$defs/Checksum" + }, + { + "type": "null" + } + ] + }, + "lock_path": { + "type": [ + "string", + "null" + ] + }, + "manager": { + "$ref": "#/$defs/EnvironmentManager" + }, + "path": { + "description": "Project-relative manager input path.", + "type": "string" + } + }, + "required": [ + "manager", + "path" + ] + }, + "ImmutableSource": { + "description": "A source whose immutable revision is part of environment identity.", + "type": "object", + "properties": { + "checksums": { + "type": "array", + "items": { + "$ref": "#/$defs/Checksum" + } + }, + "revision": { + "description": "A full immutable commit or content digest. Moving tags and branches are\nrejected in frozen validation.", + "type": "string" + }, + "subdir": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "url", + "revision" + ] + }, + "SystemPackageRequirement": { + "description": "Desired and resolved identity for one system package supplied by an\nenvironment manager rather than by the Zed dependency graph.", + "type": "object", + "properties": { + "checksums": { + "type": "array", + "items": { + "$ref": "#/$defs/Checksum" + } + }, + "package_ref": { + "description": "Exact provider-native attribute/reference when it differs from the\nnormalized package name.", + "type": [ + "string", + "null" + ] + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "provider": { + "description": "Manager/catalog provider, such as `nixpkgs`, a Flox catalog, or a flake.", + "type": [ + "string", + "null" + ] + }, + "requirement": { + "type": "string" + }, + "resolved": { + "type": [ + "string", + "null" + ] + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/ImmutableSource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "requirement" + ] + }, + "ToolRequirement": { + "description": "Desired and resolved identity for one runtime or developer tool.", + "type": "object", + "properties": { + "backend": { + "type": [ + "string", + "null" + ] + }, + "checksums": { + "type": "array", + "items": { + "$ref": "#/$defs/Checksum" + } + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "requirement": { + "description": "Author-authored requirement. It may be a range in authoring mode.", + "type": "string" + }, + "resolved": { + "description": "Exact manager-native result. Required in frozen modes.", + "type": [ + "string", + "null" + ] + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/ImmutableSource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "requirement" + ] + } + } +} diff --git a/schemas/environment-plan.json b/schemas/environment-plan.json new file mode 100644 index 0000000..c2d750e --- /dev/null +++ b/schemas/environment-plan.json @@ -0,0 +1,557 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EnvironmentPlanV2", + "description": "Manager-neutral desired and resolved developer-environment state.", + "type": "object", + "properties": { + "activation": { + "$ref": "#/$defs/ActivationPolicy", + "default": "none" + }, + "env": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + } + }, + "extensions": { + "type": "object", + "additionalProperties": true + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "schema": { + "type": "integer", + "format": "uint32", + "default": 2, + "minimum": 0 + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/EnvironmentSource" + } + }, + "system-packages": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SystemPackageSpec" + } + }, + "tasks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/TaskSpec" + } + }, + "tools": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ToolSpec" + } + }, + "vars": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + } + } + }, + "$defs": { + "ActivationPolicy": { + "description": "The only activation behavior a Zed environment adapter may add.", + "type": "string", + "enum": [ + "none", + "frozen-install" + ] + }, + "Checksum": { + "description": "One lowercase hexadecimal content checksum in canonical output.", + "type": "object", + "properties": { + "algorithm": { + "$ref": "#/$defs/ChecksumAlgorithm" + }, + "value": { + "type": "string" + } + }, + "required": [ + "algorithm", + "value" + ] + }, + "ChecksumAlgorithm": { + "description": "Supported checksum algorithms in environment provenance.", + "type": "string", + "enum": [ + "sha256", + "sha512", + "blake3" + ] + }, + "EnvironmentManager": { + "description": "Environment managers with a first-class adapter contract.", + "oneOf": [ + { + "type": "string", + "enum": [ + "mise", + "asdf", + "devbox", + "flox" + ] + }, + { + "description": "Development-shell provenance only. Nix package/derivation import and\nexport is a separate interoperability boundary.", + "type": "string", + "const": "nix" + } + ] + }, + "EnvironmentSource": { + "description": "Provenance for one manager-native input and optional lock file.", + "type": "object", + "properties": { + "digest": { + "description": "Digest of the normalized manager-native lock/input state.", + "anyOf": [ + { + "$ref": "#/$defs/Checksum" + }, + { + "type": "null" + } + ] + }, + "lock_path": { + "type": [ + "string", + "null" + ] + }, + "manager": { + "$ref": "#/$defs/EnvironmentManager" + }, + "path": { + "description": "Project-relative manager input path.", + "type": "string" + } + }, + "required": [ + "manager", + "path" + ] + }, + "EnvironmentValue": { + "description": "Typed activation value, task variable, or backend option.\n\nArrays retain order. Tables are deterministic because they use\n[`BTreeMap`]. Non-finite floats are rejected before serialization.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/EnvironmentValue" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + } + } + ] + }, + "ImmutableSource": { + "description": "A source whose immutable revision is part of environment identity.", + "type": "object", + "properties": { + "checksums": { + "type": "array", + "items": { + "$ref": "#/$defs/Checksum" + } + }, + "revision": { + "description": "A full immutable commit or content digest. Moving tags and branches are\nrejected in frozen validation.", + "type": "string" + }, + "subdir": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "url", + "revision" + ] + }, + "SystemPackageSpec": { + "description": "Schema-v2 system package with typed options and lossless extensions.", + "type": "object", + "properties": { + "checksums": { + "type": "array", + "items": { + "$ref": "#/$defs/Checksum" + } + }, + "extensions": { + "type": "object", + "additionalProperties": true + }, + "options": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + } + }, + "package_ref": { + "description": "Exact provider-native attribute/reference when it differs from the\nnormalized package name.", + "type": [ + "string", + "null" + ] + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "provider": { + "description": "Manager/catalog provider, such as `nixpkgs`, a Flox catalog, or a flake.", + "type": [ + "string", + "null" + ] + }, + "requirement": { + "type": "string" + }, + "resolved": { + "type": [ + "string", + "null" + ] + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/ImmutableSource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "requirement" + ] + }, + "TaskConfirmation": { + "description": "Confirmation may be a Boolean policy or a custom prompt.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "TaskGroup": { + "type": "object", + "properties": { + "parallel": { + "description": "Explicit groups are parallel by default, matching mise grouped tasks.", + "type": "boolean" + }, + "tasks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tasks" + ] + }, + "TaskInvocation": { + "type": "object", + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + } + }, + "task": { + "type": "string" + } + }, + "required": [ + "task" + ] + }, + "TaskSpec": { + "description": "Manager-neutral task definition.\n\nOrdered command fields, shell arguments, and invocation arguments are not\nsorted. Set-like metadata is canonicalized.", + "type": "object", + "properties": { + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "cache": { + "type": [ + "boolean", + "null" + ] + }, + "confirm": { + "anyOf": [ + { + "$ref": "#/$defs/TaskConfirmation" + }, + { + "type": "null" + } + ] + }, + "depends": { + "type": "array", + "items": { + "type": "string" + } + }, + "depends_post": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "dir": { + "type": [ + "string", + "null" + ] + }, + "env": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + } + }, + "extensions": { + "type": "object", + "additionalProperties": true + }, + "hide": { + "type": "boolean" + }, + "outputs": { + "type": "array", + "items": { + "type": "string" + } + }, + "quiet": { + "type": "boolean" + }, + "raw": { + "type": "boolean" + }, + "run": { + "type": "array", + "items": { + "$ref": "#/$defs/TaskStep" + } + }, + "run_windows": { + "type": "array", + "items": { + "$ref": "#/$defs/TaskStep" + } + }, + "shell": { + "description": "Shell program followed by arguments. Order is semantic.", + "type": "array", + "items": { + "type": "string" + } + }, + "silent": { + "type": "boolean" + }, + "sources": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeout": { + "type": [ + "string", + "null" + ] + }, + "tools": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ToolSpec" + } + }, + "usage": { + "type": [ + "string", + "null" + ] + }, + "vars": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + } + }, + "wait_for": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "TaskStep": { + "description": "A task command, one task invocation, or an explicit task group.", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/TaskInvocation" + }, + { + "$ref": "#/$defs/TaskGroup" + } + ] + }, + "ToolSpec": { + "description": "One logical tool may expose one active version or an ordered version list.\nVersion order is retained because it may determine the default executable.", + "anyOf": [ + { + "$ref": "#/$defs/ToolVersion" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/ToolVersion" + } + } + ] + }, + "ToolVersion": { + "description": "One version of a logical tool.\n\nFlattening the schema-v1 requirement keeps existing single-tool JSON and\nTOML documents readable without migration.", + "type": "object", + "properties": { + "backend": { + "type": [ + "string", + "null" + ] + }, + "checksums": { + "type": "array", + "items": { + "$ref": "#/$defs/Checksum" + } + }, + "extensions": { + "description": "Manager-qualified fields not yet promoted into the shared contract.\nAdapters preserve these instead of silently dropping them.", + "type": "object", + "additionalProperties": true + }, + "options": { + "description": "Typed options whose semantics are implemented by Zed or an adapter.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + } + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "provider": { + "type": [ + "string", + "null" + ] + }, + "requirement": { + "description": "Author-authored requirement. It may be a range in authoring mode.", + "type": "string" + }, + "resolved": { + "description": "Exact manager-native result. Required in frozen modes.", + "type": [ + "string", + "null" + ] + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/ImmutableSource" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "requirement" + ] + } + } +} diff --git a/schemas/index.json b/schemas/index.json new file mode 100644 index 0000000..ed46768 --- /dev/null +++ b/schemas/index.json @@ -0,0 +1,164 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ZedInterfacesSchemaIndex", + "description": "Demarcates which JSON Schemas are code-generated into the front-end language slices. Rust is deliberately absent: src/rust is the hand-written source of truth that schemas/*.json are generated FROM (cargo run --example generate_schemas), so every schema is already a Rust type. Dart and TypeScript are generated the other way round, from these schemas into src/dart and src/ts, and only for the subset a browser or Flutter client actually parses.", + "rule": "A schema is front-end-facing if and only if a non-Rust client decodes it directly off the registry HTTP API or the sync stream. Toolchain and on-disk formats (manifest, lockfile, environment plans, nix/oci/native adapter records, publish metadata) stay Rust-only: they are consumed by zed-cli and the servers, and generating them would put 20+ transitive types into every front-end bundle for no consumer.", + "targets": ["dart", "ts"], + "schemas": [ + { + "file": "api-error.json", + "targets": ["dart", "ts"], + "why": "Error envelope of every registry endpoint; every client renders it." + }, + { + "file": "package-metadata.json", + "targets": ["dart", "ts"], + "why": "GET /packages/{org}/{name} — the package detail page." + }, + { + "file": "version-metadata.json", + "targets": ["dart", "ts"], + "why": "GET /packages/{org}/{name}/{version} — the version detail page." + }, + { + "file": "package-list-response.json", + "targets": ["dart", "ts"], + "why": "Registry browse/index listing." + }, + { + "file": "search-response.json", + "targets": ["dart", "ts"], + "why": "Keyword search results." + }, + { + "file": "semantic-search-request.json", + "targets": ["dart", "ts"], + "why": "Semantic search is issued straight from the UI." + }, + { + "file": "semantic-search-response.json", + "targets": ["dart", "ts"], + "why": "Semantic search results, incl. per-hit scores the UI displays." + }, + { + "file": "publish-response.json", + "targets": ["dart", "ts"], + "why": "Shown after a UI-driven or extension-driven publish." + }, + { + "file": "claim-org-request.json", + "targets": ["dart", "ts"], + "why": "Org claim flow is a web form." + }, + { + "file": "claim-org-response.json", + "targets": ["dart", "ts"], + "why": "Org claim flow is a web form." + }, + { + "file": "yank-request.json", + "targets": ["dart", "ts"], + "why": "Yank/unyank is exposed in the package admin UI." + }, + { + "file": "yank-response.json", + "targets": ["dart", "ts"], + "why": "Yank/unyank is exposed in the package admin UI." + }, + { + "file": "audit-log-response.json", + "targets": ["dart", "ts"], + "why": "Org governance screen renders the audit trail." + }, + { + "file": "audit-integrity-response.json", + "targets": ["dart", "ts"], + "why": "The same screen verifies the hash chain client-side." + }, + { + "file": "sync-change-event.json", + "targets": ["dart", "ts"], + "why": "Live sync envelope decoded by browser and Flutter transports." + }, + { + "file": "sync-write-mode.json", + "targets": ["dart", "ts"], + "why": "Sync client configuration surfaced in UI settings." + }, + { + "file": "sync-error-policy.json", + "targets": ["dart", "ts"], + "why": "Sync client configuration surfaced in UI settings." + }, + { + "file": "sync-conflict-resolution.json", + "targets": ["dart", "ts"], + "why": "Sync client configuration surfaced in UI settings." + }, + { + "file": "embedding-upsert-request.json", + "targets": [], + "why": "Server-side index ingestion; never issued by a front end." + }, + { + "file": "manifest.json", + "targets": [], + "why": "On-disk .zpkg.toml model — zed-cli and the servers only." + }, + { + "file": "lockfile.json", + "targets": [], + "why": "On-disk .zpkg.lock model — zed-cli and the servers only." + }, + { + "file": "publish-meta.json", + "targets": [], + "why": "Publish-time provenance blob assembled by the CLI." + }, + { + "file": "environment-plan.json", + "targets": [], + "why": "Toolchain/environment planning — CLI only." + }, + { + "file": "environment-plan-v1.json", + "targets": [], + "why": "Toolchain/environment planning — CLI only." + }, + { + "file": "environment-lock-v1.json", + "targets": [], + "why": "Toolchain/environment planning — CLI only." + }, + { + "file": "native-dependency-lock.json", + "targets": [], + "why": "Native-registry interop record — CLI and servers only." + }, + { + "file": "native-registry-adapter-record.json", + "targets": [], + "why": "Native-registry interop record — CLI and servers only." + }, + { + "file": "nix-adapter-record.json", + "targets": [], + "why": "Nix interop record — CLI only." + }, + { + "file": "nix-export-plan.json", + "targets": [], + "why": "Nix interop record — CLI only." + }, + { + "file": "nix-export-section.json", + "targets": [], + "why": "Nix interop record — CLI only." + }, + { + "file": "oci-adapter-record.json", + "targets": [], + "why": "OCI interop record — CLI and servers only." + } + ] +} diff --git a/schemas/lockfile.json b/schemas/lockfile.json new file mode 100644 index 0000000..6c81ed7 --- /dev/null +++ b/schemas/lockfile.json @@ -0,0 +1,564 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Lockfile", + "description": "The `.zpkg.lock` file written next to `.zpkg.toml` after resolution.\n\nSerialized as TOML with one `[[package]]` table per locked Zed package,\noptional `[[native-dependency]]` tables for exact npm/Cargo resolutions,\nand optional `[[nix-adapter]]` tables for completed Nix translations.\nEvery entry pins exact immutable identity so frozen restore never needs to\nreinterpret a native range or repeat an environment translation.", + "type": "object", + "properties": { + "native-dependency": { + "description": "Exact source-aware npm/Cargo resolutions. This additive field keeps\nexisting lockfile version 1 documents readable while newer writers can\npreserve native requirement translation and immutable artifact identity.", + "type": "array", + "items": { + "$ref": "#/$defs/NativeDependencyLock" + } + }, + "nix-adapter": { + "description": "Optional immutable provenance for completed Nix interoperability\ntranslations. This additive field keeps lockfile version 1 readable by\ncurrent consumers while allowing newer writers to preserve evidence.", + "type": "array", + "items": { + "$ref": "#/$defs/NixAdapterRecord" + } + }, + "package": { + "type": "array", + "items": { + "$ref": "#/$defs/LockedPackage" + } + }, + "version": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "version" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "LockedPackage": { + "type": "object", + "properties": { + "format": { + "description": "Explicit archive format. A missing value must never be inferred during\na frozen install because the format is part of immutable artifact identity.", + "$ref": "#/$defs/ArtifactFormat" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "org": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "description": "Canonical lowercase hex sha256 of the artifact archive; also its store address.", + "type": "string", + "maxLength": 64, + "minLength": 64, + "pattern": "^[0-9a-f]{64}$" + }, + "size": { + "description": "Artifact size in bytes. Zero-byte package artifacts are invalid.", + "type": "integer", + "format": "uint64", + "minimum": 1 + }, + "source": { + "description": "Base URL of the registry the artifact was resolved from.", + "type": "string", + "minLength": 1 + }, + "vcs_commit": { + "description": "Exact immutable source revision associated with the published artifact.\nThe optional Rust representation preserves API compatibility for\nbuilders. Parsing and JSON Schema validation require an explicit value;\nthe canonical writer upgrades a legacy in-memory `None` to the exact\ncontent-addressed `artifact-sha256:` revision before emission.", + "type": "string", + "maxLength": 128, + "minLength": 7, + "pattern": "^[A-Za-z0-9._+:/-]+$" + }, + "vcs_tag": { + "description": "VCS tag the version was published from, e.g. `v1.2.0`.", + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "org", + "name", + "version", + "sha256", + "size", + "format", + "vcs_tag", + "vcs_commit", + "source" + ] + }, + "NativeArtifact": { + "description": "Exact immutable bytes published under one native package identity.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "sha256": { + "description": "Lowercase hexadecimal SHA-256 of the exact uploaded archive bytes.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size" + ] + }, + "NativeDependencyLock": { + "description": "Frozen native dependency identity. The declaration remains auditable, while\n`package.version` and `artifact` are the only restore-time identities.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NativeArtifact" + }, + "package": { + "$ref": "#/$defs/NativePackageIdentity" + }, + "requirement": { + "$ref": "#/$defs/NativeVersionRequirement" + }, + "schema": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema", + "requirement", + "package", + "artifact" + ] + }, + "NativePackageIdentity": { + "description": "Public identity selected in npm or a Cargo-compatible registry.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ] + }, + "NativeRegistry": { + "description": "Native registries with a first-class publication identity contract.", + "type": "string", + "enum": [ + "npm", + "cargo" + ] + }, + "NativeVersionRequirement": { + "description": "One source-aware native requirement and its canonical SemVer translation.", + "type": "object", + "properties": { + "canonical": { + "description": "Deterministic `semver::VersionReq` representation used by Zed.", + "type": "string" + }, + "declared": { + "description": "Exact project declaration before translation.", + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + } + }, + "additionalProperties": false, + "required": [ + "registry", + "declared", + "canonical" + ] + }, + "NixAdapterRecord": { + "description": "Final, immutable provenance record for one completed translation.\n\n`direction` is internally tagged in JSON so consumers cannot deserialize a\ndirection whose required origin/result fields are absent.", + "oneOf": [ + { + "type": "object", + "properties": { + "direction": { + "type": "string", + "const": "zed-to-nix" + }, + "flake_bundle_sha256": { + "description": "Hash of a canonical inventory of the generated standalone flake\nbundle, not a filesystem-dependent archive of the output directory.", + "type": "string" + }, + "intent": { + "$ref": "#/$defs/NixExportSection" + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/$defs/NixRealizedOutput" + } + }, + "package": { + "$ref": "#/$defs/NixPackageIdentity" + }, + "policy": { + "$ref": "#/$defs/NixPolicyEvidence" + }, + "schema": { + "type": "string" + }, + "source": { + "$ref": "#/$defs/ZedArtifactOrigin" + } + }, + "required": [ + "direction", + "schema", + "package", + "source", + "intent", + "flake_bundle_sha256", + "outputs", + "policy" + ] + }, + { + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NixInteropArtifact" + }, + "direction": { + "type": "string", + "const": "nix-to-zed" + }, + "package": { + "$ref": "#/$defs/NixPackageIdentity" + }, + "policy": { + "$ref": "#/$defs/NixPolicyEvidence" + }, + "schema": { + "type": "string" + }, + "source": { + "$ref": "#/$defs/NixOutputOrigin" + } + }, + "required": [ + "direction", + "schema", + "package", + "source", + "artifact", + "policy" + ] + } + ] + }, + "NixBuilderNetwork": { + "type": "string", + "enum": [ + "disabled", + "preparation-only", + "allowed" + ] + }, + "NixExportMode": { + "oneOf": [ + { + "description": "Export the exact immutable Zed artifact. Source-builder translation is\nintentionally not inferred from native manifests in contract v1.", + "type": "string", + "const": "artifact" + } + ] + }, + "NixExportSection": { + "description": "Author intent for exporting a package or target to Nix.\n\nThis structure contains no realized store paths, hashes, commands,\ncredentials, cache keys, or service deployment policy. Those belong in a\nversioned adapter record after planning/realization.", + "type": "object", + "properties": { + "attribute": { + "description": "Optional Nix package attribute. Omit it to use the Zed package name.", + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/$defs/NixExportMode", + "default": "artifact" + }, + "outputs": { + "description": "Explicit derivation outputs. Contract v1 never silently selects the\nfirst output of a multi-output derivation.", + "type": "array", + "items": { + "type": "string" + } + }, + "systems": { + "description": "Explicit Nix systems this package claims to support.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "NixInteropArtifact": { + "description": "Immutable Zed artifact identity used by both a Zed-origin source and the\ntranslated artifact produced by Nix → Zed sealing.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "sha256": { + "description": "Lowercase hexadecimal SHA-256 of the exact archive bytes.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size" + ] + }, + "NixOutputOrigin": { + "description": "Immutable Nix source selector plus the exact realized output selected for a\nNix → Zed translation.", + "type": "object", + "properties": { + "attribute": { + "description": "Standard flake attribute path, e.g. `packages.x86_64-linux.tool`.", + "type": "string" + }, + "flake_lock_sha256": { + "type": "string" + }, + "locked_ref": { + "type": "string" + }, + "realized": { + "$ref": "#/$defs/NixRealizedOutput" + } + }, + "required": [ + "locked_ref", + "flake_lock_sha256", + "attribute", + "realized" + ] + }, + "NixPackageIdentity": { + "description": "Public Zed identity chosen for either translation direction. A Nix\nattribute is a selector and never silently claims a Zed organization.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version" + ] + }, + "NixPolicyEvidence": { + "description": "Evidence that the translation was planned/realized under a named policy.\nThis records policy state; it does not grant credentials or execute Nix.", + "type": "object", + "properties": { + "builder_network": { + "$ref": "#/$defs/NixBuilderNetwork" + }, + "dirty_source": { + "type": "boolean" + }, + "import_from_derivation": { + "type": "boolean" + }, + "profile": { + "$ref": "#/$defs/NixPolicyProfile" + }, + "publishable": { + "type": "boolean" + }, + "pure_evaluation": { + "type": "boolean" + }, + "sandbox_required": { + "type": "boolean" + } + }, + "required": [ + "profile", + "pure_evaluation", + "import_from_derivation", + "sandbox_required", + "builder_network", + "dirty_source", + "publishable" + ] + }, + "NixPolicyProfile": { + "type": "string", + "enum": [ + "strict-v1", + "development" + ] + }, + "NixRealizedOutput": { + "description": "Realization evidence for exactly one Nix system/output pair.", + "type": "object", + "properties": { + "derivation_json_sha256": { + "type": "string" + }, + "nar_hash": { + "type": "string" + }, + "nar_size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "nix_version": { + "type": "string" + }, + "output": { + "type": "string" + }, + "references": { + "type": "array", + "items": { + "$ref": "#/$defs/NixStoreReference" + } + }, + "signatures": { + "type": "array", + "items": { + "type": "string" + } + }, + "store_info_json_version": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "store_path": { + "description": "Diagnostic path only; `nar_hash` is the portable output identity.", + "type": "string" + }, + "system": { + "type": "string" + } + }, + "required": [ + "system", + "output", + "derivation_json_sha256", + "store_path", + "nar_hash", + "nar_size", + "nix_version", + "store_info_json_version" + ] + }, + "NixStoreReference": { + "description": "One referenced Nix store object. Portable Nix → Zed imports reject any\nruntime references in contract v1; Zed → Nix output attestations may still\nretain them as evidence.", + "type": "object", + "properties": { + "nar_hash": { + "type": [ + "string", + "null" + ] + }, + "nar_size": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "store_path": { + "type": "string" + } + }, + "required": [ + "store_path" + ] + }, + "ZedArtifactOrigin": { + "description": "Immutable source evidence when Zed is the dependency-resolution authority.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NixInteropArtifact" + }, + "lock_sha256": { + "description": "Hash of the exact `.zpkg.lock` bytes, omitted only for a dependency-free\npackage whose export plan proves no lock is required.", + "type": [ + "string", + "null" + ] + }, + "registry": { + "description": "Registry base URL used to resolve the exact artifact.", + "type": "string" + }, + "vcs_commit": { + "type": "string" + }, + "vcs_tag": { + "type": "string" + } + }, + "required": [ + "registry", + "artifact", + "vcs_tag", + "vcs_commit" + ] + } + } +} diff --git a/schemas/manifest.json b/schemas/manifest.json new file mode 100644 index 0000000..b6f1188 --- /dev/null +++ b/schemas/manifest.json @@ -0,0 +1,631 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Manifest", + "description": "The `.zpkg.toml` manifest at the root of every package repository.\nTOML only — never YAML or JSON.\n\n```toml\n[package]\norg = \"acme\"\nname = \"http-kit\"\nversion = \"1.2.0\"\ndescription = \"Tiny HTTP helpers\"\nlicense = \"MIT\"\n\n[package.repository]\nvcs = \"git\"\nurl = \"https://github.com/acme/http-kit\"\n\n[dependencies]\n\"acme/logkit\" = \"^0.3\"\n\n[publish]\nexclude = [\"benches/**\"]\nsmoke_test = \"sh scripts/smoke.sh\"\n```", + "type": "object", + "properties": { + "bin": { + "description": "Executables this package exposes, keyed by command name, valued by a\npath relative to the package root. On install they are hoisted into\n`zed_modules/.bin/` and runnable via `zed run ` (zed-docs\nissue #7).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "build": { + "description": "This package's own post-extract build step (compiled extensions,\ncodegen), run when the package ships source that needs compiling.\nBuilds run in an isolated staging copy — never inside the immutable\nsource store — and results are cached per (sha256, target, command).", + "anyOf": [ + { + "$ref": "#/$defs/BuildSection" + }, + { + "type": "null" + } + ] + }, + "build-dependencies": { + "description": "Tools needed only while running this package's `[build]` command\n(compilers, codegen). They are made available in the build sandbox and\nnever linked into a consumer's `zed_modules/`. See [`BuildSection`]\nand zed-docs issue #5. Canonical TOML key is Cargo-style\n`[build-dependencies]`; the snake_case spelling is accepted on read.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "dependencies": { + "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "hooks": { + "description": "Package-local lifecycle hooks. They run in a writable staging copy,\nnever in the immutable source store and never in the consumer project.\n`pre-install` runs before `[build]`; `post-install` runs after it and\nbefore the finalized artifact is promoted to the platform cache.", + "$ref": "#/$defs/InstallHooksSection" + }, + "install": { + "description": "Where zed materializes the (few, hand-picked) dependencies it sources —\nzed complements npm/maven/etc. rather than replacing them, so this dir\nsits alongside the native one and the ecosystem adapter wires it into\nthe toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir`\ndefaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or\n`.deps/.zed`.", + "$ref": "#/$defs/InstallSection" + }, + "native-dependencies": { + "description": "Host-native packages required before this package's install hooks or\nbuild step can run. Keys are supported package-manager names (`apt`,\n`apk`, `brew`, `nix`, ...); values are package specs passed as argv,\nnever interpolated into a shell command. Installing these prerequisites\nis an explicitly consented zed operation, separate from build-hook\nconsent.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "overrides": { + "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing `[build]` step without waiting on upstream).", + "$ref": "#/$defs/OverridesSection" + }, + "package": { + "$ref": "#/$defs/PackageSection" + }, + "publish": { + "$ref": "#/$defs/PublishSection", + "default": { + "exclude": [], + "include_readme": false, + "smoke_test": null, + "tag_format": "v{version}" + } + }, + "scripts": { + "$ref": "#/$defs/ScriptsSection", + "default": { + "test": null + } + }, + "targets": { + "description": "Language subtrees for a **polyglot package** — one repo shipping the\nsame library for several ecosystems (e.g. `node/`, `python/`, `go/`).\nKeyed by ecosystem name; the value says which subdirectory is that\necosystem's package root. On install the consumer resolves one target\nand only that subtree is materialized, so a Python project gets the\nPython source at its import root rather than a tree it has to reach\ninto. Absent (the common case) = single-language package, whole tree.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/TargetSection" + } + }, + "workspace": { + "description": "Monorepo workspace declaration (zed-docs issue #7); only meaningful in\na workspace root manifest. When present, `zed install` at this root\nresolves every member against one store and writes one `.zpkg.lock`;\nmember→member dependencies link by path instead of going through the\nregistry.", + "anyOf": [ + { + "$ref": "#/$defs/WorkspaceSection" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "package" + ], + "$defs": { + "BuildSection": { + "description": "A post-extract build step. Because compiled output is OS/arch-specific,\nzed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the\nsource and caches the result in a build cache keyed by\n`(source sha256, target triple, command)` — separate from the universal,\nplatform-independent source store (zed-docs issue #5).", + "type": "object", + "properties": { + "command": { + "description": "Command executed after extraction, e.g. `make` or `cargo build --release`.", + "type": "string" + }, + "outputs": { + "description": "Paths (relative to the package root) to keep from the staging build.\nEmpty means keep everything.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "command" + ] + }, + "Ecosystem": { + "description": "How a consumer's build system takes a dependency in: the resolution\nmechanism, not the language. Drives the install guard and which toolchain\nwiring file `zed install` writes.", + "oneOf": [ + { + "type": "string", + "enum": [ + "cargo", + "cmake", + "composer", + "cran", + "gem", + "gomod", + "hackage", + "julia", + "luarocks", + "matlab", + "nimble", + "npm", + "nuget", + "opam", + "psgallery", + "pypi", + "shards", + "shell", + "swiftpm", + "zig" + ] + }, + { + "description": "No ecosystem constraint. Installs into any project; never gated.", + "type": "string", + "const": "universal" + }, + { + "description": "Elixir, Erlang and Gleam share Hex and the BEAM.", + "type": "string", + "const": "hex" + }, + { + "description": "Anything consumed off a JVM classpath: Java, Kotlin, Scala, Clojure.", + "type": "string", + "const": "jvm" + }, + { + "description": "Dart and Flutter (`pub`).", + "type": "string", + "const": "pub" + } + ] + }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, + "InstallHooksSection": { + "description": "Package-local lifecycle hooks. Commands are author-controlled shell code,\nbut execution is separately consented by the installer and occurs only in\na writable staging tree.", + "type": "object", + "properties": { + "post-install": { + "type": "array", + "items": { + "type": "string" + } + }, + "pre-install": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "InstallSection": { + "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", + "type": "object", + "properties": { + "adapter": { + "description": "Force the ecosystem adapter: `node`, `java`, or `none`. Omitted =\nauto-detect (or the CLI `--adapter`). Mirrors the CLI's values.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Project-relative directory for the installed tree (`//`).\nDefaults to `zed_modules`. Common overrides: `.vendor/.zed`, `.deps/.zed`.\nMust be a safe relative path (no leading `/`, no `..`).", + "type": [ + "string", + "null" + ] + }, + "target": { + "description": "Which language subtree to take from **polyglot** dependencies (see\n[`TargetSection`]). Omitted = infer from the project (`package.json` →\n`node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a\ntarget a dependency does not publish is an error rather than a silent\nfallback: the consumer asked for something specific.", + "type": [ + "string", + "null" + ] + } + } + }, + "Language": { + "description": "The language a package's code is written for. Names the published package\n(`-`), and implies an [`Ecosystem`] via\n[`Language::ecosystem`].\n\nCanonical tokens are the colloquial names people search for — `nodejs`, not\n`node`; `golang`, not `go` — because that is what ends up in a package name\na human has to recall. The shorter spellings, and near-synonyms like\n`typescript`, are accepted by [`Language::from_token`] and by umbrella\nvariant aliases, so both spellings resolve.", + "oneOf": [ + { + "type": "string", + "enum": [ + "c", + "clojure", + "cpp", + "crystal", + "csharp", + "dart", + "elixir", + "erlang", + "fsharp", + "gleam", + "golang", + "haskell", + "java", + "julia", + "kotlin", + "lua", + "matlab", + "nim", + "ocaml", + "php", + "powershell", + "python", + "r", + "ruby", + "rust", + "scala", + "shell", + "swift", + "zig" + ] + }, + { + "description": "Not language-specific: protocol schemas, docs, `.proto` files, an\numbrella package. Never subject to the install ecosystem guard.", + "type": "string", + "const": "universal" + }, + { + "description": "Flutter is a framework rather than a language, but a Flutter client is a\nseparate package from a plain Dart one (different `pubspec.yaml`,\ndifferent deps), so it gets its own token.", + "type": "string", + "const": "flutter" + }, + { + "description": "Covers JavaScript and TypeScript alike: for a client library they are\none artifact published to one ecosystem, so `typescript`, `javascript`,\n`ts`, `js` and `node` all resolve here rather than splitting the package.", + "type": "string", + "const": "nodejs" + }, + { + "description": "Rust compiled to WebAssembly. Distinct from [`Language::Rust`] because\nthe artifact is consumed by a JS bundler, not by Cargo.", + "type": "string", + "const": "rust-wasm" + } + ] + }, + "NativeRegistry": { + "type": "string", + "enum": [ + "npm", + "crates-io", + "pub.dev", + "pypi", + "maven-central", + "rubygems", + "nuget", + "packagist", + "go-modules" + ] + }, + "NativeReleaseSection": { + "type": "object", + "properties": { + "forge": { + "description": "Optional copies in package registries run by source forges. The native\nregistry remains the canonical ecosystem destination; these mirrors\nuse the same native package format and version.", + "type": "array", + "items": { + "$ref": "#/$defs/ForgeRegistry" + } + }, + "package": { + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + }, + "tag_format": { + "description": "Optional VCS tag template for native ecosystems whose package is\nresolved from a tag rather than uploaded. It must contain `{version}`.\nGo modules below a repository subdirectory need the directory prefix,\nfor example `clients/go/v{version}`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "registry", + "package" + ] + }, + "NixExportMode": { + "oneOf": [ + { + "description": "Export the exact immutable Zed artifact. Source-builder translation is\nintentionally not inferred from native manifests in contract v1.", + "type": "string", + "const": "artifact" + } + ] + }, + "NixExportSection": { + "description": "Author intent for exporting a package or target to Nix.\n\nThis structure contains no realized store paths, hashes, commands,\ncredentials, cache keys, or service deployment policy. Those belong in a\nversioned adapter record after planning/realization.", + "type": "object", + "properties": { + "attribute": { + "description": "Optional Nix package attribute. Omit it to use the Zed package name.", + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/$defs/NixExportMode", + "default": "artifact" + }, + "outputs": { + "description": "Explicit derivation outputs. Contract v1 never silently selects the\nfirst output of a multi-output derivation.", + "type": "array", + "items": { + "type": "string" + } + }, + "systems": { + "description": "Explicit Nix systems this package claims to support.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "OverridesSection": { + "description": "Consumer-side dependency patches, keyed by `org/name`.", + "type": "object", + "properties": { + "build": { + "description": "Replace or provide a dependency's `[build]` step.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/BuildSection" + } + } + } + }, + "PackageSection": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "ecosystem": { + "description": "How consumers take this package in: `jvm`, `npm`, `gomod`, … Drives the\ninstall-time guard that refuses to drop a Java client into a Node\nproject, and picks the toolchain wiring `zed install` writes.\n\nOmit it and it is derived from `language` (see\n[`Language::ecosystem`]) — declare it only when the language does not\ndetermine consumption. Read through [`PackageSection::ecosystem`]\nrather than touching this field, so the fallback always applies.", + "$ref": "#/$defs/Ecosystem" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "description": "The language this package's code targets. A multi-language repository\npublishes one package per language, all at one version, and each names\nits own — `acme-clients-java`, `acme-clients-nodejs`. Defaults to\n`universal` (language-agnostic), which is never subject to the install\necosystem guard.", + "$ref": "#/$defs/Language" + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Package name, unique within the org. Lowercase slug.", + "type": "string" + }, + "org": { + "description": "Namespace the package is published under. Lowercase slug.", + "type": "string" + }, + "repository": { + "$ref": "#/$defs/RepositorySection" + }, + "version": { + "description": "Version of this package, interpreted according to `version_scheme`\n(semver by default).", + "type": "string" + }, + "version_scheme": { + "description": "How `version` (and published tags) should be interpreted. Semver by\ndefault; `calver` for calendar versions, `opaque` for arbitrary tags.", + "$ref": "#/$defs/VersionScheme" + } + }, + "required": [ + "org", + "name", + "version", + "repository" + ] + }, + "PublishSection": { + "type": "object", + "properties": { + "exclude": { + "description": "Extra glob patterns to exclude on top of the built-in defaults.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "include_readme": { + "description": "Keep README files in the published artifact (stripped by default).", + "type": "boolean", + "default": false + }, + "native": { + "description": "Optional native-registry route for a single-language package whose\npackage-manager manifest lives at the repository root. Polyglot\npackages declare this metadata on each `[targets.*.native]` section\ninstead.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + }, + "nix": { + "description": "Optional deterministic export of this single-language package as a\nstandalone Nix flake. Nix is a typed interop adapter, not a native\nregistry destination, so its intent remains separate from `native`.", + "anyOf": [ + { + "$ref": "#/$defs/NixExportSection" + }, + { + "type": "null" + } + ] + }, + "smoke_test": { + "description": "Command run by `zed r2g` (alias `zed test-local`) inside a throwaway\nconsumer project that has this package installed the same way a real\nconsumer would.", + "type": [ + "string", + "null" + ], + "default": null + }, + "tag_format": { + "description": "VCS tag template that must exist and point at the published commit.\n`{version}` is substituted with `package.version`.", + "type": "string", + "default": "v{version}" + } + } + }, + "RepositorySection": { + "description": "Where the package's source of truth lives. Any Git or Mercurial host\nworks: GitHub, GitLab, Bitbucket, or a self-hosted server.", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "vcs": { + "$ref": "#/$defs/Vcs", + "default": "git" + } + }, + "required": [ + "url" + ] + }, + "ScriptsSection": { + "type": "object", + "properties": { + "test": { + "description": "Test command run from the repository (not from published artifacts;\ntests are stripped at publish time).", + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "TargetSection": { + "description": "One ecosystem's slice of a polyglot package — and, on publish, its own\nindependently installable package.\n\nA repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and\n`clients/go` declares one target each. `zed publish` then emits **one\nartifact per target**, named `-` by default:\n\n```text\nfiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only\nfiducia/fiducia-clients-java@1.1.2 <- clients/java only\nfiducia/fiducia-clients-golang@1.1.2 <- clients/go only\n```\n\nOne source of truth and one version in the repo; N packages on the wire.\nA Java consumer downloads only Java bytes — the decisive advantage over\nshipping one fat artifact and slicing it at install time.", + "type": "object", + "properties": { + "adapter": { + "description": "Ecosystem adapter consumers of THIS target should use (`node`, `java`,\n`none`). Recorded in the published per-target manifest so a consumer\ngets the right wiring without configuring it.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Package-relative directory that is this ecosystem's package root, e.g.\n`python` or `clients/go`. Must be a safe relative path (no leading `/`,\nno `..`) so a target can never escape the package.", + "type": "string" + }, + "ecosystem": { + "description": "Override the ecosystem this target publishes into. Omit it (the normal\ncase) and it is derived from the target key via [`Language::ecosystem`].\nDeclare it when the key does not determine consumption — a `rust-wasm`\ntarget is consumed by a JS bundler, not by Cargo.", + "$ref": "#/$defs/Ecosystem" + }, + "hooks": { + "description": "Target-specific lifecycle hooks, appended after package-level hooks in\neach phase when the target is selected.", + "$ref": "#/$defs/InstallHooksSection" + }, + "name": { + "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", + "type": [ + "string", + "null" + ] + }, + "native": { + "description": "Optional routing to this target's native ecosystem registry. This is\ndeclarative metadata only: the native manifest remains authoritative,\nand arbitrary commands are intentionally not representable here.\n\nNote the difference from [`TargetSection::ecosystem`]: `native` is\n**outbound** (where this slice is mirrored to — npm, crates.io), while\n`ecosystem` is **inbound** (what toolchain a consumer must have to\ninstall it from zed). They describe the same ecosystem from two sides,\nso when both are set they must agree — see\n[`NativeRegistry::ecosystem`] and the check in [`Manifest::validate`].", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + }, + "native-dependencies": { + "description": "Native prerequisites added by this target. Entries merge with the\npackage-level `[native-dependencies]` table when this target is selected.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nix": { + "description": "Optional deterministic Nix export intent for this isolated target.", + "anyOf": [ + { + "$ref": "#/$defs/NixExportSection" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "dir" + ] + }, + "Vcs": { + "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", + "type": "string", + "enum": [ + "git", + "hg", + "jj", + "sapling", + "fossil", + "pijul" + ] + }, + "VersionScheme": { + "description": "How a package's `version` string (and its published tags) should be\ninterpreted. Defaults to [`VersionScheme::Semver`], which covers the vast\nmajority of modern ecosystems.", + "oneOf": [ + { + "description": "Semantic Versioning. `package.version` must be valid semver; ranges\n(`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version.", + "type": "string", + "const": "semver" + }, + { + "description": "Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver\ntotal order (leading zeros dropped, padded to major.minor.patch) so the\nsame range algebra applies. See [`normalize_calver`].", + "type": "string", + "const": "calver" + }, + { + "description": "Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra:\na requirement must match a published version **exactly**.", + "type": "string", + "const": "opaque" + } + ] + }, + "WorkspaceSection": { + "description": "Monorepo workspace membership. `members` are glob patterns (relative to\nthe workspace root) selecting directories that each contain a `.zpkg.toml`,\ne.g. `[\"packages/*\", \"apps/*\"]`. Dependencies that resolve to a member are\nlinked straight to the member's source directory instead of going through\nthe registry.", + "type": "object", + "properties": { + "members": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/schemas/native-dependency-lock.json b/schemas/native-dependency-lock.json new file mode 100644 index 0000000..108abef --- /dev/null +++ b/schemas/native-dependency-lock.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "NativeDependencyLock", + "description": "Frozen native dependency identity. The declaration remains auditable, while\n`package.version` and `artifact` are the only restore-time identities.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NativeArtifact" + }, + "package": { + "$ref": "#/$defs/NativePackageIdentity" + }, + "requirement": { + "$ref": "#/$defs/NativeVersionRequirement" + }, + "schema": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema", + "requirement", + "package", + "artifact" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "NativeArtifact": { + "description": "Exact immutable bytes published under one native package identity.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "sha256": { + "description": "Lowercase hexadecimal SHA-256 of the exact uploaded archive bytes.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size" + ] + }, + "NativePackageIdentity": { + "description": "Public identity selected in npm or a Cargo-compatible registry.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ] + }, + "NativeRegistry": { + "description": "Native registries with a first-class publication identity contract.", + "type": "string", + "enum": [ + "npm", + "cargo" + ] + }, + "NativeVersionRequirement": { + "description": "One source-aware native requirement and its canonical SemVer translation.", + "type": "object", + "properties": { + "canonical": { + "description": "Deterministic `semver::VersionReq` representation used by Zed.", + "type": "string" + }, + "declared": { + "description": "Exact project declaration before translation.", + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + } + }, + "additionalProperties": false, + "required": [ + "registry", + "declared", + "canonical" + ] + } + } +} diff --git a/schemas/native-registry-adapter-record.json b/schemas/native-registry-adapter-record.json new file mode 100644 index 0000000..aebd126 --- /dev/null +++ b/schemas/native-registry-adapter-record.json @@ -0,0 +1,207 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "NativeRegistryAdapterRecord", + "description": "Final immutable mapping from one Zed source target to a family of native\nregistry packages.", + "type": "object", + "properties": { + "publications": { + "type": "array", + "items": { + "$ref": "#/$defs/NativePublication" + } + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + }, + "schema": { + "type": "string" + }, + "source": { + "$ref": "#/$defs/ZedNativePackageIdentity" + } + }, + "required": [ + "schema", + "registry", + "source", + "publications" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "NativeArtifact": { + "description": "Exact immutable bytes published under one native package identity.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "sha256": { + "description": "Lowercase hexadecimal SHA-256 of the exact uploaded archive bytes.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size" + ] + }, + "NativePackageIdentity": { + "description": "Public identity selected in npm or a Cargo-compatible registry.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ] + }, + "NativePlatform": { + "description": "Platform identity kept outside the package version.", + "type": "object", + "properties": { + "arch": { + "type": "string" + }, + "libc": { + "type": [ + "string", + "null" + ] + }, + "os": { + "type": "string" + } + }, + "required": [ + "os", + "arch" + ] + }, + "NativePlatformPackage": { + "description": "One platform-to-package edge emitted by a generic wrapper package.", + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "platform": { + "$ref": "#/$defs/NativePlatform" + } + }, + "required": [ + "platform", + "package" + ] + }, + "NativePublication": { + "description": "One uploaded package within a publication family.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NativeArtifact" + }, + "kind": { + "$ref": "#/$defs/NativePublicationKind" + }, + "package": { + "$ref": "#/$defs/NativePackageIdentity" + }, + "platform": { + "anyOf": [ + { + "$ref": "#/$defs/NativePlatform" + }, + { + "type": "null" + } + ] + }, + "platform_packages": { + "description": "Only a `meta` publication may contain selector edges. Each edge must\nreference a `platform` publication in the same adapter record.", + "type": "array", + "items": { + "$ref": "#/$defs/NativePlatformPackage" + } + } + }, + "required": [ + "package", + "kind", + "artifact" + ] + }, + "NativePublicationKind": { + "description": "Role of one package in a native-registry publication family.", + "oneOf": [ + { + "description": "One package whose payload is portable across all supported platforms.", + "type": "string", + "const": "portable" + }, + { + "description": "A generic wrapper package that selects platform-specific packages.", + "type": "string", + "const": "meta" + }, + { + "description": "One package containing bytes for exactly one explicit platform.", + "type": "string", + "const": "platform" + } + ] + }, + "NativeRegistry": { + "description": "Native registries with a first-class publication identity contract.", + "type": "string", + "enum": [ + "npm", + "cargo" + ] + }, + "ZedNativePackageIdentity": { + "description": "Strict SemVer source identity in the Zed registry.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version" + ] + } + } +} diff --git a/schemas/nix-adapter-record.json b/schemas/nix-adapter-record.json new file mode 100644 index 0000000..08c870d --- /dev/null +++ b/schemas/nix-adapter-record.json @@ -0,0 +1,373 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "NixAdapterRecord", + "description": "Final, immutable provenance record for one completed translation.\n\n`direction` is internally tagged in JSON so consumers cannot deserialize a\ndirection whose required origin/result fields are absent.", + "oneOf": [ + { + "type": "object", + "properties": { + "direction": { + "type": "string", + "const": "zed-to-nix" + }, + "flake_bundle_sha256": { + "description": "Hash of a canonical inventory of the generated standalone flake\nbundle, not a filesystem-dependent archive of the output directory.", + "type": "string" + }, + "intent": { + "$ref": "#/$defs/NixExportSection" + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/$defs/NixRealizedOutput" + } + }, + "package": { + "$ref": "#/$defs/NixPackageIdentity" + }, + "policy": { + "$ref": "#/$defs/NixPolicyEvidence" + }, + "schema": { + "type": "string" + }, + "source": { + "$ref": "#/$defs/ZedArtifactOrigin" + } + }, + "required": [ + "direction", + "schema", + "package", + "source", + "intent", + "flake_bundle_sha256", + "outputs", + "policy" + ] + }, + { + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NixInteropArtifact" + }, + "direction": { + "type": "string", + "const": "nix-to-zed" + }, + "package": { + "$ref": "#/$defs/NixPackageIdentity" + }, + "policy": { + "$ref": "#/$defs/NixPolicyEvidence" + }, + "schema": { + "type": "string" + }, + "source": { + "$ref": "#/$defs/NixOutputOrigin" + } + }, + "required": [ + "direction", + "schema", + "package", + "source", + "artifact", + "policy" + ] + } + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "NixBuilderNetwork": { + "type": "string", + "enum": [ + "disabled", + "preparation-only", + "allowed" + ] + }, + "NixExportMode": { + "oneOf": [ + { + "description": "Export the exact immutable Zed artifact. Source-builder translation is\nintentionally not inferred from native manifests in contract v1.", + "type": "string", + "const": "artifact" + } + ] + }, + "NixExportSection": { + "description": "Author intent for exporting a package or target to Nix.\n\nThis structure contains no realized store paths, hashes, commands,\ncredentials, cache keys, or service deployment policy. Those belong in a\nversioned adapter record after planning/realization.", + "type": "object", + "properties": { + "attribute": { + "description": "Optional Nix package attribute. Omit it to use the Zed package name.", + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/$defs/NixExportMode", + "default": "artifact" + }, + "outputs": { + "description": "Explicit derivation outputs. Contract v1 never silently selects the\nfirst output of a multi-output derivation.", + "type": "array", + "items": { + "type": "string" + } + }, + "systems": { + "description": "Explicit Nix systems this package claims to support.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "NixInteropArtifact": { + "description": "Immutable Zed artifact identity used by both a Zed-origin source and the\ntranslated artifact produced by Nix → Zed sealing.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "sha256": { + "description": "Lowercase hexadecimal SHA-256 of the exact archive bytes.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size" + ] + }, + "NixOutputOrigin": { + "description": "Immutable Nix source selector plus the exact realized output selected for a\nNix → Zed translation.", + "type": "object", + "properties": { + "attribute": { + "description": "Standard flake attribute path, e.g. `packages.x86_64-linux.tool`.", + "type": "string" + }, + "flake_lock_sha256": { + "type": "string" + }, + "locked_ref": { + "type": "string" + }, + "realized": { + "$ref": "#/$defs/NixRealizedOutput" + } + }, + "required": [ + "locked_ref", + "flake_lock_sha256", + "attribute", + "realized" + ] + }, + "NixPackageIdentity": { + "description": "Public Zed identity chosen for either translation direction. A Nix\nattribute is a selector and never silently claims a Zed organization.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version" + ] + }, + "NixPolicyEvidence": { + "description": "Evidence that the translation was planned/realized under a named policy.\nThis records policy state; it does not grant credentials or execute Nix.", + "type": "object", + "properties": { + "builder_network": { + "$ref": "#/$defs/NixBuilderNetwork" + }, + "dirty_source": { + "type": "boolean" + }, + "import_from_derivation": { + "type": "boolean" + }, + "profile": { + "$ref": "#/$defs/NixPolicyProfile" + }, + "publishable": { + "type": "boolean" + }, + "pure_evaluation": { + "type": "boolean" + }, + "sandbox_required": { + "type": "boolean" + } + }, + "required": [ + "profile", + "pure_evaluation", + "import_from_derivation", + "sandbox_required", + "builder_network", + "dirty_source", + "publishable" + ] + }, + "NixPolicyProfile": { + "type": "string", + "enum": [ + "strict-v1", + "development" + ] + }, + "NixRealizedOutput": { + "description": "Realization evidence for exactly one Nix system/output pair.", + "type": "object", + "properties": { + "derivation_json_sha256": { + "type": "string" + }, + "nar_hash": { + "type": "string" + }, + "nar_size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "nix_version": { + "type": "string" + }, + "output": { + "type": "string" + }, + "references": { + "type": "array", + "items": { + "$ref": "#/$defs/NixStoreReference" + } + }, + "signatures": { + "type": "array", + "items": { + "type": "string" + } + }, + "store_info_json_version": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "store_path": { + "description": "Diagnostic path only; `nar_hash` is the portable output identity.", + "type": "string" + }, + "system": { + "type": "string" + } + }, + "required": [ + "system", + "output", + "derivation_json_sha256", + "store_path", + "nar_hash", + "nar_size", + "nix_version", + "store_info_json_version" + ] + }, + "NixStoreReference": { + "description": "One referenced Nix store object. Portable Nix → Zed imports reject any\nruntime references in contract v1; Zed → Nix output attestations may still\nretain them as evidence.", + "type": "object", + "properties": { + "nar_hash": { + "type": [ + "string", + "null" + ] + }, + "nar_size": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "store_path": { + "type": "string" + } + }, + "required": [ + "store_path" + ] + }, + "ZedArtifactOrigin": { + "description": "Immutable source evidence when Zed is the dependency-resolution authority.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NixInteropArtifact" + }, + "lock_sha256": { + "description": "Hash of the exact `.zpkg.lock` bytes, omitted only for a dependency-free\npackage whose export plan proves no lock is required.", + "type": [ + "string", + "null" + ] + }, + "registry": { + "description": "Registry base URL used to resolve the exact artifact.", + "type": "string" + }, + "vcs_commit": { + "type": "string" + }, + "vcs_tag": { + "type": "string" + } + }, + "required": [ + "registry", + "artifact", + "vcs_tag", + "vcs_commit" + ] + } + } +} diff --git a/schemas/nix-export-plan.json b/schemas/nix-export-plan.json new file mode 100644 index 0000000..1b9b454 --- /dev/null +++ b/schemas/nix-export-plan.json @@ -0,0 +1,268 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "NixExportPlan", + "description": "Canonical, credential-free plan for one Zed → Nix package export.", + "type": "object", + "properties": { + "bins": { + "description": "Command name → artifact-relative executable path.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "dependencies": { + "description": "Contract v1 requires this list to be empty.", + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/PlannedNixExportDependency" + } + }, + "intent": { + "$ref": "#/$defs/ResolvedNixExportIntent" + }, + "package": { + "$ref": "#/$defs/NixPackageIdentity" + }, + "package_class": { + "$ref": "#/$defs/NixExportPackageClass" + }, + "policy": { + "$ref": "#/$defs/NixPolicyEvidence" + }, + "schema": { + "type": "string" + }, + "source": { + "$ref": "#/$defs/PlannedZedExportArtifact" + } + }, + "additionalProperties": false, + "required": [ + "schema", + "package", + "package_class", + "intent", + "source", + "policy" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "NixBuilderNetwork": { + "type": "string", + "enum": [ + "disabled", + "preparation-only", + "allowed" + ] + }, + "NixExportMode": { + "oneOf": [ + { + "description": "Export the exact immutable Zed artifact. Source-builder translation is\nintentionally not inferred from native manifests in contract v1.", + "type": "string", + "const": "artifact" + } + ] + }, + "NixExportPackageClass": { + "oneOf": [ + { + "description": "Immutable package data with no executable entry point.", + "type": "string", + "const": "data" + }, + { + "description": "Executables already present in the immutable Zed artifact. Contract v1\nnever infers or executes a source build to create them.", + "type": "string", + "const": "prebuilt-bin" + } + ] + }, + "NixInteropArtifact": { + "description": "Immutable Zed artifact identity used by both a Zed-origin source and the\ntranslated artifact produced by Nix → Zed sealing.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "sha256": { + "description": "Lowercase hexadecimal SHA-256 of the exact archive bytes.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size" + ] + }, + "NixPackageIdentity": { + "description": "Public Zed identity chosen for either translation direction. A Nix\nattribute is a selector and never silently claims a Zed organization.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version" + ] + }, + "NixPolicyEvidence": { + "description": "Evidence that the translation was planned/realized under a named policy.\nThis records policy state; it does not grant credentials or execute Nix.", + "type": "object", + "properties": { + "builder_network": { + "$ref": "#/$defs/NixBuilderNetwork" + }, + "dirty_source": { + "type": "boolean" + }, + "import_from_derivation": { + "type": "boolean" + }, + "profile": { + "$ref": "#/$defs/NixPolicyProfile" + }, + "publishable": { + "type": "boolean" + }, + "pure_evaluation": { + "type": "boolean" + }, + "sandbox_required": { + "type": "boolean" + } + }, + "required": [ + "profile", + "pure_evaluation", + "import_from_derivation", + "sandbox_required", + "builder_network", + "dirty_source", + "publishable" + ] + }, + "NixPolicyProfile": { + "type": "string", + "enum": [ + "strict-v1", + "development" + ] + }, + "PlannedNixExportDependency": { + "description": "Reserved typed dependency edge for later plan revisions.\n\nStrict v1 plans must keep `dependencies` empty. Keeping the typed field in\nthe wire contract prevents a later implementation from smuggling an opaque\nnative package-manager graph into otherwise valid-looking plan JSON.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "org", + "name", + "version", + "sha256" + ] + }, + "PlannedZedExportArtifact": { + "description": "Exact immutable Zed source selected by planning.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NixInteropArtifact" + }, + "file_name": { + "description": "Safe artifact basename, e.g. `acme-tool-1.2.3.tar.gz`.", + "type": "string" + }, + "lock_sha256": { + "description": "SHA-256 of exact `.zpkg.lock` bytes.", + "type": "string" + }, + "manifest_sha256": { + "description": "SHA-256 of exact `.zpkg.toml` bytes, including comments and formatting.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "file_name", + "artifact", + "manifest_sha256", + "lock_sha256" + ] + }, + "ResolvedNixExportIntent": { + "description": "Fully resolved author intent. Unlike manifest intent, the package attribute\nis no longer optional and non-semantic arrays are canonicalized.", + "type": "object", + "properties": { + "attribute": { + "type": "string" + }, + "mode": { + "$ref": "#/$defs/NixExportMode" + }, + "outputs": { + "type": "array", + "items": { + "type": "string" + } + }, + "systems": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [ + "mode", + "attribute", + "systems", + "outputs" + ] + } + } +} diff --git a/schemas/nix-export-section.json b/schemas/nix-export-section.json new file mode 100644 index 0000000..f3f28b1 --- /dev/null +++ b/schemas/nix-export-section.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "NixExportSection", + "description": "Author intent for exporting a package or target to Nix.\n\nThis structure contains no realized store paths, hashes, commands,\ncredentials, cache keys, or service deployment policy. Those belong in a\nversioned adapter record after planning/realization.", + "type": "object", + "properties": { + "attribute": { + "description": "Optional Nix package attribute. Omit it to use the Zed package name.", + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/$defs/NixExportMode", + "default": "artifact" + }, + "outputs": { + "description": "Explicit derivation outputs. Contract v1 never silently selects the\nfirst output of a multi-output derivation.", + "type": "array", + "items": { + "type": "string" + } + }, + "systems": { + "description": "Explicit Nix systems this package claims to support.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "$defs": { + "NixExportMode": { + "oneOf": [ + { + "description": "Export the exact immutable Zed artifact. Source-builder translation is\nintentionally not inferred from native manifests in contract v1.", + "type": "string", + "const": "artifact" + } + ] + } + } +} diff --git a/schemas/oci-adapter-record.json b/schemas/oci-adapter-record.json new file mode 100644 index 0000000..5443550 --- /dev/null +++ b/schemas/oci-adapter-record.json @@ -0,0 +1,207 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OciAdapterRecord", + "description": "Final immutable provenance record connecting a Zed package identity to one\nOCI manifest digest and all of its typed blobs.", + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "config": { + "$ref": "#/$defs/OciDescriptor" + }, + "layers": { + "type": "array", + "items": { + "$ref": "#/$defs/OciLayer" + } + }, + "manifest": { + "$ref": "#/$defs/OciDescriptor" + }, + "package": { + "$ref": "#/$defs/OciPackageIdentity" + }, + "reference": { + "$ref": "#/$defs/OciReference" + }, + "schema": { + "type": "string" + }, + "subject": { + "anyOf": [ + { + "$ref": "#/$defs/OciDescriptor" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "schema", + "package", + "reference", + "manifest", + "config", + "layers" + ], + "$defs": { + "OciDescriptor": { + "description": "One immutable OCI descriptor.", + "type": "object", + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "digest": { + "$ref": "#/$defs/OciDigest" + }, + "mediaType": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "mediaType", + "digest", + "size" + ] + }, + "OciDigest": { + "description": "One canonical OCI content digest.\n\nContract v1 intentionally accepts only lowercase SHA-256 digests. OCI can\ncarry other digest algorithms, but Zed's existing artifact and lock model\nis SHA-256 based; accepting an algorithm the rest of the system cannot\nverify would create a false portability guarantee.", + "type": "string" + }, + "OciLayer": { + "description": "One typed layer in the finalized artifact record.", + "type": "object", + "properties": { + "descriptor": { + "$ref": "#/$defs/OciDescriptor" + }, + "kind": { + "$ref": "#/$defs/OciLayerKind" + }, + "platform": { + "anyOf": [ + { + "$ref": "#/$defs/OciPlatform" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "descriptor" + ] + }, + "OciLayerKind": { + "description": "Semantic role of a layer within a Zed OCI artifact.", + "type": "string", + "enum": [ + "package-tar-gz", + "package-zip", + "manifest", + "lockfile", + "binary", + "spdx-sbom", + "cyclone-dx-sbom", + "provenance" + ] + }, + "OciPackageIdentity": { + "description": "Public Zed identity associated with an OCI artifact.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version" + ] + }, + "OciPlatform": { + "description": "OCI platform selector for multi-platform package payloads.", + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "os": { + "type": "string" + }, + "variant": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "os", + "architecture" + ] + }, + "OciReference": { + "description": "A normalized OCI registry reference.\n\nThe textual form is `oci://registry/repository[:tag][@sha256:digest]`.\nAt least one tag or digest is required. A final adapter record additionally\nrequires the digest because a tag alone is mutable.", + "type": "object", + "properties": { + "digest": { + "anyOf": [ + { + "$ref": "#/$defs/OciDigest" + }, + { + "type": "null" + } + ] + }, + "registry": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "tag": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "registry", + "repository" + ] + } + } +} diff --git a/schemas/package-list-response.json b/schemas/package-list-response.json new file mode 100644 index 0000000..78aecd9 --- /dev/null +++ b/schemas/package-list-response.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PackageListResponse", + "description": "Response for `GET /v1/packages` (list all).", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/PackageSummary" + } + }, + "total": { + "description": "Total packages matching the filter (before limit/offset).", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "items", + "total" + ], + "$defs": { + "PackageSummary": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "latest": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "tags": { + "description": "Free-form tags for filtering/discovery.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name" + ] + } + } +} diff --git a/schemas/package-metadata.json b/schemas/package-metadata.json new file mode 100644 index 0000000..5079cb0 --- /dev/null +++ b/schemas/package-metadata.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PackageMetadata", + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "latest": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "repo_url": { + "type": "string" + }, + "tags": { + "description": "Free-form tags for filtering/discovery (multi-tag lookup).", + "type": "array", + "items": { + "type": "string" + } + }, + "vcs": { + "$ref": "#/$defs/Vcs" + }, + "version_scheme": { + "description": "How this package's versions should be interpreted (semver by default).", + "$ref": "#/$defs/VersionScheme" + }, + "versions": { + "description": "All published, non-yanked versions, newest first.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name", + "vcs", + "repo_url", + "versions" + ], + "$defs": { + "Vcs": { + "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", + "type": "string", + "enum": [ + "git", + "hg", + "jj", + "sapling", + "fossil", + "pijul" + ] + }, + "VersionScheme": { + "description": "How a package's `version` string (and its published tags) should be\ninterpreted. Defaults to [`VersionScheme::Semver`], which covers the vast\nmajority of modern ecosystems.", + "oneOf": [ + { + "description": "Semantic Versioning. `package.version` must be valid semver; ranges\n(`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version.", + "type": "string", + "const": "semver" + }, + { + "description": "Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver\ntotal order (leading zeros dropped, padded to major.minor.patch) so the\nsame range algebra applies. See [`normalize_calver`].", + "type": "string", + "const": "calver" + }, + { + "description": "Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra:\na requirement must match a published version **exactly**.", + "type": "string", + "const": "opaque" + } + ] + } + } +} diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json new file mode 100644 index 0000000..4fa785e --- /dev/null +++ b/schemas/publish-meta.json @@ -0,0 +1,677 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PublishMeta", + "description": "JSON half of the multipart publish request; the artifact bytes travel in\nthe [`PUBLISH_ARTIFACT_FIELD`] part.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "manifest": { + "$ref": "#/$defs/Manifest" + }, + "sha256": { + "description": "Client-computed sha256 of the uploaded archive; the server recomputes\nand rejects on mismatch.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vcs_commit": { + "type": [ + "string", + "null" + ] + }, + "vcs_tag": { + "description": "Tag that exists in the source repository for this version.", + "type": "string" + } + }, + "required": [ + "manifest", + "vcs_tag", + "sha256", + "size" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "BuildSection": { + "description": "A post-extract build step. Because compiled output is OS/arch-specific,\nzed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the\nsource and caches the result in a build cache keyed by\n`(source sha256, target triple, command)` — separate from the universal,\nplatform-independent source store (zed-docs issue #5).", + "type": "object", + "properties": { + "command": { + "description": "Command executed after extraction, e.g. `make` or `cargo build --release`.", + "type": "string" + }, + "outputs": { + "description": "Paths (relative to the package root) to keep from the staging build.\nEmpty means keep everything.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "command" + ] + }, + "Ecosystem": { + "description": "How a consumer's build system takes a dependency in: the resolution\nmechanism, not the language. Drives the install guard and which toolchain\nwiring file `zed install` writes.", + "oneOf": [ + { + "type": "string", + "enum": [ + "cargo", + "cmake", + "composer", + "cran", + "gem", + "gomod", + "hackage", + "julia", + "luarocks", + "matlab", + "nimble", + "npm", + "nuget", + "opam", + "psgallery", + "pypi", + "shards", + "shell", + "swiftpm", + "zig" + ] + }, + { + "description": "No ecosystem constraint. Installs into any project; never gated.", + "type": "string", + "const": "universal" + }, + { + "description": "Elixir, Erlang and Gleam share Hex and the BEAM.", + "type": "string", + "const": "hex" + }, + { + "description": "Anything consumed off a JVM classpath: Java, Kotlin, Scala, Clojure.", + "type": "string", + "const": "jvm" + }, + { + "description": "Dart and Flutter (`pub`).", + "type": "string", + "const": "pub" + } + ] + }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, + "InstallHooksSection": { + "description": "Package-local lifecycle hooks. Commands are author-controlled shell code,\nbut execution is separately consented by the installer and occurs only in\na writable staging tree.", + "type": "object", + "properties": { + "post-install": { + "type": "array", + "items": { + "type": "string" + } + }, + "pre-install": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "InstallSection": { + "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", + "type": "object", + "properties": { + "adapter": { + "description": "Force the ecosystem adapter: `node`, `java`, or `none`. Omitted =\nauto-detect (or the CLI `--adapter`). Mirrors the CLI's values.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Project-relative directory for the installed tree (`//`).\nDefaults to `zed_modules`. Common overrides: `.vendor/.zed`, `.deps/.zed`.\nMust be a safe relative path (no leading `/`, no `..`).", + "type": [ + "string", + "null" + ] + }, + "target": { + "description": "Which language subtree to take from **polyglot** dependencies (see\n[`TargetSection`]). Omitted = infer from the project (`package.json` →\n`node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a\ntarget a dependency does not publish is an error rather than a silent\nfallback: the consumer asked for something specific.", + "type": [ + "string", + "null" + ] + } + } + }, + "Language": { + "description": "The language a package's code is written for. Names the published package\n(`-`), and implies an [`Ecosystem`] via\n[`Language::ecosystem`].\n\nCanonical tokens are the colloquial names people search for — `nodejs`, not\n`node`; `golang`, not `go` — because that is what ends up in a package name\na human has to recall. The shorter spellings, and near-synonyms like\n`typescript`, are accepted by [`Language::from_token`] and by umbrella\nvariant aliases, so both spellings resolve.", + "oneOf": [ + { + "type": "string", + "enum": [ + "c", + "clojure", + "cpp", + "crystal", + "csharp", + "dart", + "elixir", + "erlang", + "fsharp", + "gleam", + "golang", + "haskell", + "java", + "julia", + "kotlin", + "lua", + "matlab", + "nim", + "ocaml", + "php", + "powershell", + "python", + "r", + "ruby", + "rust", + "scala", + "shell", + "swift", + "zig" + ] + }, + { + "description": "Not language-specific: protocol schemas, docs, `.proto` files, an\numbrella package. Never subject to the install ecosystem guard.", + "type": "string", + "const": "universal" + }, + { + "description": "Flutter is a framework rather than a language, but a Flutter client is a\nseparate package from a plain Dart one (different `pubspec.yaml`,\ndifferent deps), so it gets its own token.", + "type": "string", + "const": "flutter" + }, + { + "description": "Covers JavaScript and TypeScript alike: for a client library they are\none artifact published to one ecosystem, so `typescript`, `javascript`,\n`ts`, `js` and `node` all resolve here rather than splitting the package.", + "type": "string", + "const": "nodejs" + }, + { + "description": "Rust compiled to WebAssembly. Distinct from [`Language::Rust`] because\nthe artifact is consumed by a JS bundler, not by Cargo.", + "type": "string", + "const": "rust-wasm" + } + ] + }, + "Manifest": { + "description": "The `.zpkg.toml` manifest at the root of every package repository.\nTOML only — never YAML or JSON.\n\n```toml\n[package]\norg = \"acme\"\nname = \"http-kit\"\nversion = \"1.2.0\"\ndescription = \"Tiny HTTP helpers\"\nlicense = \"MIT\"\n\n[package.repository]\nvcs = \"git\"\nurl = \"https://github.com/acme/http-kit\"\n\n[dependencies]\n\"acme/logkit\" = \"^0.3\"\n\n[publish]\nexclude = [\"benches/**\"]\nsmoke_test = \"sh scripts/smoke.sh\"\n```", + "type": "object", + "properties": { + "bin": { + "description": "Executables this package exposes, keyed by command name, valued by a\npath relative to the package root. On install they are hoisted into\n`zed_modules/.bin/` and runnable via `zed run ` (zed-docs\nissue #7).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "build": { + "description": "This package's own post-extract build step (compiled extensions,\ncodegen), run when the package ships source that needs compiling.\nBuilds run in an isolated staging copy — never inside the immutable\nsource store — and results are cached per (sha256, target, command).", + "anyOf": [ + { + "$ref": "#/$defs/BuildSection" + }, + { + "type": "null" + } + ] + }, + "build-dependencies": { + "description": "Tools needed only while running this package's `[build]` command\n(compilers, codegen). They are made available in the build sandbox and\nnever linked into a consumer's `zed_modules/`. See [`BuildSection`]\nand zed-docs issue #5. Canonical TOML key is Cargo-style\n`[build-dependencies]`; the snake_case spelling is accepted on read.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "dependencies": { + "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "hooks": { + "description": "Package-local lifecycle hooks. They run in a writable staging copy,\nnever in the immutable source store and never in the consumer project.\n`pre-install` runs before `[build]`; `post-install` runs after it and\nbefore the finalized artifact is promoted to the platform cache.", + "$ref": "#/$defs/InstallHooksSection" + }, + "install": { + "description": "Where zed materializes the (few, hand-picked) dependencies it sources —\nzed complements npm/maven/etc. rather than replacing them, so this dir\nsits alongside the native one and the ecosystem adapter wires it into\nthe toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir`\ndefaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or\n`.deps/.zed`.", + "$ref": "#/$defs/InstallSection" + }, + "native-dependencies": { + "description": "Host-native packages required before this package's install hooks or\nbuild step can run. Keys are supported package-manager names (`apt`,\n`apk`, `brew`, `nix`, ...); values are package specs passed as argv,\nnever interpolated into a shell command. Installing these prerequisites\nis an explicitly consented zed operation, separate from build-hook\nconsent.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "overrides": { + "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing `[build]` step without waiting on upstream).", + "$ref": "#/$defs/OverridesSection" + }, + "package": { + "$ref": "#/$defs/PackageSection" + }, + "publish": { + "$ref": "#/$defs/PublishSection", + "default": { + "exclude": [], + "include_readme": false, + "smoke_test": null, + "tag_format": "v{version}" + } + }, + "scripts": { + "$ref": "#/$defs/ScriptsSection", + "default": { + "test": null + } + }, + "targets": { + "description": "Language subtrees for a **polyglot package** — one repo shipping the\nsame library for several ecosystems (e.g. `node/`, `python/`, `go/`).\nKeyed by ecosystem name; the value says which subdirectory is that\necosystem's package root. On install the consumer resolves one target\nand only that subtree is materialized, so a Python project gets the\nPython source at its import root rather than a tree it has to reach\ninto. Absent (the common case) = single-language package, whole tree.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/TargetSection" + } + }, + "workspace": { + "description": "Monorepo workspace declaration (zed-docs issue #7); only meaningful in\na workspace root manifest. When present, `zed install` at this root\nresolves every member against one store and writes one `.zpkg.lock`;\nmember→member dependencies link by path instead of going through the\nregistry.", + "anyOf": [ + { + "$ref": "#/$defs/WorkspaceSection" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "package" + ] + }, + "NativeRegistry": { + "type": "string", + "enum": [ + "npm", + "crates-io", + "pub.dev", + "pypi", + "maven-central", + "rubygems", + "nuget", + "packagist", + "go-modules" + ] + }, + "NativeReleaseSection": { + "type": "object", + "properties": { + "forge": { + "description": "Optional copies in package registries run by source forges. The native\nregistry remains the canonical ecosystem destination; these mirrors\nuse the same native package format and version.", + "type": "array", + "items": { + "$ref": "#/$defs/ForgeRegistry" + } + }, + "package": { + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + }, + "tag_format": { + "description": "Optional VCS tag template for native ecosystems whose package is\nresolved from a tag rather than uploaded. It must contain `{version}`.\nGo modules below a repository subdirectory need the directory prefix,\nfor example `clients/go/v{version}`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "registry", + "package" + ] + }, + "NixExportMode": { + "oneOf": [ + { + "description": "Export the exact immutable Zed artifact. Source-builder translation is\nintentionally not inferred from native manifests in contract v1.", + "type": "string", + "const": "artifact" + } + ] + }, + "NixExportSection": { + "description": "Author intent for exporting a package or target to Nix.\n\nThis structure contains no realized store paths, hashes, commands,\ncredentials, cache keys, or service deployment policy. Those belong in a\nversioned adapter record after planning/realization.", + "type": "object", + "properties": { + "attribute": { + "description": "Optional Nix package attribute. Omit it to use the Zed package name.", + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/$defs/NixExportMode", + "default": "artifact" + }, + "outputs": { + "description": "Explicit derivation outputs. Contract v1 never silently selects the\nfirst output of a multi-output derivation.", + "type": "array", + "items": { + "type": "string" + } + }, + "systems": { + "description": "Explicit Nix systems this package claims to support.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "OverridesSection": { + "description": "Consumer-side dependency patches, keyed by `org/name`.", + "type": "object", + "properties": { + "build": { + "description": "Replace or provide a dependency's `[build]` step.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/BuildSection" + } + } + } + }, + "PackageSection": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "ecosystem": { + "description": "How consumers take this package in: `jvm`, `npm`, `gomod`, … Drives the\ninstall-time guard that refuses to drop a Java client into a Node\nproject, and picks the toolchain wiring `zed install` writes.\n\nOmit it and it is derived from `language` (see\n[`Language::ecosystem`]) — declare it only when the language does not\ndetermine consumption. Read through [`PackageSection::ecosystem`]\nrather than touching this field, so the fallback always applies.", + "$ref": "#/$defs/Ecosystem" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "description": "The language this package's code targets. A multi-language repository\npublishes one package per language, all at one version, and each names\nits own — `acme-clients-java`, `acme-clients-nodejs`. Defaults to\n`universal` (language-agnostic), which is never subject to the install\necosystem guard.", + "$ref": "#/$defs/Language" + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Package name, unique within the org. Lowercase slug.", + "type": "string" + }, + "org": { + "description": "Namespace the package is published under. Lowercase slug.", + "type": "string" + }, + "repository": { + "$ref": "#/$defs/RepositorySection" + }, + "version": { + "description": "Version of this package, interpreted according to `version_scheme`\n(semver by default).", + "type": "string" + }, + "version_scheme": { + "description": "How `version` (and published tags) should be interpreted. Semver by\ndefault; `calver` for calendar versions, `opaque` for arbitrary tags.", + "$ref": "#/$defs/VersionScheme" + } + }, + "required": [ + "org", + "name", + "version", + "repository" + ] + }, + "PublishSection": { + "type": "object", + "properties": { + "exclude": { + "description": "Extra glob patterns to exclude on top of the built-in defaults.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "include_readme": { + "description": "Keep README files in the published artifact (stripped by default).", + "type": "boolean", + "default": false + }, + "native": { + "description": "Optional native-registry route for a single-language package whose\npackage-manager manifest lives at the repository root. Polyglot\npackages declare this metadata on each `[targets.*.native]` section\ninstead.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + }, + "nix": { + "description": "Optional deterministic export of this single-language package as a\nstandalone Nix flake. Nix is a typed interop adapter, not a native\nregistry destination, so its intent remains separate from `native`.", + "anyOf": [ + { + "$ref": "#/$defs/NixExportSection" + }, + { + "type": "null" + } + ] + }, + "smoke_test": { + "description": "Command run by `zed r2g` (alias `zed test-local`) inside a throwaway\nconsumer project that has this package installed the same way a real\nconsumer would.", + "type": [ + "string", + "null" + ], + "default": null + }, + "tag_format": { + "description": "VCS tag template that must exist and point at the published commit.\n`{version}` is substituted with `package.version`.", + "type": "string", + "default": "v{version}" + } + } + }, + "RepositorySection": { + "description": "Where the package's source of truth lives. Any Git or Mercurial host\nworks: GitHub, GitLab, Bitbucket, or a self-hosted server.", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "vcs": { + "$ref": "#/$defs/Vcs", + "default": "git" + } + }, + "required": [ + "url" + ] + }, + "ScriptsSection": { + "type": "object", + "properties": { + "test": { + "description": "Test command run from the repository (not from published artifacts;\ntests are stripped at publish time).", + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "TargetSection": { + "description": "One ecosystem's slice of a polyglot package — and, on publish, its own\nindependently installable package.\n\nA repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and\n`clients/go` declares one target each. `zed publish` then emits **one\nartifact per target**, named `-` by default:\n\n```text\nfiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only\nfiducia/fiducia-clients-java@1.1.2 <- clients/java only\nfiducia/fiducia-clients-golang@1.1.2 <- clients/go only\n```\n\nOne source of truth and one version in the repo; N packages on the wire.\nA Java consumer downloads only Java bytes — the decisive advantage over\nshipping one fat artifact and slicing it at install time.", + "type": "object", + "properties": { + "adapter": { + "description": "Ecosystem adapter consumers of THIS target should use (`node`, `java`,\n`none`). Recorded in the published per-target manifest so a consumer\ngets the right wiring without configuring it.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Package-relative directory that is this ecosystem's package root, e.g.\n`python` or `clients/go`. Must be a safe relative path (no leading `/`,\nno `..`) so a target can never escape the package.", + "type": "string" + }, + "ecosystem": { + "description": "Override the ecosystem this target publishes into. Omit it (the normal\ncase) and it is derived from the target key via [`Language::ecosystem`].\nDeclare it when the key does not determine consumption — a `rust-wasm`\ntarget is consumed by a JS bundler, not by Cargo.", + "$ref": "#/$defs/Ecosystem" + }, + "hooks": { + "description": "Target-specific lifecycle hooks, appended after package-level hooks in\neach phase when the target is selected.", + "$ref": "#/$defs/InstallHooksSection" + }, + "name": { + "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", + "type": [ + "string", + "null" + ] + }, + "native": { + "description": "Optional routing to this target's native ecosystem registry. This is\ndeclarative metadata only: the native manifest remains authoritative,\nand arbitrary commands are intentionally not representable here.\n\nNote the difference from [`TargetSection::ecosystem`]: `native` is\n**outbound** (where this slice is mirrored to — npm, crates.io), while\n`ecosystem` is **inbound** (what toolchain a consumer must have to\ninstall it from zed). They describe the same ecosystem from two sides,\nso when both are set they must agree — see\n[`NativeRegistry::ecosystem`] and the check in [`Manifest::validate`].", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + }, + "native-dependencies": { + "description": "Native prerequisites added by this target. Entries merge with the\npackage-level `[native-dependencies]` table when this target is selected.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "nix": { + "description": "Optional deterministic Nix export intent for this isolated target.", + "anyOf": [ + { + "$ref": "#/$defs/NixExportSection" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "dir" + ] + }, + "Vcs": { + "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", + "type": "string", + "enum": [ + "git", + "hg", + "jj", + "sapling", + "fossil", + "pijul" + ] + }, + "VersionScheme": { + "description": "How a package's `version` string (and its published tags) should be\ninterpreted. Defaults to [`VersionScheme::Semver`], which covers the vast\nmajority of modern ecosystems.", + "oneOf": [ + { + "description": "Semantic Versioning. `package.version` must be valid semver; ranges\n(`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version.", + "type": "string", + "const": "semver" + }, + { + "description": "Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver\ntotal order (leading zeros dropped, padded to major.minor.patch) so the\nsame range algebra applies. See [`normalize_calver`].", + "type": "string", + "const": "calver" + }, + { + "description": "Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra:\na requirement must match a published version **exactly**.", + "type": "string", + "const": "opaque" + } + ] + }, + "WorkspaceSection": { + "description": "Monorepo workspace membership. `members` are glob patterns (relative to\nthe workspace root) selecting directories that each contain a `.zpkg.toml`,\ne.g. `[\"packages/*\", \"apps/*\"]`. Dependencies that resolve to a member are\nlinked straight to the member's source directory instead of going through\nthe registry.", + "type": "object", + "properties": { + "members": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/schemas/publish-response.json b/schemas/publish-response.json new file mode 100644 index 0000000..30d0d41 --- /dev/null +++ b/schemas/publish-response.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PublishResponse", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version", + "sha256" + ] +} diff --git a/schemas/search-response.json b/schemas/search-response.json new file mode 100644 index 0000000..86d2a0c --- /dev/null +++ b/schemas/search-response.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SearchResponse", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/PackageSummary" + } + }, + "query": { + "type": "string" + } + }, + "required": [ + "query", + "items" + ], + "$defs": { + "PackageSummary": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "latest": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "tags": { + "description": "Free-form tags for filtering/discovery.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name" + ] + } + } +} diff --git a/schemas/semantic-search-request.json b/schemas/semantic-search-request.json new file mode 100644 index 0000000..9682ee0 --- /dev/null +++ b/schemas/semantic-search-request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SemanticSearchRequest", + "description": "Body of `POST /v1/search/semantic` (RAG). The caller computes the query\nembedding with its model; the server ranks stored package embeddings from\nthe SAME model by cosine distance. Vectors up to 2050 dims are accepted and\nzero-padded server-side.", + "type": "object", + "properties": { + "embedding": { + "description": "The query embedding (native width; padded to 2050 server-side).", + "type": "array", + "items": { + "type": "number", + "format": "float" + } + }, + "limit": { + "type": "integer", + "format": "uint32", + "default": 20, + "minimum": 0 + }, + "model": { + "description": "Embedding model id, e.g. `openai/text-embedding-3-small`. Only stored\nembeddings from this model are searched.", + "type": "string" + }, + "tags": { + "description": "Optional tag filter: results must carry all of these tags.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "model", + "embedding" + ] +} diff --git a/schemas/semantic-search-response.json b/schemas/semantic-search-response.json new file mode 100644 index 0000000..9a89e4c --- /dev/null +++ b/schemas/semantic-search-response.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SemanticSearchResponse", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/SemanticHit" + } + } + }, + "required": [ + "items" + ], + "$defs": { + "SemanticHit": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "distance": { + "description": "Cosine distance (0 = identical direction, 2 = opposite). Lower is nearer.", + "type": "number", + "format": "double" + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name", + "distance" + ] + } + } +} diff --git a/schemas/sync-change-event.json b/schemas/sync-change-event.json new file mode 100644 index 0000000..7c46c10 --- /dev/null +++ b/schemas/sync-change-event.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncChangeEvent", + "description": "The change envelope both transports decode to (mirrors zed-sync\n`ChangeEvent`). `version` is the ONLY reconciliation key; `sync_sequence`\nis the catch-up cursor and never feeds reconciliation.", + "type": "object", + "properties": { + "at_ms": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "op": { + "$ref": "#/$defs/SyncOp" + }, + "row": true, + "sync_sequence": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "table": { + "type": "string" + }, + "version": { + "$ref": "#/$defs/Hlc" + }, + "write_key": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "table", + "op", + "id", + "version", + "at_ms" + ], + "$defs": { + "Hlc": { + "description": "Hybrid Logical Clock stamp — the reconciliation key. Total order:\n`wall_ms`, then `counter`, then `actor`.", + "type": "object", + "properties": { + "actor": { + "type": "string" + }, + "counter": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "wall_ms": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "wall_ms", + "counter", + "actor" + ] + }, + "SyncOp": { + "type": "string", + "enum": [ + "upsert", + "delete" + ] + } + } +} diff --git a/schemas/sync-conflict-resolution.json b/schemas/sync-conflict-resolution.json new file mode 100644 index 0000000..620e053 --- /dev/null +++ b/schemas/sync-conflict-resolution.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncConflictResolution", + "description": "How a dirty-vs-newer-remote conflict resolves (mirrors zed-sync\n`ConflictResolution`).", + "type": "string", + "enum": [ + "server_wins", + "last_write_wins" + ] +} diff --git a/schemas/sync-error-policy.json b/schemas/sync-error-policy.json new file mode 100644 index 0000000..03afa98 --- /dev/null +++ b/schemas/sync-error-policy.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncErrorPolicy", + "description": "How write/flush errors are surfaced (mirrors zed-sync `ErrorPolicy`).", + "type": "string", + "enum": [ + "throw_only", + "emit_only", + "throw_and_emit", + "silent" + ] +} diff --git a/schemas/sync-write-mode.json b/schemas/sync-write-mode.json new file mode 100644 index 0000000..f2f2fe7 --- /dev/null +++ b/schemas/sync-write-mode.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncWriteMode", + "description": "Per-write optimism level (mirrors zed-sync `WriteMode`). Enum, not a\nboolean: a write names its exact behavior.", + "type": "string", + "enum": [ + "local_only", + "optimistic_queue", + "optimistic_await_ack", + "server_first", + "server_only" + ] +} diff --git a/schemas/version-metadata.json b/schemas/version-metadata.json new file mode 100644 index 0000000..f458cb7 --- /dev/null +++ b/schemas/version-metadata.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "VersionMetadata", + "type": "object", + "properties": { + "download_url": { + "description": "Absolute or registry-relative URL the artifact can be fetched from.", + "type": "string" + }, + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "published_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "sha256": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vcs_commit": { + "type": [ + "string", + "null" + ] + }, + "vcs_tag": { + "type": "string" + }, + "version": { + "type": "string" + }, + "yanked": { + "type": "boolean", + "default": false + } + }, + "required": [ + "org", + "name", + "version", + "sha256", + "size", + "vcs_tag", + "download_url", + "published_at" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + } + } +} diff --git a/schemas/yank-request.json b/schemas/yank-request.json new file mode 100644 index 0000000..28324b5 --- /dev/null +++ b/schemas/yank-request.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "YankRequest", + "description": "Body for the yank route. `yanked: false` restores a yanked version.", + "type": "object", + "properties": { + "yanked": { + "type": "boolean" + } + }, + "required": [ + "yanked" + ] +} diff --git a/schemas/yank-response.json b/schemas/yank-response.json new file mode 100644 index 0000000..4dfb9f7 --- /dev/null +++ b/schemas/yank-response.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "YankResponse", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "version": { + "type": "string" + }, + "yanked": { + "type": "boolean" + } + }, + "required": [ + "org", + "name", + "version", + "yanked" + ] +} diff --git a/src/dart/analysis_options.yaml b/src/dart/analysis_options.yaml new file mode 100644 index 0000000..4e3f1dc --- /dev/null +++ b/src/dart/analysis_options.yaml @@ -0,0 +1,12 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # Everything here is generated: a warning means the generator emitted bad + # Dart, which is a bug to fix in codegen/generate.mjs, not to tolerate. + unused_import: error + dead_code: error diff --git a/src/dart/lib/api_error.dart b/src/dart/lib/api_error.dart new file mode 100644 index 0000000..4a1b4f1 --- /dev/null +++ b/src/dart/lib/api_error.dart @@ -0,0 +1,27 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/api-error.json + +/// Error body returned with any non-2xx status. +class ApiError { + const ApiError({ + required this.code, + required this.message, + }); + + factory ApiError.fromJson(Map json) => ApiError( + code: json['code'] as String, + message: json['message'] as String, + ); + + /// Stable machine-readable code, e.g. `not_found`, `sha256_mismatch`, `tag_not_found`, + /// `unauthorized`, `org_taken`. + final String code; + + final String message; + + Map toJson() => { + 'code': code, + 'message': message, + }; +} diff --git a/src/dart/lib/audit_integrity_response.dart b/src/dart/lib/audit_integrity_response.dart new file mode 100644 index 0000000..f9a74e6 --- /dev/null +++ b/src/dart/lib/audit_integrity_response.dart @@ -0,0 +1,52 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/audit-integrity-response.json + +/// The result of walking an org's audit chain end to end. +class AuditIntegrityResponse { + const AuditIntegrityResponse({ + required this.entriesChecked, + this.firstBadSeq, + this.headHash, + required this.intact, + required this.org, + this.problem, + }); + + factory AuditIntegrityResponse.fromJson(Map json) => AuditIntegrityResponse( + entriesChecked: (json['entries_checked'] as num).toInt(), + firstBadSeq: (json['first_bad_seq'] as num?)?.toInt(), + headHash: json['head_hash'] as String?, + intact: json['intact'] as bool, + org: json['org'] as String, + problem: json['problem'] as String?, + ); + + final int entriesChecked; + + /// The `seq` where verification first failed, if any. + final int? firstBadSeq; + + /// The newest entry's hash — an anchor an operator can record externally so that later + /// truncation of the whole tail is also detectable. + final String? headHash; + + /// True only when every entry's hash recomputes and every link matches. + final bool intact; + + final String org; + + /// Machine-readable failure kind: `hash_mismatch` (an entry was edited), `broken_link` (an + /// entry's `prev_hash` does not match its predecessor), or `sequence_gap` (an entry was + /// deleted). + final String? problem; + + Map toJson() => { + 'entries_checked': entriesChecked, + 'first_bad_seq': firstBadSeq, + 'head_hash': headHash, + 'intact': intact, + 'org': org, + 'problem': problem, + }; +} diff --git a/src/dart/lib/audit_log_response.dart b/src/dart/lib/audit_log_response.dart new file mode 100644 index 0000000..d6308af --- /dev/null +++ b/src/dart/lib/audit_log_response.dart @@ -0,0 +1,135 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/audit-log-response.json + +/// A state-changing action recorded in an org's audit log. Reads are never audited — only +/// mutations of published state and of the namespace itself, so the log answers "who +/// changed what" without drowning in traffic. +enum AuditAction { + /// A version was published. + publish('publish'), + /// A version was yanked (hidden from fresh resolution). + yank('yank'), + /// A previously yanked version was restored. + unyank('unyank'), + /// The org namespace was claimed. + orgClaim('org_claim'); + + const AuditAction(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static AuditAction fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown AuditAction: $value'), + ); + + static AuditAction? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} + +/// One audit-log record. The actor is identified by the *token* that acted — its name and +/// role, never its secret — which is the identity a registry actually has (zed-docs issue +/// #7 governance). +class AuditEntry { + const AuditEntry({ + required this.action, + this.actionKind, + required this.actorRole, + required this.actorTokenName, + required this.at, + this.detail, + this.entryHash, + this.prevHash, + this.seq = 0, + required this.subject, + }); + + factory AuditEntry.fromJson(Map json) => AuditEntry( + action: json['action'] as String, + actionKind: AuditAction.maybeFromJson(json['action_kind'] as String?), + actorRole: json['actor_role'] as String, + actorTokenName: json['actor_token_name'] as String, + at: json['at'] as String, + detail: json['detail'] as String?, + entryHash: json['entry_hash'] as String?, + prevHash: json['prev_hash'] as String?, + seq: json['seq'] == null + ? 0 + : (json['seq'] as num).toInt(), + subject: json['subject'] as String, + ); + + /// Raw action string; `action_kind` is the parsed form when recognized. + final String action; + + /// Parsed action, absent when this server build doesn't recognize it. + final AuditAction? actionKind; + + /// The acting token's role (`owner`/`publisher`/`reader`, or `admin` for unscoped tokens). + final String actorRole; + + /// Human-readable name of the token that acted. + final String actorTokenName; + + /// RFC 3339 timestamp of the action. + final String at; + + /// Extra context, e.g. the artifact sha256 for a publish. + final String? detail; + + /// `sha256(audit_chain_preimage(..))` for this entry, lowercase hex. Empty from a pre-chain + /// server. + final String? entryHash; + + /// The previous entry's `entry_hash`; `None` for the first entry in an org's chain. Linking + /// each entry to its predecessor is what makes a silent deletion or edit detectable. + final String? prevHash; + + /// Position in the org's append-only chain, starting at 1. Gaps mean entries were deleted. + /// Defaults to 0 when read from a server that predates the chain. + final int seq; + + /// What was acted on, e.g. `acme/http-kit@1.2.0` or the org slug. + final String subject; + + Map toJson() => { + 'action': action, + 'action_kind': actionKind?.toJson(), + 'actor_role': actorRole, + 'actor_token_name': actorTokenName, + 'at': at, + 'detail': detail, + 'entry_hash': entryHash, + 'prev_hash': prevHash, + 'seq': seq, + 'subject': subject, + }; +} + +class AuditLogResponse { + const AuditLogResponse({ + required this.entries, + required this.org, + }); + + factory AuditLogResponse.fromJson(Map json) => AuditLogResponse( + entries: (json['entries'] as List).map((e) => AuditEntry.fromJson(e as Map)).toList(), + org: json['org'] as String, + ); + + /// Most recent first. + final List entries; + + final String org; + + Map toJson() => { + 'entries': entries.map((e) => e.toJson()).toList(), + 'org': org, + }; +} diff --git a/src/dart/lib/claim_org_request.dart b/src/dart/lib/claim_org_request.dart new file mode 100644 index 0000000..d87a751 --- /dev/null +++ b/src/dart/lib/claim_org_request.dart @@ -0,0 +1,19 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/claim-org-request.json + +class ClaimOrgRequest { + const ClaimOrgRequest({ + required this.slug, + }); + + factory ClaimOrgRequest.fromJson(Map json) => ClaimOrgRequest( + slug: json['slug'] as String, + ); + + final String slug; + + Map toJson() => { + 'slug': slug, + }; +} diff --git a/src/dart/lib/claim_org_response.dart b/src/dart/lib/claim_org_response.dart new file mode 100644 index 0000000..a4645af --- /dev/null +++ b/src/dart/lib/claim_org_response.dart @@ -0,0 +1,25 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/claim-org-response.json + +class ClaimOrgResponse { + const ClaimOrgResponse({ + required this.created, + required this.slug, + }); + + factory ClaimOrgResponse.fromJson(Map json) => ClaimOrgResponse( + created: json['created'] as bool, + slug: json['slug'] as String, + ); + + /// False when the caller already owned the org. + final bool created; + + final String slug; + + Map toJson() => { + 'created': created, + 'slug': slug, + }; +} diff --git a/src/dart/lib/common.dart b/src/dart/lib/common.dart new file mode 100644 index 0000000..154f76a --- /dev/null +++ b/src/dart/lib/common.dart @@ -0,0 +1,40 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: types shared by more than one schema + +class PackageSummary { + const PackageSummary({ + this.description, + this.latest, + required this.name, + required this.org, + this.tags, + }); + + factory PackageSummary.fromJson(Map json) => PackageSummary( + description: json['description'] as String?, + latest: json['latest'] as String?, + name: json['name'] as String, + org: json['org'] as String, + tags: (json['tags'] as List?)?.map((e) => e as String).toList(), + ); + + final String? description; + + final String? latest; + + final String name; + + final String org; + + /// Free-form tags for filtering/discovery. + final List? tags; + + Map toJson() => { + 'description': description, + 'latest': latest, + 'name': name, + 'org': org, + 'tags': tags, + }; +} diff --git a/src/dart/lib/package_list_response.dart b/src/dart/lib/package_list_response.dart new file mode 100644 index 0000000..5771cd5 --- /dev/null +++ b/src/dart/lib/package_list_response.dart @@ -0,0 +1,28 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/package-list-response.json + +import 'common.dart'; + +/// Response for `GET /v1/packages` (list all). +class PackageListResponse { + const PackageListResponse({ + required this.items, + required this.total, + }); + + factory PackageListResponse.fromJson(Map json) => PackageListResponse( + items: (json['items'] as List).map((e) => PackageSummary.fromJson(e as Map)).toList(), + total: (json['total'] as num).toInt(), + ); + + final List items; + + /// Total packages matching the filter (before limit/offset). + final int total; + + Map toJson() => { + 'items': items.map((e) => e.toJson()).toList(), + 'total': total, + }; +} diff --git a/src/dart/lib/package_metadata.dart b/src/dart/lib/package_metadata.dart new file mode 100644 index 0000000..56a42b0 --- /dev/null +++ b/src/dart/lib/package_metadata.dart @@ -0,0 +1,128 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/package-metadata.json + +class PackageMetadata { + const PackageMetadata({ + this.description, + this.latest, + required this.name, + required this.org, + required this.repoUrl, + this.tags, + required this.vcs, + this.versionScheme, + required this.versions, + }); + + factory PackageMetadata.fromJson(Map json) => PackageMetadata( + description: json['description'] as String?, + latest: json['latest'] as String?, + name: json['name'] as String, + org: json['org'] as String, + repoUrl: json['repo_url'] as String, + tags: (json['tags'] as List?)?.map((e) => e as String).toList(), + vcs: Vcs.fromJson(json['vcs'] as String), + versionScheme: VersionScheme.maybeFromJson(json['version_scheme'] as String?), + versions: (json['versions'] as List).map((e) => e as String).toList(), + ); + + final String? description; + + final String? latest; + + final String name; + + final String org; + + final String repoUrl; + + /// Free-form tags for filtering/discovery (multi-tag lookup). + final List? tags; + + final Vcs vcs; + + /// How this package's versions should be interpreted (semver by default). + final VersionScheme? versionScheme; + + /// All published, non-yanked versions, newest first. + final List versions; + + Map toJson() => { + 'description': description, + 'latest': latest, + 'name': name, + 'org': org, + 'repo_url': repoUrl, + 'tags': tags, + 'vcs': vcs.toJson(), + 'version_scheme': versionScheme?.toJson(), + 'versions': versions, + }; +} + +/// Version-control systems a package's source repository can live on. zed-pkg is +/// VCS-agnostic by design: what gets installed is always a registry artifact, and the VCS +/// is where provenance (tags) is anchored. Authors must create a matching tag on their +/// declared backing repo (GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, +/// Azure DevOps, CodeCommit, Radicle, or self-hosted) before publishing. `jj` and Sapling +/// are git-compatible and push to git remotes, so their provenance is verified through git +/// tags. +enum Vcs { + git('git'), + hg('hg'), + jj('jj'), + sapling('sapling'), + fossil('fossil'), + pijul('pijul'); + + const Vcs(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static Vcs fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown Vcs: $value'), + ); + + static Vcs? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} + +/// How a package's `version` string (and its published tags) should be interpreted. +/// Defaults to [`VersionScheme::Semver`], which covers the vast majority of modern +/// ecosystems. +enum VersionScheme { + /// Semantic Versioning. `package.version` must be valid semver; ranges (`^1.2`, `>=0.2 + /// <0.5`) resolve to the max satisfying stable version. + semver('semver'), + /// Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver total order + /// (leading zeros dropped, padded to major.minor.patch) so the same range algebra applies. + /// See [`normalize_calver`]. + calver('calver'), + /// Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra: a requirement + /// must match a published version **exactly**. + opaque('opaque'); + + const VersionScheme(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static VersionScheme fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown VersionScheme: $value'), + ); + + static VersionScheme? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} diff --git a/src/dart/lib/publish_response.dart b/src/dart/lib/publish_response.dart new file mode 100644 index 0000000..8e01f57 --- /dev/null +++ b/src/dart/lib/publish_response.dart @@ -0,0 +1,34 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/publish-response.json + +class PublishResponse { + const PublishResponse({ + required this.name, + required this.org, + required this.sha256, + required this.version, + }); + + factory PublishResponse.fromJson(Map json) => PublishResponse( + name: json['name'] as String, + org: json['org'] as String, + sha256: json['sha256'] as String, + version: json['version'] as String, + ); + + final String name; + + final String org; + + final String sha256; + + final String version; + + Map toJson() => { + 'name': name, + 'org': org, + 'sha256': sha256, + 'version': version, + }; +} diff --git a/src/dart/lib/search_response.dart b/src/dart/lib/search_response.dart new file mode 100644 index 0000000..3022915 --- /dev/null +++ b/src/dart/lib/search_response.dart @@ -0,0 +1,26 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/search-response.json + +import 'common.dart'; + +class SearchResponse { + const SearchResponse({ + required this.items, + required this.query, + }); + + factory SearchResponse.fromJson(Map json) => SearchResponse( + items: (json['items'] as List).map((e) => PackageSummary.fromJson(e as Map)).toList(), + query: json['query'] as String, + ); + + final List items; + + final String query; + + Map toJson() => { + 'items': items.map((e) => e.toJson()).toList(), + 'query': query, + }; +} diff --git a/src/dart/lib/semantic_search_request.dart b/src/dart/lib/semantic_search_request.dart new file mode 100644 index 0000000..57a3aa6 --- /dev/null +++ b/src/dart/lib/semantic_search_request.dart @@ -0,0 +1,43 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/semantic-search-request.json + +/// Body of `POST /v1/search/semantic` (RAG). The caller computes the query embedding with +/// its model; the server ranks stored package embeddings from the SAME model by cosine +/// distance. Vectors up to 2050 dims are accepted and zero-padded server-side. +class SemanticSearchRequest { + const SemanticSearchRequest({ + required this.embedding, + this.limit = 20, + required this.model, + this.tags, + }); + + factory SemanticSearchRequest.fromJson(Map json) => SemanticSearchRequest( + embedding: (json['embedding'] as List).map((e) => (e as num).toDouble()).toList(), + limit: json['limit'] == null + ? 20 + : (json['limit'] as num).toInt(), + model: json['model'] as String, + tags: (json['tags'] as List?)?.map((e) => e as String).toList(), + ); + + /// The query embedding (native width; padded to 2050 server-side). + final List embedding; + + final int limit; + + /// Embedding model id, e.g. `openai/text-embedding-3-small`. Only stored embeddings from + /// this model are searched. + final String model; + + /// Optional tag filter: results must carry all of these tags. + final List? tags; + + Map toJson() => { + 'embedding': embedding, + 'limit': limit, + 'model': model, + 'tags': tags, + }; +} diff --git a/src/dart/lib/semantic_search_response.dart b/src/dart/lib/semantic_search_response.dart new file mode 100644 index 0000000..6cb8cff --- /dev/null +++ b/src/dart/lib/semantic_search_response.dart @@ -0,0 +1,56 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/semantic-search-response.json + +class SemanticHit { + const SemanticHit({ + this.description, + required this.distance, + required this.name, + required this.org, + this.tags, + }); + + factory SemanticHit.fromJson(Map json) => SemanticHit( + description: json['description'] as String?, + distance: (json['distance'] as num).toDouble(), + name: json['name'] as String, + org: json['org'] as String, + tags: (json['tags'] as List?)?.map((e) => e as String).toList(), + ); + + final String? description; + + /// Cosine distance (0 = identical direction, 2 = opposite). Lower is nearer. + final double distance; + + final String name; + + final String org; + + final List? tags; + + Map toJson() => { + 'description': description, + 'distance': distance, + 'name': name, + 'org': org, + 'tags': tags, + }; +} + +class SemanticSearchResponse { + const SemanticSearchResponse({ + required this.items, + }); + + factory SemanticSearchResponse.fromJson(Map json) => SemanticSearchResponse( + items: (json['items'] as List).map((e) => SemanticHit.fromJson(e as Map)).toList(), + ); + + final List items; + + Map toJson() => { + 'items': items.map((e) => e.toJson()).toList(), + }; +} diff --git a/src/dart/lib/sync_change_event.dart b/src/dart/lib/sync_change_event.dart new file mode 100644 index 0000000..7a3efaa --- /dev/null +++ b/src/dart/lib/sync_change_event.dart @@ -0,0 +1,107 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-change-event.json + +/// Hybrid Logical Clock stamp — the reconciliation key. Total order: `wall_ms`, then +/// `counter`, then `actor`. +class Hlc { + const Hlc({ + required this.actor, + required this.counter, + required this.wallMs, + }); + + factory Hlc.fromJson(Map json) => Hlc( + actor: json['actor'] as String, + counter: (json['counter'] as num).toInt(), + wallMs: (json['wall_ms'] as num).toInt(), + ); + + final String actor; + + final int counter; + + final int wallMs; + + Map toJson() => { + 'actor': actor, + 'counter': counter, + 'wall_ms': wallMs, + }; +} + +/// The change envelope both transports decode to (mirrors zed-sync `ChangeEvent`). +/// `version` is the ONLY reconciliation key; `sync_sequence` is the catch-up cursor and +/// never feeds reconciliation. +class SyncChangeEvent { + const SyncChangeEvent({ + required this.atMs, + required this.id, + required this.op, + this.row, + this.syncSequence, + required this.table, + required this.version, + this.writeKey, + }); + + factory SyncChangeEvent.fromJson(Map json) => SyncChangeEvent( + atMs: (json['at_ms'] as num).toInt(), + id: json['id'] as String, + op: SyncOp.fromJson(json['op'] as String), + row: json['row'], + syncSequence: (json['sync_sequence'] as num?)?.toInt(), + table: json['table'] as String, + version: Hlc.fromJson(json['version'] as Map), + writeKey: json['write_key'] as String?, + ); + + final int atMs; + + final String id; + + final SyncOp op; + + final Object? row; + + final int? syncSequence; + + final String table; + + final Hlc version; + + final String? writeKey; + + Map toJson() => { + 'at_ms': atMs, + 'id': id, + 'op': op.toJson(), + 'row': row, + 'sync_sequence': syncSequence, + 'table': table, + 'version': version.toJson(), + 'write_key': writeKey, + }; +} + +enum SyncOp { + upsert('upsert'), + delete('delete'); + + const SyncOp(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static SyncOp fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown SyncOp: $value'), + ); + + static SyncOp? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} diff --git a/src/dart/lib/sync_conflict_resolution.dart b/src/dart/lib/sync_conflict_resolution.dart new file mode 100644 index 0000000..59d095d --- /dev/null +++ b/src/dart/lib/sync_conflict_resolution.dart @@ -0,0 +1,26 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-conflict-resolution.json + +/// How a dirty-vs-newer-remote conflict resolves (mirrors zed-sync `ConflictResolution`). +enum SyncConflictResolution { + serverWins('server_wins'), + lastWriteWins('last_write_wins'); + + const SyncConflictResolution(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static SyncConflictResolution fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown SyncConflictResolution: $value'), + ); + + static SyncConflictResolution? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} diff --git a/src/dart/lib/sync_error_policy.dart b/src/dart/lib/sync_error_policy.dart new file mode 100644 index 0000000..3c9d410 --- /dev/null +++ b/src/dart/lib/sync_error_policy.dart @@ -0,0 +1,28 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-error-policy.json + +/// How write/flush errors are surfaced (mirrors zed-sync `ErrorPolicy`). +enum SyncErrorPolicy { + throwOnly('throw_only'), + emitOnly('emit_only'), + throwAndEmit('throw_and_emit'), + silent('silent'); + + const SyncErrorPolicy(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static SyncErrorPolicy fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown SyncErrorPolicy: $value'), + ); + + static SyncErrorPolicy? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} diff --git a/src/dart/lib/sync_write_mode.dart b/src/dart/lib/sync_write_mode.dart new file mode 100644 index 0000000..0c3f314 --- /dev/null +++ b/src/dart/lib/sync_write_mode.dart @@ -0,0 +1,30 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-write-mode.json + +/// Per-write optimism level (mirrors zed-sync `WriteMode`). Enum, not a boolean: a write +/// names its exact behavior. +enum SyncWriteMode { + localOnly('local_only'), + optimisticQueue('optimistic_queue'), + optimisticAwaitAck('optimistic_await_ack'), + serverFirst('server_first'), + serverOnly('server_only'); + + const SyncWriteMode(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static SyncWriteMode fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown SyncWriteMode: $value'), + ); + + static SyncWriteMode? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} diff --git a/src/dart/lib/version_metadata.dart b/src/dart/lib/version_metadata.dart new file mode 100644 index 0000000..8356b77 --- /dev/null +++ b/src/dart/lib/version_metadata.dart @@ -0,0 +1,98 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/version-metadata.json + +/// On-the-wire formats for published package artifacts. +enum ArtifactFormat { + tarGz('tar.gz'), + zip('zip'); + + const ArtifactFormat(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static ArtifactFormat fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown ArtifactFormat: $value'), + ); + + static ArtifactFormat? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} + +class VersionMetadata { + const VersionMetadata({ + required this.downloadUrl, + this.format = ArtifactFormat.tarGz, + required this.name, + required this.org, + required this.publishedAt, + required this.sha256, + required this.size, + this.vcsCommit, + required this.vcsTag, + required this.version, + this.yanked = false, + }); + + factory VersionMetadata.fromJson(Map json) => VersionMetadata( + downloadUrl: json['download_url'] as String, + format: json['format'] == null + ? ArtifactFormat.tarGz + : ArtifactFormat.fromJson(json['format'] as String), + name: json['name'] as String, + org: json['org'] as String, + publishedAt: json['published_at'] as String, + sha256: json['sha256'] as String, + size: (json['size'] as num).toInt(), + vcsCommit: json['vcs_commit'] as String?, + vcsTag: json['vcs_tag'] as String, + version: json['version'] as String, + yanked: json['yanked'] == null + ? false + : json['yanked'] as bool, + ); + + /// Absolute or registry-relative URL the artifact can be fetched from. + final String downloadUrl; + + final ArtifactFormat format; + + final String name; + + final String org; + + /// RFC 3339 timestamp. + final String publishedAt; + + final String sha256; + + final int size; + + final String? vcsCommit; + + final String vcsTag; + + final String version; + + final bool yanked; + + Map toJson() => { + 'download_url': downloadUrl, + 'format': format.toJson(), + 'name': name, + 'org': org, + 'published_at': publishedAt, + 'sha256': sha256, + 'size': size, + 'vcs_commit': vcsCommit, + 'vcs_tag': vcsTag, + 'version': version, + 'yanked': yanked, + }; +} diff --git a/src/dart/lib/yank_request.dart b/src/dart/lib/yank_request.dart new file mode 100644 index 0000000..f8becd2 --- /dev/null +++ b/src/dart/lib/yank_request.dart @@ -0,0 +1,20 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/yank-request.json + +/// Body for the yank route. `yanked: false` restores a yanked version. +class YankRequest { + const YankRequest({ + required this.yanked, + }); + + factory YankRequest.fromJson(Map json) => YankRequest( + yanked: json['yanked'] as bool, + ); + + final bool yanked; + + Map toJson() => { + 'yanked': yanked, + }; +} diff --git a/src/dart/lib/yank_response.dart b/src/dart/lib/yank_response.dart new file mode 100644 index 0000000..74f18a9 --- /dev/null +++ b/src/dart/lib/yank_response.dart @@ -0,0 +1,34 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/yank-response.json + +class YankResponse { + const YankResponse({ + required this.name, + required this.org, + required this.version, + required this.yanked, + }); + + factory YankResponse.fromJson(Map json) => YankResponse( + name: json['name'] as String, + org: json['org'] as String, + version: json['version'] as String, + yanked: json['yanked'] as bool, + ); + + final String name; + + final String org; + + final String version; + + final bool yanked; + + Map toJson() => { + 'name': name, + 'org': org, + 'version': version, + 'yanked': yanked, + }; +} diff --git a/src/dart/lib/zed_interfaces.dart b/src/dart/lib/zed_interfaces.dart new file mode 100644 index 0000000..244290f --- /dev/null +++ b/src/dart/lib/zed_interfaces.dart @@ -0,0 +1,26 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. + +/// Front-end contract types for the zed-pkg registry, generated from the +/// JSON Schemas that `zed-interfaces` (Rust) is the source of truth for. +library zed_interfaces; + +export 'api_error.dart'; +export 'audit_integrity_response.dart'; +export 'audit_log_response.dart'; +export 'claim_org_request.dart'; +export 'claim_org_response.dart'; +export 'common.dart'; +export 'package_list_response.dart'; +export 'package_metadata.dart'; +export 'publish_response.dart'; +export 'search_response.dart'; +export 'semantic_search_request.dart'; +export 'semantic_search_response.dart'; +export 'sync_change_event.dart'; +export 'sync_conflict_resolution.dart'; +export 'sync_error_policy.dart'; +export 'sync_write_mode.dart'; +export 'version_metadata.dart'; +export 'yank_request.dart'; +export 'yank_response.dart'; diff --git a/src/dart/pubspec.yaml b/src/dart/pubspec.yaml new file mode 100644 index 0000000..3cd6f31 --- /dev/null +++ b/src/dart/pubspec.yaml @@ -0,0 +1,16 @@ +name: zed_interfaces +description: >- + Front-end contract types for the zed-pkg registry — generated from the JSON + Schemas that the Rust crate in src/rust is the source of truth for. +version: 0.1.0 +# Published as a zed-package ([targets.dart] in ../../.zpkg.toml) and mirrored +# to pub.dev from there, never with a bare `dart pub publish` from this tree. +publish_to: none +repository: https://github.com/zed-pkg/zed-interfaces + +environment: + sdk: ^3.0.0 + +dev_dependencies: + lints: ^4.0.0 + test: ^1.25.0 diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml new file mode 100644 index 0000000..1d87d40 --- /dev/null +++ b/src/rust/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "zed-interfaces" +version = "0.1.0" +edition = "2024" +description = "Core interface definitions for the zed-pkg universal package manager" +license = "MIT" +repository = "https://github.com/zed-pkg/zed-interfaces" + +# This crate is the Rust slice of a polyglot zed-package: it sits in +# `src/rust/` beside `src/dart/` and `src/ts/`, which are generated from +# `schemas/`. `.zpkg.toml` routes this directory to crates.io as +# `[targets.rust]` — a target may not own `dir = "."`, which is why the +# manifest lives here rather than at the repository root. See +# `docs/multi-language-layout.md`. +[lib] +path = "lib.rs" + +[dependencies] +hex = "0.4" +schemars = "1.2.1" +semver = { version = "1.0.28", features = ["serde"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = "0.10" +thiserror = "2.0.19" +toml = "1.1.3" diff --git a/src/rust/artifact.rs b/src/rust/artifact.rs new file mode 100644 index 0000000..180d0e7 --- /dev/null +++ b/src/rust/artifact.rs @@ -0,0 +1,36 @@ +use std::fmt; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// On-the-wire formats for published package artifacts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub enum ArtifactFormat { + #[default] + #[serde(rename = "tar.gz")] + TarGz, + #[serde(rename = "zip")] + Zip, +} + +impl ArtifactFormat { + pub fn extension(&self) -> &'static str { + match self { + ArtifactFormat::TarGz => "tar.gz", + ArtifactFormat::Zip => "zip", + } + } + + pub fn content_type(&self) -> &'static str { + match self { + ArtifactFormat::TarGz => "application/gzip", + ArtifactFormat::Zip => "application/zip", + } + } +} + +impl fmt::Display for ArtifactFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.extension()) + } +} diff --git a/src/rust/environment.rs b/src/rust/environment.rs new file mode 100644 index 0000000..4c9063b --- /dev/null +++ b/src/rust/environment.rs @@ -0,0 +1,900 @@ +//! Shared contracts for developer-environment interoperability. +//! +//! Zed remains authoritative for the Zed package graph. These types describe +//! exact toolchains, system packages, manager-native lock provenance, and the +//! fixed activation policy used by Flox, Devbox, mise, asdf, and future Nix +//! development-shell adapters. Arbitrary imported shell hooks and secrets are +//! intentionally not representable. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use semver::Version; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Environment managers with a first-class adapter contract. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum EnvironmentManager { + Mise, + Asdf, + Devbox, + Flox, + /// Development-shell provenance only. Nix package/derivation import and + /// export is a separate interoperability boundary. + Nix, +} + +/// The only activation behavior a Zed environment adapter may add. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ActivationPolicy { + #[default] + None, + FrozenInstall, +} + +impl ActivationPolicy { + /// The exact command emitted by adapters that support activation hooks. + pub fn command(self) -> Option<&'static str> { + match self { + Self::None => None, + Self::FrozenInstall => Some("zed install --frozen"), + } + } +} + +/// How strictly an environment plan is validated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnvironmentValidationMode { + /// Requirements may be ranges and resolved identities are optional. + Authoring, + /// Every identity must be exact, immutable, and portable. + FrozenPortable, + /// Every identity must be exact and immutable; explicit local paths are + /// allowed and make the plan intentionally non-portable. + FrozenLocal, +} + +impl EnvironmentValidationMode { + fn is_frozen(self) -> bool { + !matches!(self, Self::Authoring) + } + + fn allows_local_paths(self) -> bool { + matches!(self, Self::FrozenLocal) + } +} + +/// Supported checksum algorithms in environment provenance. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum ChecksumAlgorithm { + Sha256, + Sha512, + Blake3, +} + +impl ChecksumAlgorithm { + fn expected_hex_len(self) -> usize { + match self { + Self::Sha256 | Self::Blake3 => 64, + Self::Sha512 => 128, + } + } +} + +/// One lowercase hexadecimal content checksum in canonical output. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct Checksum { + pub algorithm: ChecksumAlgorithm, + pub value: String, +} + +impl Checksum { + fn normalized(&self) -> Self { + Self { + algorithm: self.algorithm, + value: self.value.trim().to_ascii_lowercase(), + } + } + + fn validate(&self, field: &str) -> Result<(), EnvironmentPlanError> { + let expected_hex_len = self.algorithm.expected_hex_len(); + let value = self.value.trim(); + if value.len() != expected_hex_len || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(EnvironmentPlanError::InvalidChecksum { + field: field.to_string(), + algorithm: self.algorithm, + expected_hex_len, + value: self.value.clone(), + }); + } + Ok(()) + } +} + +/// A source whose immutable revision is part of environment identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ImmutableSource { + pub url: String, + /// A full immutable commit or content digest. Moving tags and branches are + /// rejected in frozen validation. + pub revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subdir: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checksums: Vec, +} + +impl ImmutableSource { + fn normalized(&self) -> Self { + let mut source = self.clone(); + source.url = source.url.trim().to_string(); + source.revision = source.revision.trim().to_ascii_lowercase(); + source.subdir = normalize_optional(&source.subdir); + normalize_checksums(&mut source.checksums); + source + } + + fn validate( + &self, + field: &str, + mode: EnvironmentValidationMode, + ) -> Result<(), EnvironmentPlanError> { + if self.url.trim().is_empty() { + return Err(EnvironmentPlanError::EmptyField { + field: format!("{field}.url"), + }); + } + if mode == EnvironmentValidationMode::FrozenPortable && is_local_reference(&self.url) { + return Err(EnvironmentPlanError::NonPortableLocalReference { + field: format!("{field}.url"), + value: self.url.clone(), + }); + } + if mode.is_frozen() && !is_immutable_revision(&self.revision) { + return Err(EnvironmentPlanError::MutableSourceRevision { + field: format!("{field}.revision"), + revision: self.revision.clone(), + }); + } + if let Some(subdir) = &self.subdir { + validate_relative_path(&format!("{field}.subdir"), subdir)?; + } + validate_checksums(&format!("{field}.checksums"), &self.checksums) + } +} + +/// Desired and resolved identity for one runtime or developer tool. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ToolRequirement { + /// Author-authored requirement. It may be a range in authoring mode. + pub requirement: String, + /// Exact manager-native result. Required in frozen modes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checksums: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub platforms: Vec, +} + +impl ToolRequirement { + fn normalized(&self) -> Self { + let mut requirement = self.clone(); + requirement.requirement = requirement.requirement.trim().to_string(); + requirement.resolved = normalize_optional(&requirement.resolved); + requirement.provider = normalize_optional(&requirement.provider); + requirement.backend = normalize_optional(&requirement.backend); + requirement.source = requirement.source.as_ref().map(ImmutableSource::normalized); + normalize_checksums(&mut requirement.checksums); + normalize_strings(&mut requirement.platforms); + requirement + } + + fn validate( + &self, + name: &str, + mode: EnvironmentValidationMode, + ) -> Result<(), EnvironmentPlanError> { + validate_requirement( + "tool", + name, + &self.requirement, + self.resolved.as_deref(), + mode, + )?; + if let Some(source) = &self.source { + source.validate(&format!("tools.{name}.source"), mode)?; + } + validate_checksums(&format!("tools.{name}.checksums"), &self.checksums)?; + validate_platforms(&format!("tools.{name}.platforms"), &self.platforms) + } +} + +/// Desired and resolved identity for one system package supplied by an +/// environment manager rather than by the Zed dependency graph. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SystemPackageRequirement { + pub requirement: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved: Option, + /// Manager/catalog provider, such as `nixpkgs`, a Flox catalog, or a flake. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Exact provider-native attribute/reference when it differs from the + /// normalized package name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checksums: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub platforms: Vec, +} + +impl SystemPackageRequirement { + fn normalized(&self) -> Self { + let mut requirement = self.clone(); + requirement.requirement = requirement.requirement.trim().to_string(); + requirement.resolved = normalize_optional(&requirement.resolved); + requirement.provider = normalize_optional(&requirement.provider); + requirement.package_ref = normalize_optional(&requirement.package_ref); + requirement.source = requirement.source.as_ref().map(ImmutableSource::normalized); + normalize_checksums(&mut requirement.checksums); + normalize_strings(&mut requirement.platforms); + requirement + } + + fn validate( + &self, + name: &str, + mode: EnvironmentValidationMode, + ) -> Result<(), EnvironmentPlanError> { + validate_requirement( + "system package", + name, + &self.requirement, + self.resolved.as_deref(), + mode, + )?; + if let Some(source) = &self.source { + source.validate(&format!("system-packages.{name}.source"), mode)?; + } + validate_checksums( + &format!("system-packages.{name}.checksums"), + &self.checksums, + )?; + validate_platforms( + &format!("system-packages.{name}.platforms"), + &self.platforms, + ) + } +} + +/// Provenance for one manager-native input and optional lock file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct EnvironmentSource { + pub manager: EnvironmentManager, + /// Project-relative manager input path. + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lock_path: Option, + /// Digest of the normalized manager-native lock/input state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub digest: Option, +} + +impl EnvironmentSource { + fn normalized(&self) -> Self { + Self { + manager: self.manager, + path: self.path.trim().to_string(), + lock_path: normalize_optional(&self.lock_path), + digest: self.digest.as_ref().map(Checksum::normalized), + } + } + + fn validate(&self, index: usize) -> Result<(), EnvironmentPlanError> { + let field = format!("sources[{index}]"); + validate_relative_path(&format!("{field}.path"), &self.path)?; + if let Some(lock_path) = &self.lock_path { + validate_relative_path(&format!("{field}.lock-path"), lock_path)?; + } + if let Some(digest) = &self.digest { + digest.validate(&format!("{field}.digest"))?; + } + Ok(()) + } +} + +/// Manager-neutral desired and resolved developer-environment state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct EnvironmentPlan { + #[serde(default = "current_environment_schema")] + pub schema: u32, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub tools: BTreeMap, + #[serde( + default, + rename = "system-packages", + alias = "system_packages", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub system_packages: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub platforms: Vec, + #[serde(default)] + pub activation: ActivationPolicy, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, +} + +fn current_environment_schema() -> u32 { + EnvironmentPlan::CURRENT_SCHEMA +} + +impl Default for EnvironmentPlan { + fn default() -> Self { + Self { + schema: Self::CURRENT_SCHEMA, + tools: BTreeMap::new(), + system_packages: BTreeMap::new(), + platforms: Vec::new(), + activation: ActivationPolicy::None, + sources: Vec::new(), + } + } +} + +impl EnvironmentPlan { + pub const CURRENT_SCHEMA: u32 = 1; + + /// Return a presentation-independent form for deterministic generation and + /// hashing. Invalid map keys remain unchanged so validation cannot be + /// bypassed by normalization. + pub fn normalized(&self) -> Self { + let mut plan = self.clone(); + plan.tools = plan + .tools + .iter() + .map(|(name, requirement)| (name.clone(), requirement.normalized())) + .collect(); + plan.system_packages = plan + .system_packages + .iter() + .map(|(name, requirement)| (name.clone(), requirement.normalized())) + .collect(); + normalize_strings(&mut plan.platforms); + plan.sources = plan + .sources + .iter() + .map(EnvironmentSource::normalized) + .collect(); + plan.sources.sort_by(|left, right| { + (left.manager, &left.path, &left.lock_path, &left.digest).cmp(&( + right.manager, + &right.path, + &right.lock_path, + &right.digest, + )) + }); + plan.sources.dedup(); + plan + } + + /// Canonical compact JSON bytes for the environment-plan digest. + pub fn canonical_json_bytes(&self) -> Result, EnvironmentPlanError> { + serde_json::to_vec(&self.normalized()) + .map_err(|error| EnvironmentPlanError::Serialization(error.to_string())) + } + + pub fn validate(&self, mode: EnvironmentValidationMode) -> Result<(), EnvironmentPlanError> { + if self.schema == 0 || self.schema > Self::CURRENT_SCHEMA { + return Err(EnvironmentPlanError::UnsupportedSchema { + found: self.schema, + supported: Self::CURRENT_SCHEMA, + }); + } + validate_platforms("platforms", &self.platforms)?; + for (name, requirement) in &self.tools { + validate_name("tool", name)?; + requirement.validate(name, mode)?; + } + for (name, requirement) in &self.system_packages { + validate_name("system package", name)?; + requirement.validate(name, mode)?; + } + for (index, source) in self.sources.iter().enumerate() { + source.validate(index)?; + } + Ok(()) + } +} + +/// Parse strict SemVer 2.0.0 for an adapter or registry that requires it. +pub fn validate_semver_export(version: &str) -> Result { + Version::parse(version).map_err(|error| EnvironmentPlanError::InvalidSemver { + version: version.to_string(), + detail: error.to_string(), + }) +} + +/// Return true when valid SemVer strings have identical precedence fields and +/// differ only in build metadata. +pub fn differ_only_in_build_metadata( + left: &str, + right: &str, +) -> Result { + let left = validate_semver_export(left)?; + let right = validate_semver_export(right)?; + Ok(left.major == right.major + && left.minor == right.minor + && left.patch == right.patch + && left.pre == right.pre + && left.build != right.build) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum EnvironmentPlanError { + #[error("unsupported environment plan schema {found}; this build supports {supported}")] + UnsupportedSchema { found: u32, supported: u32 }, + #[error("{field} must not be empty")] + EmptyField { field: String }, + #[error("invalid {kind} name `{name}`; names cannot contain whitespace or controls")] + InvalidName { kind: &'static str, name: String }, + #[error("{kind} `{name}` has no exact resolved identity for frozen validation")] + Unresolved { kind: &'static str, name: String }, + #[error("{kind} `{name}` resolves to moving selector `{value}`")] + MovingSelector { + kind: &'static str, + name: String, + value: String, + }, + #[error("{field} uses non-portable local reference `{value}`")] + NonPortableLocalReference { field: String, value: String }, + #[error("{field} has mutable or non-canonical source revision `{revision}`")] + MutableSourceRevision { field: String, revision: String }, + #[error( + "{field} has invalid {algorithm:?} checksum `{value}`; expected {expected_hex_len} hexadecimal characters" + )] + InvalidChecksum { + field: String, + algorithm: ChecksumAlgorithm, + expected_hex_len: usize, + value: String, + }, + #[error("{field} must be a safe project-relative path, got `{value}`")] + UnsafeRelativePath { field: String, value: String }, + #[error("{field} contains invalid platform `{value}`")] + InvalidPlatform { field: String, value: String }, + #[error("`{version}` is not strict SemVer 2.0.0: {detail}")] + InvalidSemver { version: String, detail: String }, + #[error("environment plan serialization failed: {0}")] + Serialization(String), +} + +fn validate_requirement( + kind: &'static str, + name: &str, + requirement: &str, + resolved: Option<&str>, + mode: EnvironmentValidationMode, +) -> Result<(), EnvironmentPlanError> { + if requirement.trim().is_empty() { + return Err(EnvironmentPlanError::EmptyField { + field: format!("{kind} `{name}` requirement"), + }); + } + if !mode.is_frozen() { + return Ok(()); + } + + let resolved = resolved + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| EnvironmentPlanError::Unresolved { + kind, + name: name.to_string(), + })?; + + if is_local_reference(resolved) { + if !mode.allows_local_paths() { + return Err(EnvironmentPlanError::NonPortableLocalReference { + field: format!("{kind} `{name}` resolved identity"), + value: resolved.to_string(), + }); + } + return Ok(()); + } + if is_moving_selector(resolved) { + return Err(EnvironmentPlanError::MovingSelector { + kind, + name: name.to_string(), + value: resolved.to_string(), + }); + } + Ok(()) +} + +fn validate_name(kind: &'static str, name: &str) -> Result<(), EnvironmentPlanError> { + if name.is_empty() + || name.trim() != name + || name + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err(EnvironmentPlanError::InvalidName { + kind, + name: name.to_string(), + }); + } + Ok(()) +} + +fn validate_checksums(field: &str, checksums: &[Checksum]) -> Result<(), EnvironmentPlanError> { + for checksum in checksums { + checksum.validate(field)?; + } + Ok(()) +} + +fn validate_platforms(field: &str, platforms: &[String]) -> Result<(), EnvironmentPlanError> { + for platform in platforms { + let value = platform.trim(); + if value.is_empty() + || value != platform + || value + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err(EnvironmentPlanError::InvalidPlatform { + field: field.to_string(), + value: platform.clone(), + }); + } + } + Ok(()) +} + +fn validate_relative_path(field: &str, value: &str) -> Result<(), EnvironmentPlanError> { + let trimmed = value.trim(); + let has_drive_prefix = trimmed.as_bytes().get(1) == Some(&b':'); + let has_unsafe_segment = trimmed + .split(['/', '\\']) + .any(|segment| segment.is_empty() || segment == "." || segment == ".."); + if trimmed.is_empty() + || trimmed != value + || trimmed.starts_with('/') + || trimmed.starts_with('\\') + || has_drive_prefix + || has_unsafe_segment + || trimmed.chars().any(|character| character.is_control()) + { + return Err(EnvironmentPlanError::UnsafeRelativePath { + field: field.to_string(), + value: value.to_string(), + }); + } + Ok(()) +} + +fn normalize_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn normalize_strings(values: &mut Vec) { + *values = values + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect(); + values.sort(); + values.dedup(); +} + +fn normalize_checksums(checksums: &mut Vec) { + *checksums = checksums.iter().map(Checksum::normalized).collect(); + checksums.sort(); + checksums.dedup(); +} + +fn is_local_reference(value: &str) -> bool { + let value = value.trim(); + value.starts_with("path:") + || value.starts_with("file:") + || value.starts_with("./") + || value.starts_with("../") + || value.starts_with('/') + || value.starts_with('\\') + || value.as_bytes().get(1) == Some(&b':') +} + +fn is_moving_selector(value: &str) -> bool { + let value = value.trim().to_ascii_lowercase(); + if matches!( + value.as_str(), + "latest" | "stable" | "lts" | "system" | "head" | "main" | "master" + ) { + return true; + } + if value.starts_with("prefix:") || value.starts_with("sub-") { + return true; + } + if let Some(revision) = value.strip_prefix("ref:") { + return !is_full_hex_revision(revision); + } + if value.contains('*') + || value.contains('^') + || value.contains('~') + || value.contains('<') + || value.contains('>') + || value.contains('|') + || value.contains(',') + || value.chars().any(char::is_whitespace) + { + return true; + } + + let precedence_core = value.split(['-', '+']).next().unwrap_or(value.as_str()); + precedence_core.split('.').any(|segment| segment == "x") +} + +fn is_immutable_revision(value: &str) -> bool { + let value = value.trim().to_ascii_lowercase(); + if is_full_hex_revision(&value) { + return true; + } + if let Some(digest) = value.strip_prefix("sha256:") { + return digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()); + } + if let Some(digest) = value.strip_prefix("sha512:") { + return digest.len() == 128 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()); + } + false +} + +fn is_full_hex_revision(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sha256(digit: char) -> Checksum { + Checksum { + algorithm: ChecksumAlgorithm::Sha256, + value: digit.to_string().repeat(64), + } + } + + fn exact_tool(resolved: &str) -> ToolRequirement { + ToolRequirement { + requirement: "^22".to_string(), + resolved: Some(resolved.to_string()), + provider: Some("core".to_string()), + backend: None, + source: None, + checksums: vec![sha256('a')], + platforms: vec!["x86_64-linux".to_string()], + } + } + + #[test] + fn activation_policy_exposes_only_the_fixed_frozen_command() { + assert_eq!(ActivationPolicy::None.command(), None); + assert_eq!( + ActivationPolicy::FrozenInstall.command(), + Some("zed install --frozen") + ); + } + + #[test] + fn authoring_accepts_ranges_but_frozen_requires_resolution() { + let mut plan = EnvironmentPlan::default(); + plan.tools.insert( + "node".to_string(), + ToolRequirement { + requirement: "^22".to_string(), + resolved: None, + provider: None, + backend: None, + source: None, + checksums: Vec::new(), + platforms: Vec::new(), + }, + ); + + plan.validate(EnvironmentValidationMode::Authoring).unwrap(); + assert!(matches!( + plan.validate(EnvironmentValidationMode::FrozenPortable), + Err(EnvironmentPlanError::Unresolved { .. }) + )); + } + + #[test] + fn frozen_validation_rejects_moving_and_nonportable_resolutions() { + for value in ["latest", "lts", "prefix:22", "ref:master", "^22", "22.x"] { + let mut plan = EnvironmentPlan::default(); + plan.tools.insert("node".to_string(), exact_tool(value)); + assert!(matches!( + plan.validate(EnvironmentValidationMode::FrozenPortable), + Err(EnvironmentPlanError::MovingSelector { .. }) + )); + } + + let mut plan = EnvironmentPlan::default(); + plan.tools + .insert("node".to_string(), exact_tool("path:./toolchain")); + assert!(matches!( + plan.validate(EnvironmentValidationMode::FrozenPortable), + Err(EnvironmentPlanError::NonPortableLocalReference { .. }) + )); + plan.validate(EnvironmentValidationMode::FrozenLocal) + .unwrap(); + } + + #[test] + fn prerelease_x_is_not_a_wildcard() { + let mut plan = EnvironmentPlan::default(); + plan.tools + .insert("node".to_string(), exact_tool("22.0.0-x.1")); + plan.validate(EnvironmentValidationMode::FrozenPortable) + .unwrap(); + } + + #[test] + fn frozen_sources_require_full_immutable_revisions() { + let mut plan = EnvironmentPlan::default(); + let mut tool = exact_tool("22.11.0"); + tool.source = Some(ImmutableSource { + url: "https://github.com/example/tool.git".to_string(), + revision: "main".to_string(), + subdir: None, + checksums: Vec::new(), + }); + plan.tools.insert("node".to_string(), tool); + assert!(matches!( + plan.validate(EnvironmentValidationMode::FrozenPortable), + Err(EnvironmentPlanError::MutableSourceRevision { .. }) + )); + + plan.tools + .get_mut("node") + .unwrap() + .source + .as_mut() + .unwrap() + .revision = "0123456789abcdef0123456789abcdef01234567".to_string(); + plan.validate(EnvironmentValidationMode::FrozenPortable) + .unwrap(); + } + + #[test] + fn canonical_bytes_ignore_set_order_and_duplicates() { + let mut first = EnvironmentPlan { + platforms: vec![ + "x86_64-linux".to_string(), + "aarch64-darwin".to_string(), + "x86_64-linux".to_string(), + ], + activation: ActivationPolicy::FrozenInstall, + sources: vec![ + EnvironmentSource { + manager: EnvironmentManager::Mise, + path: "mise.toml".to_string(), + lock_path: Some("mise.lock".to_string()), + digest: Some(sha256('b')), + }, + EnvironmentSource { + manager: EnvironmentManager::Mise, + path: "mise.toml".to_string(), + lock_path: Some("mise.lock".to_string()), + digest: Some(sha256('b')), + }, + ], + ..EnvironmentPlan::default() + }; + let mut node = exact_tool("22.11.0"); + node.platforms = vec![ + "x86_64-linux".to_string(), + "aarch64-darwin".to_string(), + "x86_64-linux".to_string(), + ]; + first.tools.insert("node".to_string(), node); + + let mut second = first.clone(); + second.platforms.reverse(); + second.sources.reverse(); + second.tools.get_mut("node").unwrap().platforms.reverse(); + + assert_eq!( + first.canonical_json_bytes().unwrap(), + second.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn normalization_does_not_hide_invalid_map_keys() { + let mut plan = EnvironmentPlan::default(); + plan.tools + .insert(" node ".to_string(), exact_tool("22.11.0")); + assert!(matches!( + plan.normalized() + .validate(EnvironmentValidationMode::FrozenPortable), + Err(EnvironmentPlanError::InvalidName { .. }) + )); + } + + #[test] + fn canonical_json_roundtrips() { + let mut plan = EnvironmentPlan { + activation: ActivationPolicy::FrozenInstall, + ..EnvironmentPlan::default() + }; + plan.tools.insert("node".to_string(), exact_tool("22.11.0")); + let bytes = plan.canonical_json_bytes().unwrap(); + let parsed: EnvironmentPlan = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(parsed, plan.normalized()); + } + + #[test] + fn checksums_are_length_checked() { + let mut plan = EnvironmentPlan::default(); + let mut tool = exact_tool("22.11.0"); + tool.checksums = vec![Checksum { + algorithm: ChecksumAlgorithm::Sha256, + value: "abc".to_string(), + }]; + plan.tools.insert("node".to_string(), tool); + assert!(matches!( + plan.validate(EnvironmentValidationMode::FrozenPortable), + Err(EnvironmentPlanError::InvalidChecksum { .. }) + )); + } + + #[test] + fn strict_semver_is_an_export_boundary() { + assert!(validate_semver_export("1.2.3-rc.1+build.7").is_ok()); + assert!(validate_semver_export("v1.2.3").is_err()); + assert!(validate_semver_export("legacy-api").is_err()); + assert!(differ_only_in_build_metadata("1.2.3+arm64", "1.2.3+x86-64").unwrap()); + assert!(!differ_only_in_build_metadata("1.2.3", "1.2.4").unwrap()); + } + + #[test] + fn manager_paths_cannot_escape_the_project() { + let plan = EnvironmentPlan { + sources: vec![EnvironmentSource { + manager: EnvironmentManager::Devbox, + path: "../devbox.json".to_string(), + lock_path: None, + digest: None, + }], + ..EnvironmentPlan::default() + }; + assert!(matches!( + plan.validate(EnvironmentValidationMode::Authoring), + Err(EnvironmentPlanError::UnsafeRelativePath { .. }) + )); + } +} diff --git a/src/rust/environment_lock.rs b/src/rust/environment_lock.rs new file mode 100644 index 0000000..b4ab7fd --- /dev/null +++ b/src/rust/environment_lock.rs @@ -0,0 +1,1301 @@ +//! Exact, manager-neutral locks for native Zed development environments. +//! +//! Human-authored requirements belong in an environment plan. This module +//! records the immutable backend, source, platform, artifact, and install +//! identities selected from that plan so installs can be frozen, replayed +//! offline, verified for tampering, and garbage-collected without requiring the +//! source environment manager. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +/// Current on-disk/wire schema for [`EnvironmentLock`]. +pub const ENVIRONMENT_LOCK_SCHEMA_VERSION: u32 = 1; + +fn default_schema_version() -> u32 { + ENVIRONMENT_LOCK_SCHEMA_VERSION +} + +/// Portability boundary used while validating an environment lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum EnvironmentLockValidationMode { + /// Reject local-only source identities and machine-specific state. + Portable, + /// Permit explicitly local source identities, while retaining exact tree + /// digests and project-relative paths. + Local, +} + +/// Exact resolved state for all tools in one development environment. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct EnvironmentLock { + #[serde(default = "default_schema_version")] + pub schema_version: u32, + + /// SHA-256 of the normalized environment plan that produced this lock. + pub plan_digest_sha256: String, + + /// Logical tool name to one or more exact version/platform variants. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub tools: BTreeMap>, + + /// Lossless future/backend fields. Unknown state must never disappear + /// merely because an older client rewrote a lock. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +impl Default for EnvironmentLock { + fn default() -> Self { + Self { + schema_version: ENVIRONMENT_LOCK_SCHEMA_VERSION, + plan_digest_sha256: String::new(), + tools: BTreeMap::new(), + extensions: BTreeMap::new(), + } + } +} + +/// One exact backend/version/platform selection for a logical tool. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct LockedTool { + /// Original human-authored requirement retained for diagnostics. + pub requirement: String, + + /// Exact, non-moving version selected by the backend. + pub resolved: String, + + /// Backend/provider identity (`core`, `aqua`, `github`, `npm`, `cargo`, + /// `ubi`, `asdf`, `vfox`, `http`, `zed`, ...). + pub backend: String, + + /// Exact backend/plugin implementation version when backend behavior can + /// affect resolution or installation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_version: Option, + + /// Digest of normalized backend options, excluding credentials/secrets. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_options_digest_sha256: Option, + + pub source: LockedSource, + pub artifact: LockedArtifact, + pub platform: LockedPlatform, + pub install: LockedInstall, + + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +/// Source category for an exact tool artifact. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum LockedSourceKind { + Registry, + Vcs, + Http, + Oci, + Path, + Other, +} + +/// Exact source identity used before artifact verification. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct LockedSource { + pub kind: LockedSourceKind, + + /// Registry coordinates, repository URL, HTTP URL, OCI reference, or a + /// project-relative path. + pub locator: String, + + /// Exact package revision, VCS object, OCI digest, or backend identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, + + /// SHA-256 of a local directory tree for `path` sources. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tree_sha256: Option, + + /// True when backend semantics guarantee that `revision` cannot move. + #[serde(default)] + pub immutable: bool, + + /// A relative path source may be marked portable only when its complete + /// tree identity is locked and intended to travel with the project. + #[serde(default)] + pub portable: bool, + + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +/// Archive/blob identity downloaded into the content-addressed store. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct LockedArtifact { + /// Exact lowercase hexadecimal SHA-256, without a `sha256:` prefix. + pub sha256: String, + pub size: u64, + pub format: LockedArtifactFormat, + + /// Alternate immutable download locations. Order is not semantic. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mirrors: Vec, + + /// Signature, transparency-log, or attestation identities. Verification + /// policy remains a caller concern; the lock records what was verified. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub signatures: Vec, + + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum LockedArtifactFormat { + Tar, + TarGz, + TarXz, + TarZstd, + Zip, + Raw, + Directory, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct LockedSignature { + /// Signature system (`cosign`, `minisign`, `gpg`, `sigstore-bundle`, ...). + pub kind: String, + /// Key, certificate, transparency-log, or issuer/subject identity. + pub identity: String, + /// Optional SHA-256 of detached signature or attestation bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, +} + +/// Exact target identity for one locked variant. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct LockedPlatform { + /// Canonical target triple or backend target identifier. + pub target: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub libc: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub abi: Option, +} + +/// Verified install layout within an extracted artifact/store entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct LockedInstall { + /// Relative root selected from the extracted artifact. `.` means the + /// artifact root. + pub root: String, + + /// Relative PATH directories below `root`. Order is not semantic here; + /// activation precedence is defined by the environment plan/runtime. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bin_dirs: Vec, + + /// Executables and aliases exposed by this variant. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub executables: Vec, + + /// Digest of normalized layout metadata when a backend has additional + /// deterministic install-layout decisions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layout_digest_sha256: Option, + + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct LockedExecutable { + pub name: String, + /// Relative to [`LockedInstall::root`]. + pub path: String, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, +} + +/// Validation, parsing, serialization, and identity failures. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum EnvironmentLockError { + #[error("unsupported environment lock schema version {found}; this build supports {supported}")] + UnsupportedSchemaVersion { found: u32, supported: u32 }, + + #[error("{field} cannot be empty")] + EmptyField { field: String }, + + #[error("{field} contains a control character")] + ControlCharacter { field: String }, + + #[error("{field} must be a 64-character hexadecimal SHA-256 digest")] + InvalidSha256 { field: String }, + + #[error("{field} must not contain credentials, query parameters, or fragments: `{value}`")] + UnsafeLocator { field: String, value: String }, + + #[error("tool `{tool}` source {kind:?} is incompatible with artifact format {format:?}")] + SourceArtifactMismatch { + tool: String, + kind: LockedSourceKind, + format: LockedArtifactFormat, + }, + + #[error("extension value `{path}` cannot be null")] + NullExtension { path: String }, + + #[error("tool `{tool}` has no locked variants")] + ToolWithoutVariants { tool: String }, + + #[error("tool `{tool}` resolved to moving selector `{value}`")] + FloatingResolvedVersion { tool: String, value: String }, + + #[error("tool `{tool}` has mutable or incomplete {kind:?} source provenance")] + MutableSource { + tool: String, + kind: LockedSourceKind, + }, + + #[error("tool `{tool}` uses local-only source `{locator}` in a portable lock")] + LocalSourceNotPortable { tool: String, locator: String }, + + #[error("{field} must be a portable relative path: `{path}`")] + UnsafeRelativePath { field: String, path: String }, + + #[error("tool `{tool}` contains duplicate locked variant `{identity}`")] + DuplicateVariant { tool: String, identity: String }, + + #[error("tool `{tool}` variant `{variant}` exposes executable name `{name}` more than once")] + ExecutableCollision { + tool: String, + variant: String, + name: String, + }, + + #[error("tool `{tool}` variant `{variant}` has invalid executable name `{name}`")] + InvalidExecutableName { + tool: String, + variant: String, + name: String, + }, + + #[error("expected environment plan digest {expected}, but lock records {actual}")] + PlanDigestMismatch { expected: String, actual: String }, + + #[error("invalid environment lock TOML: {message}")] + TomlParse { message: String }, + + #[error("could not serialize environment lock as TOML: {message}")] + TomlSerialize { message: String }, + + #[error("invalid environment lock JSON: {message}")] + JsonParse { message: String }, + + #[error("could not serialize canonical environment lock: {message}")] + JsonSerialize { message: String }, +} + +impl EnvironmentLock { + /// Parse TOML and apply local frozen validation. + pub fn parse_toml(input: &str) -> Result { + let lock: Self = + toml::from_str(input).map_err(|error| EnvironmentLockError::TomlParse { + message: error.to_string(), + })?; + lock.validate(EnvironmentLockValidationMode::Local)?; + Ok(lock) + } + + /// Parse JSON and apply local frozen validation. + pub fn parse_json(input: &str) -> Result { + let lock: Self = + serde_json::from_str(input).map_err(|error| EnvironmentLockError::JsonParse { + message: error.to_string(), + })?; + lock.validate(EnvironmentLockValidationMode::Local)?; + Ok(lock) + } + + /// Validate exact frozen identities and the selected portability boundary. + pub fn validate( + &self, + mode: EnvironmentLockValidationMode, + ) -> Result<(), EnvironmentLockError> { + if self.schema_version != ENVIRONMENT_LOCK_SCHEMA_VERSION { + return Err(EnvironmentLockError::UnsupportedSchemaVersion { + found: self.schema_version, + supported: ENVIRONMENT_LOCK_SCHEMA_VERSION, + }); + } + validate_sha256("plan_digest_sha256", &self.plan_digest_sha256)?; + validate_extensions("extensions", &self.extensions)?; + + for (tool_name, variants) in &self.tools { + validate_text(&format!("tool name `{tool_name}`"), tool_name)?; + if variants.is_empty() { + return Err(EnvironmentLockError::ToolWithoutVariants { + tool: tool_name.clone(), + }); + } + + let mut identities = BTreeSet::new(); + for variant in variants { + variant.validate(tool_name, mode)?; + let identity = variant.variant_identity(); + if !identities.insert(identity.clone()) { + return Err(EnvironmentLockError::DuplicateVariant { + tool: tool_name.clone(), + identity, + }); + } + } + } + Ok(()) + } + + /// Canonical clone used for generation, drift comparison, and hashing. + pub fn normalized(&self) -> Self { + let mut lock = self.clone(); + lock.plan_digest_sha256.make_ascii_lowercase(); + + for variants in lock.tools.values_mut() { + for variant in variants.iter_mut() { + variant.normalize(); + } + variants.sort_by_cached_key(LockedTool::stable_key); + } + lock + } + + pub fn to_toml_string(&self) -> Result { + self.validate(EnvironmentLockValidationMode::Local)?; + toml::to_string_pretty(&self.normalized()).map_err(|error| { + EnvironmentLockError::TomlSerialize { + message: error.to_string(), + } + }) + } + + pub fn canonical_json_string(&self) -> Result { + self.validate(EnvironmentLockValidationMode::Local)?; + serde_json::to_string_pretty(&self.normalized()).map_err(|error| { + EnvironmentLockError::JsonSerialize { + message: error.to_string(), + } + }) + } + + /// SHA-256 over compact canonical JSON. + pub fn normalized_digest_sha256(&self) -> Result { + self.validate(EnvironmentLockValidationMode::Local)?; + let bytes = serde_json::to_vec(&self.normalized()).map_err(|error| { + EnvironmentLockError::JsonSerialize { + message: error.to_string(), + } + })?; + Ok(hex::encode(Sha256::digest(bytes))) + } + + /// Verify that this lock belongs to one normalized environment plan. + pub fn verify_plan_digest(&self, expected: &str) -> Result<(), EnvironmentLockError> { + validate_sha256("expected plan digest", expected)?; + if !self.plan_digest_sha256.eq_ignore_ascii_case(expected) { + return Err(EnvironmentLockError::PlanDigestMismatch { + expected: expected.to_ascii_lowercase(), + actual: self.plan_digest_sha256.to_ascii_lowercase(), + }); + } + Ok(()) + } + + /// Exact variants for one target, retaining multi-version declaration + /// order only where it remains encoded in distinct locked records. + pub fn variants_for_target<'a>( + &'a self, + tool: &'a str, + target: &'a str, + ) -> impl Iterator + 'a { + self.tools + .get(tool) + .into_iter() + .flatten() + .filter(move |variant| variant.platform.target == target) + } +} + +impl LockedTool { + fn validate( + &self, + tool: &str, + mode: EnvironmentLockValidationMode, + ) -> Result<(), EnvironmentLockError> { + validate_text(&format!("tool `{tool}` requirement"), &self.requirement)?; + validate_text(&format!("tool `{tool}` resolved version"), &self.resolved)?; + validate_text(&format!("tool `{tool}` backend"), &self.backend)?; + if looks_floating(&self.resolved) { + return Err(EnvironmentLockError::FloatingResolvedVersion { + tool: tool.to_string(), + value: self.resolved.clone(), + }); + } + if let Some(version) = &self.backend_version { + validate_text(&format!("tool `{tool}` backend version"), version)?; + if looks_floating(version) { + return Err(EnvironmentLockError::FloatingResolvedVersion { + tool: format!("{tool} backend"), + value: version.clone(), + }); + } + } + if let Some(digest) = &self.backend_options_digest_sha256 { + validate_sha256(&format!("tool `{tool}` backend options digest"), digest)?; + } + validate_extensions(&format!("tool `{tool}` extensions"), &self.extensions)?; + self.artifact.validate(tool)?; + self.source.validate(tool, &self.artifact, mode)?; + self.platform.validate(tool)?; + self.install.validate(tool, &self.variant_identity())?; + Ok(()) + } + + fn variant_identity(&self) -> String { + format!( + "{}:{}@{}:{}", + self.backend, self.resolved, self.platform.target, self.source.locator + ) + } + + fn stable_key(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| self.variant_identity()) + } + + fn normalize(&mut self) { + if let Some(digest) = &mut self.backend_options_digest_sha256 { + digest.make_ascii_lowercase(); + } + self.source.normalize(); + self.artifact.normalize(); + self.install.normalize(); + } +} + +impl LockedSource { + fn validate( + &self, + tool: &str, + artifact: &LockedArtifact, + mode: EnvironmentLockValidationMode, + ) -> Result<(), EnvironmentLockError> { + let locator_field = format!("tool `{tool}` source locator"); + validate_text(&locator_field, &self.locator)?; + validate_source_locator(&locator_field, &self.locator, self.kind)?; + if let Some(revision) = &self.revision { + validate_text(&format!("tool `{tool}` source revision"), revision)?; + } + if let Some(digest) = &self.tree_sha256 { + validate_sha256(&format!("tool `{tool}` source tree digest"), digest)?; + } + validate_extensions( + &format!("tool `{tool}` source extensions"), + &self.extensions, + )?; + + let revision_is_exact = self + .revision + .as_deref() + .is_some_and(|revision| !looks_floating(revision)); + + let exact = match self.kind { + LockedSourceKind::Registry => revision_is_exact && self.immutable, + LockedSourceKind::Vcs | LockedSourceKind::Other => revision_is_exact && self.immutable, + LockedSourceKind::Http => valid_sha256(&artifact.sha256), + LockedSourceKind::Oci => self.revision.as_deref().is_some_and(valid_prefixed_sha256), + LockedSourceKind::Path => { + validate_relative_path( + &format!("tool `{tool}` path source"), + &self.locator, + false, + )?; + self.tree_sha256.as_deref().is_some_and(valid_sha256) + } + }; + + if !exact { + return Err(EnvironmentLockError::MutableSource { + tool: tool.to_string(), + kind: self.kind, + }); + } + let path_source = self.kind == LockedSourceKind::Path; + let directory_artifact = artifact.format == LockedArtifactFormat::Directory; + if path_source != directory_artifact || (!path_source && self.tree_sha256.is_some()) { + return Err(EnvironmentLockError::SourceArtifactMismatch { + tool: tool.to_string(), + kind: self.kind, + format: artifact.format, + }); + } + + if self.kind == LockedSourceKind::Path + && mode == EnvironmentLockValidationMode::Portable + && !self.portable + { + return Err(EnvironmentLockError::LocalSourceNotPortable { + tool: tool.to_string(), + locator: self.locator.clone(), + }); + } + if self.kind != LockedSourceKind::Path && self.portable { + return Err(EnvironmentLockError::MutableSource { + tool: tool.to_string(), + kind: self.kind, + }); + } + Ok(()) + } + + fn normalize(&mut self) { + if let Some(digest) = &mut self.tree_sha256 { + digest.make_ascii_lowercase(); + } + if self.kind == LockedSourceKind::Path { + self.locator = portable_path(&self.locator); + } + } +} + +impl LockedArtifact { + fn validate(&self, tool: &str) -> Result<(), EnvironmentLockError> { + validate_sha256(&format!("tool `{tool}` artifact digest"), &self.sha256)?; + for (index, mirror) in self.mirrors.iter().enumerate() { + let field = format!("tool `{tool}` mirror {index}"); + validate_text(&field, mirror)?; + validate_network_locator(&field, mirror, false)?; + } + for (index, signature) in self.signatures.iter().enumerate() { + validate_text( + &format!("tool `{tool}` signature {index} kind"), + &signature.kind, + )?; + validate_text( + &format!("tool `{tool}` signature {index} identity"), + &signature.identity, + )?; + if let Some(digest) = &signature.sha256 { + validate_sha256(&format!("tool `{tool}` signature {index} digest"), digest)?; + } + } + validate_extensions( + &format!("tool `{tool}` artifact extensions"), + &self.extensions, + )?; + Ok(()) + } + + fn normalize(&mut self) { + self.sha256.make_ascii_lowercase(); + self.mirrors.sort(); + self.mirrors.dedup(); + for signature in &mut self.signatures { + if let Some(digest) = &mut signature.sha256 { + digest.make_ascii_lowercase(); + } + } + self.signatures.sort(); + self.signatures.dedup(); + } +} + +impl LockedPlatform { + fn validate(&self, tool: &str) -> Result<(), EnvironmentLockError> { + validate_text(&format!("tool `{tool}` platform target"), &self.target)?; + for (field, value) in [ + ("os", &self.os), + ("arch", &self.arch), + ("libc", &self.libc), + ("abi", &self.abi), + ] { + if let Some(value) = value { + validate_text(&format!("tool `{tool}` platform {field}"), value)?; + } + } + Ok(()) + } +} + +impl LockedInstall { + fn validate(&self, tool: &str, variant: &str) -> Result<(), EnvironmentLockError> { + validate_relative_path(&format!("tool `{tool}` install root"), &self.root, true)?; + for (index, path) in self.bin_dirs.iter().enumerate() { + validate_relative_path(&format!("tool `{tool}` bin directory {index}"), path, true)?; + } + if let Some(digest) = &self.layout_digest_sha256 { + validate_sha256(&format!("tool `{tool}` layout digest"), digest)?; + } + validate_extensions( + &format!("tool `{tool}` install extensions"), + &self.extensions, + )?; + + let mut names = BTreeSet::new(); + for executable in &self.executables { + validate_executable_name(tool, variant, &executable.name)?; + if !names.insert(portable_executable_key(&executable.name)) { + return Err(EnvironmentLockError::ExecutableCollision { + tool: tool.to_string(), + variant: variant.to_string(), + name: executable.name.clone(), + }); + } + validate_relative_path( + &format!("tool `{tool}` executable `{}`", executable.name), + &executable.path, + false, + )?; + for alias in &executable.aliases { + validate_executable_name(tool, variant, alias)?; + if !names.insert(portable_executable_key(alias)) { + return Err(EnvironmentLockError::ExecutableCollision { + tool: tool.to_string(), + variant: variant.to_string(), + name: alias.clone(), + }); + } + } + } + Ok(()) + } + + fn normalize(&mut self) { + self.root = portable_path(&self.root); + for path in &mut self.bin_dirs { + *path = portable_path(path); + } + self.bin_dirs.sort(); + self.bin_dirs.dedup(); + for executable in &mut self.executables { + executable.path = portable_path(&executable.path); + executable.aliases.sort(); + executable.aliases.dedup(); + } + self.executables + .sort_by(|left, right| left.name.cmp(&right.name).then(left.path.cmp(&right.path))); + if let Some(digest) = &mut self.layout_digest_sha256 { + digest.make_ascii_lowercase(); + } + } +} + +fn validate_executable_name( + tool: &str, + variant: &str, + name: &str, +) -> Result<(), EnvironmentLockError> { + let valid = !name.trim().is_empty() + && name == name.trim() + && name != "." + && name != ".." + && !name.contains(['/', '\\']) + && !name.chars().any(char::is_control); + if valid { + Ok(()) + } else { + Err(EnvironmentLockError::InvalidExecutableName { + tool: tool.to_string(), + variant: variant.to_string(), + name: name.to_string(), + }) + } +} + +fn portable_executable_key(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + for suffix in [".exe", ".cmd", ".bat", ".com"] { + if let Some(stem) = lower.strip_suffix(suffix) + && !stem.is_empty() + { + return stem.to_string(); + } + } + lower +} + +fn validate_source_locator( + field: &str, + value: &str, + kind: LockedSourceKind, +) -> Result<(), EnvironmentLockError> { + if kind == LockedSourceKind::Path || kind == LockedSourceKind::Registry { + return Ok(()); + } + validate_network_locator(field, value, kind == LockedSourceKind::Vcs) +} + +fn validate_network_locator( + field: &str, + value: &str, + allow_git_user: bool, +) -> Result<(), EnvironmentLockError> { + if value.contains('?') || value.contains('#') { + return Err(EnvironmentLockError::UnsafeLocator { + field: field.to_string(), + value: value.to_string(), + }); + } + + if let Some((_, remainder)) = value.split_once("://") { + let authority = remainder.split('/').next().unwrap_or(remainder); + if let Some((userinfo, _)) = authority.rsplit_once('@') { + let allowed = allow_git_user && userinfo == "git"; + if !allowed { + return Err(EnvironmentLockError::UnsafeLocator { + field: field.to_string(), + value: value.to_string(), + }); + } + } + } + Ok(()) +} + +fn validate_text(field: &str, value: &str) -> Result<(), EnvironmentLockError> { + if value.trim().is_empty() { + return Err(EnvironmentLockError::EmptyField { + field: field.to_string(), + }); + } + if value.chars().any(char::is_control) { + return Err(EnvironmentLockError::ControlCharacter { + field: field.to_string(), + }); + } + Ok(()) +} + +fn validate_sha256(field: &str, value: &str) -> Result<(), EnvironmentLockError> { + if valid_sha256(value) { + Ok(()) + } else { + Err(EnvironmentLockError::InvalidSha256 { + field: field.to_string(), + }) + } +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn valid_prefixed_sha256(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(valid_sha256) +} + +fn validate_relative_path( + field: &str, + value: &str, + allow_dot: bool, +) -> Result<(), EnvironmentLockError> { + let value = value.trim(); + let windows_drive = value.as_bytes().get(1).is_some_and(|byte| *byte == b':') + && value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphabetic); + let has_parent = value.split(['/', '\\']).any(|part| part == ".."); + let dot_is_invalid = value == "." && !allow_dot; + let unsafe_path = value.is_empty() + || dot_is_invalid + || Path::new(value).is_absolute() + || windows_drive + || value.starts_with('~') + || value.starts_with("$HOME") + || value.starts_with("${HOME}") + || value.starts_with("%USERPROFILE%") + || value.starts_with("//") + || value.starts_with("\\\\") + || has_parent + || value.chars().any(char::is_control); + if unsafe_path { + Err(EnvironmentLockError::UnsafeRelativePath { + field: field.to_string(), + path: value.to_string(), + }) + } else { + Ok(()) + } +} + +fn portable_path(value: &str) -> String { + value.replace('\\', "/") +} + +fn validate_extensions( + field: &str, + extensions: &BTreeMap, +) -> Result<(), EnvironmentLockError> { + for (key, value) in extensions { + validate_text(&format!("{field} key"), key)?; + validate_extension_value(&format!("{field}.{key}"), value)?; + } + serde_json::to_vec(extensions).map_err(|error| EnvironmentLockError::JsonSerialize { + message: format!("{field}: {error}"), + })?; + Ok(()) +} + +fn validate_extension_value( + path: &str, + value: &serde_json::Value, +) -> Result<(), EnvironmentLockError> { + match value { + serde_json::Value::Null => Err(EnvironmentLockError::NullExtension { + path: path.to_string(), + }), + serde_json::Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + validate_extension_value(&format!("{path}[{index}]"), value)?; + } + Ok(()) + } + serde_json::Value::Object(values) => { + for (key, value) in values { + validate_text(&format!("{path} key"), key)?; + validate_extension_value(&format!("{path}.{key}"), value)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn looks_floating(value: &str) -> bool { + let value = value.trim().to_ascii_lowercase(); + value.is_empty() + || matches!( + value.as_str(), + "latest" + | "stable" + | "current" + | "system" + | "present" + | "head" + | "main" + | "master" + | "nightly" + | "canary" + | "beta" + | "alpha" + | "lts" + ) + || value.contains('*') + || value.ends_with(".x") + || value.starts_with(['^', '~', '>', '<', '=']) + || value.starts_with("lts/") + || value.starts_with("prefix:") + || value.starts_with("path:") + || value.starts_with("env:") + || value.starts_with("ref:main") + || value.starts_with("ref:master") + || value.contains(" || ") + || value.contains(" && ") +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn registry_tool(version: &str, target: &str) -> LockedTool { + LockedTool { + requirement: "22".to_string(), + resolved: version.to_string(), + backend: "core".to_string(), + backend_version: Some("1.0.0".to_string()), + backend_options_digest_sha256: Some(A.to_string()), + source: LockedSource { + kind: LockedSourceKind::Registry, + locator: "core:node".to_string(), + revision: Some(version.to_string()), + tree_sha256: None, + immutable: true, + portable: false, + extensions: BTreeMap::new(), + }, + artifact: LockedArtifact { + sha256: B.to_string(), + size: 42, + format: LockedArtifactFormat::TarGz, + mirrors: vec![ + "https://mirror-b.invalid/node.tgz".to_string(), + "https://mirror-a.invalid/node.tgz".to_string(), + ], + signatures: vec![LockedSignature { + kind: "minisign".to_string(), + identity: "node-release-key".to_string(), + sha256: Some(A.to_string()), + }], + extensions: BTreeMap::new(), + }, + platform: LockedPlatform { + target: target.to_string(), + os: Some("linux".to_string()), + arch: Some("x86_64".to_string()), + libc: Some("gnu".to_string()), + abi: None, + }, + install: LockedInstall { + root: ".".to_string(), + bin_dirs: vec!["bin".to_string()], + executables: vec![LockedExecutable { + name: "node".to_string(), + path: "bin/node".to_string(), + aliases: vec!["nodejs".to_string()], + }], + layout_digest_sha256: Some(A.to_string()), + extensions: BTreeMap::new(), + }, + extensions: BTreeMap::new(), + } + } + + fn lock_with(tool: LockedTool) -> EnvironmentLock { + EnvironmentLock { + schema_version: ENVIRONMENT_LOCK_SCHEMA_VERSION, + plan_digest_sha256: A.to_string(), + tools: BTreeMap::from([("node".to_string(), vec![tool])]), + extensions: BTreeMap::new(), + } + } + + #[test] + fn exact_registry_lock_is_portable() { + let lock = lock_with(registry_tool("22.4.0", "x86_64-unknown-linux-gnu")); + assert_eq!( + lock.validate(EnvironmentLockValidationMode::Portable), + Ok(()) + ); + } + + #[test] + fn moving_resolved_version_is_rejected() { + let lock = lock_with(registry_tool("latest", "x86_64-unknown-linux-gnu")); + assert!(matches!( + lock.validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::FloatingResolvedVersion { .. }) + )); + } + + #[test] + fn mutable_vcs_source_is_rejected() { + let mut tool = registry_tool("1.2.3", "x86_64-unknown-linux-gnu"); + tool.source = LockedSource { + kind: LockedSourceKind::Vcs, + locator: "https://github.com/acme/tool".to_string(), + revision: Some("main".to_string()), + tree_sha256: None, + immutable: false, + portable: false, + extensions: BTreeMap::new(), + }; + let lock = lock_with(tool); + assert!(matches!( + lock.validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::MutableSource { .. }) + )); + } + + #[test] + fn digest_pinned_http_source_is_exact() { + let mut tool = registry_tool("1.2.3", "aarch64-apple-darwin"); + tool.source = LockedSource { + kind: LockedSourceKind::Http, + locator: "https://example.invalid/tool.tar.gz".to_string(), + revision: None, + tree_sha256: None, + immutable: false, + portable: false, + extensions: BTreeMap::new(), + }; + let lock = lock_with(tool); + assert_eq!( + lock.validate(EnvironmentLockValidationMode::Portable), + Ok(()) + ); + } + + #[test] + fn local_tree_requires_explicit_portability() { + let mut tool = registry_tool("1.2.3", "x86_64-unknown-linux-gnu"); + tool.source = LockedSource { + kind: LockedSourceKind::Path, + locator: "vendor/tool".to_string(), + revision: None, + tree_sha256: Some(A.to_string()), + immutable: false, + portable: false, + extensions: BTreeMap::new(), + }; + tool.artifact.format = LockedArtifactFormat::Directory; + let lock = lock_with(tool); + assert_eq!(lock.validate(EnvironmentLockValidationMode::Local), Ok(())); + assert!(matches!( + lock.validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::LocalSourceNotPortable { .. }) + )); + + let mut portable = lock.clone(); + portable.tools.get_mut("node").unwrap()[0].source.portable = true; + assert_eq!( + portable.validate(EnvironmentLockValidationMode::Portable), + Ok(()) + ); + } + + #[test] + fn unsafe_install_paths_are_rejected_cross_platform() { + for path in ["../bin", "/usr/bin", r"C:\\tool\\bin", r"\\\\server\\share"] { + let mut tool = registry_tool("22.4.0", "x86_64-unknown-linux-gnu"); + tool.install.executables[0].path = path.to_string(); + let lock = lock_with(tool); + assert!(matches!( + lock.validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::UnsafeRelativePath { .. }) + )); + } + } + + #[test] + fn executable_alias_collisions_are_rejected() { + let mut tool = registry_tool("22.4.0", "x86_64-unknown-linux-gnu"); + tool.install.executables.push(LockedExecutable { + name: "npm".to_string(), + path: "bin/npm".to_string(), + aliases: vec!["nodejs".to_string()], + }); + let lock = lock_with(tool); + assert!(matches!( + lock.validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::ExecutableCollision { .. }) + )); + } + + #[test] + fn duplicate_backend_version_target_variants_are_rejected() { + let tool = registry_tool("22.4.0", "x86_64-unknown-linux-gnu"); + let mut lock = lock_with(tool.clone()); + let mut duplicate = tool; + duplicate.artifact.sha256 = A.to_string(); + lock.tools.get_mut("node").unwrap().push(duplicate); + assert!(matches!( + lock.validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::DuplicateVariant { .. }) + )); + } + + #[test] + fn normalization_ignores_set_and_variant_insertion_order() { + let mut first = EnvironmentLock { + schema_version: ENVIRONMENT_LOCK_SCHEMA_VERSION, + plan_digest_sha256: A.to_ascii_uppercase(), + tools: BTreeMap::from([( + "node".to_string(), + vec![ + registry_tool("22.4.0", "x86_64-unknown-linux-gnu"), + registry_tool("22.4.0", "aarch64-apple-darwin"), + ], + )]), + extensions: BTreeMap::new(), + }; + first.tools.get_mut("node").unwrap()[0] + .artifact + .mirrors + .reverse(); + first.tools.get_mut("node").unwrap()[0] + .install + .bin_dirs + .extend(["libexec".to_string(), "bin".to_string()]); + + let mut second = first.clone(); + second.tools.get_mut("node").unwrap().reverse(); + second.tools.get_mut("node").unwrap()[1] + .artifact + .mirrors + .reverse(); + + assert_eq!( + first.normalized_digest_sha256().unwrap(), + second.normalized_digest_sha256().unwrap() + ); + } + + #[test] + fn plan_digest_mismatch_is_explicit() { + let lock = lock_with(registry_tool("22.4.0", "x86_64-unknown-linux-gnu")); + assert!(matches!( + lock.verify_plan_digest(B), + Err(EnvironmentLockError::PlanDigestMismatch { .. }) + )); + } + + #[test] + fn variants_are_selected_by_exact_target() { + let lock = EnvironmentLock { + schema_version: ENVIRONMENT_LOCK_SCHEMA_VERSION, + plan_digest_sha256: A.to_string(), + tools: BTreeMap::from([( + "node".to_string(), + vec![ + registry_tool("22.4.0", "x86_64-unknown-linux-gnu"), + registry_tool("22.4.0", "aarch64-apple-darwin"), + ], + )]), + extensions: BTreeMap::new(), + }; + let selected: Vec<_> = lock + .variants_for_target("node", "aarch64-apple-darwin") + .collect(); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].platform.target, "aarch64-apple-darwin"); + } + + #[test] + fn oci_source_requires_digest_revision() { + let mut tool = registry_tool("1.2.3", "x86_64-unknown-linux-gnu"); + tool.source = LockedSource { + kind: LockedSourceKind::Oci, + locator: "ghcr.io/acme/tool".to_string(), + revision: Some(format!("sha256:{A}")), + tree_sha256: None, + immutable: true, + portable: false, + extensions: BTreeMap::new(), + }; + assert_eq!( + lock_with(tool).validate(EnvironmentLockValidationMode::Portable), + Ok(()) + ); + } + + #[test] + fn toml_and_json_round_trip() { + let lock = lock_with(registry_tool("22.4.0", "x86_64-unknown-linux-gnu")); + let toml = lock.to_toml_string().unwrap(); + assert_eq!( + EnvironmentLock::parse_toml(&toml).unwrap(), + lock.normalized() + ); + let json = lock.canonical_json_string().unwrap(); + assert_eq!( + EnvironmentLock::parse_json(&json).unwrap(), + lock.normalized() + ); + } + + #[test] + fn credential_bearing_and_signed_urls_are_rejected() { + let mut credential = registry_tool("1.2.3", "x86_64-unknown-linux-gnu"); + credential.source = LockedSource { + kind: LockedSourceKind::Http, + locator: "https://user:placeholder@example.invalid/tool.tar.gz".to_string(), + revision: None, + tree_sha256: None, + immutable: false, + portable: false, + extensions: BTreeMap::new(), + }; + assert!(matches!( + lock_with(credential).validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::UnsafeLocator { .. }) + )); + + let mut signed = registry_tool("1.2.3", "x86_64-unknown-linux-gnu"); + signed.artifact.mirrors = + vec!["https://example.invalid/tool.tar.gz?X-Signature=placeholder".to_string()]; + assert!(matches!( + lock_with(signed).validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::UnsafeLocator { .. }) + )); + } + + #[test] + fn source_and_artifact_format_must_agree() { + let mut local = registry_tool("1.2.3", "x86_64-unknown-linux-gnu"); + local.source = LockedSource { + kind: LockedSourceKind::Path, + locator: "vendor/tool".to_string(), + revision: None, + tree_sha256: Some(A.to_string()), + immutable: false, + portable: true, + extensions: BTreeMap::new(), + }; + assert!(matches!( + lock_with(local).validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::SourceArtifactMismatch { .. }) + )); + + let mut remote_directory = registry_tool("1.2.3", "x86_64-unknown-linux-gnu"); + remote_directory.artifact.format = LockedArtifactFormat::Directory; + assert!(matches!( + lock_with(remote_directory).validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::SourceArtifactMismatch { .. }) + )); + } + + #[test] + fn executable_collisions_follow_windows_command_semantics() { + let mut tool = registry_tool("22.4.0", "x86_64-pc-windows-msvc"); + tool.install.executables.push(LockedExecutable { + name: "Node.EXE".to_string(), + path: "bin/Node.EXE".to_string(), + aliases: Vec::new(), + }); + assert!(matches!( + lock_with(tool).validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::ExecutableCollision { .. }) + )); + } + + #[test] + fn null_extension_values_are_rejected_recursively() { + let mut lock = lock_with(registry_tool("22.4.0", "x86_64-unknown-linux-gnu")); + lock.extensions.insert( + "future".to_string(), + serde_json::json!({"nested": [1, null]}), + ); + assert!(matches!( + lock.validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::NullExtension { .. }) + )); + } + + #[test] + fn malformed_digest_is_rejected() { + let mut lock = lock_with(registry_tool("22.4.0", "x86_64-unknown-linux-gnu")); + lock.plan_digest_sha256 = "sha256:not-a-digest".to_string(); + assert!(matches!( + lock.validate(EnvironmentLockValidationMode::Portable), + Err(EnvironmentLockError::InvalidSha256 { .. }) + )); + } +} diff --git a/src/rust/environment_v2.rs b/src/rust/environment_v2.rs new file mode 100644 index 0000000..61ee1a7 --- /dev/null +++ b/src/rust/environment_v2.rs @@ -0,0 +1,1504 @@ +//! Version-2 developer-environment contract for native Zed environments and +//! environment-manager adapters. +//! +//! Schema v1 established immutable tool and system-package provenance. Schema +//! v2 keeps the v1 single-tool wire shape readable while adding the parts +//! required for practical mise compatibility: ordered multi-version tools, +//! typed environment values, task graphs, task-local tools, and lossless +//! manager extension fields. + +use std::collections::{BTreeMap, BTreeSet}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::environment::{ + ActivationPolicy, Checksum, EnvironmentPlan as EnvironmentPlanV1, + EnvironmentPlanError as EnvironmentPlanV1Error, EnvironmentSource, EnvironmentValidationMode, + ImmutableSource, SystemPackageRequirement, ToolRequirement, +}; + +/// Typed activation value, task variable, or backend option. +/// +/// Arrays retain order. Tables are deterministic because they use +/// [`BTreeMap`]. Non-finite floats are rejected before serialization. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum EnvironmentValue { + String(String), + Integer(i64), + Float(f64), + Boolean(bool), + Array(Vec), + Table(BTreeMap), +} + +impl EnvironmentValue { + fn validate(&self, field: &str) -> Result<(), EnvironmentPlanV2Error> { + match self { + Self::Float(value) if !value.is_finite() => { + Err(EnvironmentPlanV2Error::NonFiniteValue { + field: field.to_string(), + }) + } + Self::Array(values) => { + for (index, value) in values.iter().enumerate() { + value.validate(&format!("{field}[{index}]"))?; + } + Ok(()) + } + Self::Table(values) => { + for (key, value) in values { + validate_table_key(field, key)?; + value.validate(&format!("{field}.{key}"))?; + } + Ok(()) + } + _ => Ok(()), + } + } +} + +/// One version of a logical tool. +/// +/// Flattening the schema-v1 requirement keeps existing single-tool JSON and +/// TOML documents readable without migration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ToolVersion { + #[serde(flatten)] + pub requirement: ToolRequirement, + /// Typed options whose semantics are implemented by Zed or an adapter. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub options: BTreeMap, + /// Manager-qualified fields not yet promoted into the shared contract. + /// Adapters preserve these instead of silently dropping them. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +impl ToolVersion { + pub fn new(requirement: ToolRequirement) -> Self { + Self { + requirement, + options: BTreeMap::new(), + extensions: BTreeMap::new(), + } + } + + fn normalized(&self) -> Self { + Self { + requirement: normalize_tool_requirement(&self.requirement), + options: self.options.clone(), + extensions: self.extensions.clone(), + } + } + + fn validate( + &self, + name: &str, + field: &str, + mode: EnvironmentValidationMode, + ) -> Result<(), EnvironmentPlanV2Error> { + validate_tool_requirement(name, field, &self.requirement, mode)?; + validate_value_map(&format!("{field}.options"), &self.options, false)?; + validate_extension_map(&format!("{field}.extensions"), &self.extensions) + } + + fn uses_v2_features(&self) -> bool { + !self.options.is_empty() || !self.extensions.is_empty() + } +} + +impl From for ToolVersion { + fn from(value: ToolRequirement) -> Self { + Self::new(value) + } +} + +/// One logical tool may expose one active version or an ordered version list. +/// Version order is retained because it may determine the default executable. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum ToolSpec { + One(Box), + Many(Vec), +} + +impl ToolSpec { + pub fn one(requirement: impl Into) -> Self { + Self::One(Box::new(requirement.into())) + } + + pub fn versions(&self) -> &[ToolVersion] { + match self { + Self::One(version) => std::slice::from_ref(version.as_ref()), + Self::Many(versions) => versions, + } + } + + pub fn versions_mut(&mut self) -> &mut [ToolVersion] { + match self { + Self::One(version) => std::slice::from_mut(version.as_mut()), + Self::Many(versions) => versions, + } + } + + fn normalized(&self) -> Self { + let mut versions = self + .versions() + .iter() + .map(ToolVersion::normalized) + .collect::>(); + stable_dedup_by_json(&mut versions); + if versions.len() == 1 { + Self::One(Box::new(versions.remove(0))) + } else { + Self::Many(versions) + } + } + + fn validate( + &self, + name: &str, + field: &str, + mode: EnvironmentValidationMode, + ) -> Result<(), EnvironmentPlanV2Error> { + if self.versions().is_empty() { + return Err(EnvironmentPlanV2Error::ToolWithoutVersions { + field: field.to_string(), + }); + } + for (index, version) in self.versions().iter().enumerate() { + version.validate(name, &format!("{field}.versions[{index}]"), mode)?; + } + Ok(()) + } + + fn uses_v2_features(&self) -> bool { + matches!(self, Self::Many(_)) || self.versions().iter().any(ToolVersion::uses_v2_features) + } +} + +impl From for ToolSpec { + fn from(value: ToolRequirement) -> Self { + Self::one(value) + } +} + +impl From for ToolSpec { + fn from(value: ToolVersion) -> Self { + Self::One(Box::new(value)) + } +} + +/// Schema-v2 system package with typed options and lossless extensions. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SystemPackageSpec { + #[serde(flatten)] + pub requirement: SystemPackageRequirement, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub options: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +impl SystemPackageSpec { + pub fn new(requirement: SystemPackageRequirement) -> Self { + Self { + requirement, + options: BTreeMap::new(), + extensions: BTreeMap::new(), + } + } + + fn normalized(&self) -> Self { + Self { + requirement: normalize_system_package_requirement(&self.requirement), + options: self.options.clone(), + extensions: self.extensions.clone(), + } + } + + fn validate( + &self, + name: &str, + field: &str, + mode: EnvironmentValidationMode, + ) -> Result<(), EnvironmentPlanV2Error> { + validate_system_package_requirement(name, field, &self.requirement, mode)?; + validate_value_map(&format!("{field}.options"), &self.options, false)?; + validate_extension_map(&format!("{field}.extensions"), &self.extensions) + } + + fn uses_v2_features(&self) -> bool { + !self.options.is_empty() || !self.extensions.is_empty() + } +} + +impl From for SystemPackageSpec { + fn from(value: SystemPackageRequirement) -> Self { + Self::new(value) + } +} + +/// A task command, one task invocation, or an explicit task group. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum TaskStep { + Command(String), + Task(TaskInvocation), + Tasks(TaskGroup), +} + +impl TaskStep { + fn normalized(&self) -> Self { + match self { + Self::Command(command) => Self::Command(command.clone()), + Self::Task(invocation) => Self::Task(invocation.normalized()), + Self::Tasks(group) => Self::Tasks(group.normalized()), + } + } + + fn validate(&self, field: &str) -> Result<(), EnvironmentPlanV2Error> { + match self { + Self::Command(command) => { + if command.trim().is_empty() { + return Err(EnvironmentPlanV2Error::EmptyTaskCommand { + field: field.to_string(), + }); + } + Ok(()) + } + Self::Task(invocation) => invocation.validate(field), + Self::Tasks(group) => group.validate(field), + } + } + + fn referenced_tasks<'a>(&'a self, output: &mut Vec<&'a str>) { + match self { + Self::Command(_) => {} + Self::Task(invocation) => output.push(invocation.task.as_str()), + Self::Tasks(group) => output.extend(group.tasks.iter().map(String::as_str)), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TaskInvocation { + pub task: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, +} + +impl TaskInvocation { + fn normalized(&self) -> Self { + let mut invocation = self.clone(); + invocation.task = invocation.task.trim().to_string(); + invocation + } + + fn validate(&self, field: &str) -> Result<(), EnvironmentPlanV2Error> { + validate_name("task reference", &self.task)?; + validate_value_map(&format!("{field}.env"), &self.env, true) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct TaskGroup { + pub tasks: Vec, + /// Explicit groups are parallel by default, matching mise grouped tasks. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub parallel: bool, +} + +impl TaskGroup { + fn normalized(&self) -> Self { + let mut group = self.clone(); + group.tasks = group + .tasks + .iter() + .map(|task| task.trim()) + .filter(|task| !task.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if group.parallel { + group.tasks.sort(); + group.tasks.dedup(); + } else { + stable_dedup_strings(&mut group.tasks); + } + group + } + + fn validate(&self, field: &str) -> Result<(), EnvironmentPlanV2Error> { + if self.tasks.is_empty() { + return Err(EnvironmentPlanV2Error::EmptyTaskGroup { + field: field.to_string(), + }); + } + for task in &self.tasks { + validate_name("task reference", task)?; + } + Ok(()) + } +} + +/// Confirmation may be a Boolean policy or a custom prompt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum TaskConfirmation { + Enabled(bool), + Prompt(String), +} + +/// Manager-neutral task definition. +/// +/// Ordered command fields, shell arguments, and invocation arguments are not +/// sorted. Set-like metadata is canonicalized. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TaskSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub run: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub run_windows: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub depends: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub depends_post: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub wait_for: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub vars: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub tools: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dir: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, + /// Shell program followed by arguments. Order is semantic. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub shell: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confirm: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub hide: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub quiet: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub silent: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub raw: bool, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +impl TaskSpec { + fn normalized(&self) -> Self { + let mut task = self.clone(); + task.description = normalize_optional(&task.description); + normalize_strings(&mut task.aliases); + task.run = task.run.iter().map(TaskStep::normalized).collect(); + task.run_windows = task.run_windows.iter().map(TaskStep::normalized).collect(); + normalize_strings(&mut task.depends); + normalize_strings(&mut task.depends_post); + normalize_strings(&mut task.wait_for); + task.tools = task + .tools + .iter() + .map(|(name, spec)| (name.clone(), spec.normalized())) + .collect(); + task.dir = task.dir.as_deref().map(normalize_project_path); + task.sources = task + .sources + .iter() + .map(|value| normalize_project_path(value)) + .collect(); + normalize_strings(&mut task.sources); + task.outputs = task + .outputs + .iter() + .map(|value| normalize_project_path(value)) + .collect(); + normalize_strings(&mut task.outputs); + task.usage = normalize_optional(&task.usage); + task.timeout = normalize_optional(&task.timeout); + task.confirm = match &task.confirm { + Some(TaskConfirmation::Prompt(prompt)) if prompt.trim().is_empty() => None, + Some(TaskConfirmation::Prompt(prompt)) => { + Some(TaskConfirmation::Prompt(prompt.trim().to_string())) + } + other => other.clone(), + }; + task + } + + fn validate( + &self, + name: &str, + mode: EnvironmentValidationMode, + ) -> Result<(), EnvironmentPlanV2Error> { + let field = format!("tasks.{name}"); + for (index, step) in self.run.iter().enumerate() { + step.validate(&format!("{field}.run[{index}]"))?; + } + for (index, step) in self.run_windows.iter().enumerate() { + step.validate(&format!("{field}.run-windows[{index}]"))?; + } + for alias in &self.aliases { + validate_name("task alias", alias)?; + } + for dependency in self + .depends + .iter() + .chain(self.depends_post.iter()) + .chain(self.wait_for.iter()) + { + validate_name("task reference", dependency)?; + } + validate_value_map(&format!("{field}.env"), &self.env, true)?; + validate_value_map(&format!("{field}.vars"), &self.vars, false)?; + validate_tool_map(&format!("{field}.tools"), &self.tools, mode)?; + if let Some(dir) = &self.dir { + validate_project_pattern(&format!("{field}.dir"), dir)?; + } + for (index, source) in self.sources.iter().enumerate() { + validate_project_pattern(&format!("{field}.sources[{index}]"), source)?; + } + for (index, output) in self.outputs.iter().enumerate() { + validate_project_pattern(&format!("{field}.outputs[{index}]"), output)?; + } + for (index, shell) in self.shell.iter().enumerate() { + if shell.trim().is_empty() || shell.chars().any(char::is_control) { + return Err(EnvironmentPlanV2Error::InvalidShell { + field: format!("{field}.shell[{index}]"), + value: shell.clone(), + }); + } + } + if let Some(TaskConfirmation::Prompt(prompt)) = &self.confirm + && prompt.trim().is_empty() + { + return Err(EnvironmentPlanV2Error::EmptyField { + field: format!("{field}.confirm"), + }); + } + if let Some(timeout) = &self.timeout + && (timeout.trim().is_empty() || timeout.chars().any(char::is_control)) + { + return Err(EnvironmentPlanV2Error::InvalidTimeout { + field: format!("{field}.timeout"), + value: timeout.clone(), + }); + } + validate_extension_map(&format!("{field}.extensions"), &self.extensions) + } + + fn referenced_tasks(&self) -> Vec<&str> { + let mut tasks = Vec::new(); + tasks.extend(self.depends.iter().map(String::as_str)); + tasks.extend(self.depends_post.iter().map(String::as_str)); + tasks.extend(self.wait_for.iter().map(String::as_str)); + for step in self.run.iter().chain(self.run_windows.iter()) { + step.referenced_tasks(&mut tasks); + } + tasks + } +} + +/// Manager-neutral desired and resolved developer-environment state. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct EnvironmentPlanV2 { + #[serde(default = "current_environment_schema")] + pub schema: u32, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub tools: BTreeMap, + #[serde( + default, + rename = "system-packages", + alias = "system_packages", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub system_packages: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub vars: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub tasks: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub platforms: Vec, + #[serde(default)] + pub activation: ActivationPolicy, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +fn current_environment_schema() -> u32 { + EnvironmentPlanV2::CURRENT_SCHEMA +} + +impl Default for EnvironmentPlanV2 { + fn default() -> Self { + Self { + schema: Self::CURRENT_SCHEMA, + tools: BTreeMap::new(), + system_packages: BTreeMap::new(), + env: BTreeMap::new(), + vars: BTreeMap::new(), + tasks: BTreeMap::new(), + platforms: Vec::new(), + activation: ActivationPolicy::None, + sources: Vec::new(), + extensions: BTreeMap::new(), + } + } +} + +impl EnvironmentPlanV2 { + pub const CURRENT_SCHEMA: u32 = 2; + + /// Parse a JSON environment plan and validate its authoring structure. + /// Schema-v1 single-tool documents remain accepted. + pub fn parse_json(input: &str) -> Result { + let plan: Self = serde_json::from_str(input).map_err(|error| { + EnvironmentPlanV2Error::Deserialization { + format: "JSON", + detail: error.to_string(), + } + })?; + plan.validate(EnvironmentValidationMode::Authoring)?; + Ok(plan) + } + + /// Parse a TOML environment plan and validate its authoring structure. + /// Schema-v1 single-tool documents remain accepted. + pub fn parse_toml(input: &str) -> Result { + let plan: Self = + toml::from_str(input).map_err(|error| EnvironmentPlanV2Error::Deserialization { + format: "TOML", + detail: error.to_string(), + })?; + plan.validate(EnvironmentValidationMode::Authoring)?; + Ok(plan) + } + + /// Emit deterministic TOML after normalization. + pub fn to_toml_string(&self) -> Result { + self.validate(EnvironmentValidationMode::Authoring)?; + toml::to_string_pretty(&self.normalized()) + .map_err(|error| EnvironmentPlanV2Error::Serialization(error.to_string())) + } + + /// Canonical compact JSON bytes for semantic identity and drift checks. + pub fn canonical_json_bytes(&self) -> Result, EnvironmentPlanV2Error> { + self.validate(EnvironmentValidationMode::Authoring)?; + serde_json::to_vec(&self.normalized()) + .map_err(|error| EnvironmentPlanV2Error::Serialization(error.to_string())) + } + + /// Return a presentation-independent form for generation and hashing. + /// Invalid map keys remain unchanged, so normalization cannot hide them. + pub fn normalized(&self) -> Self { + let mut plan = self.clone(); + plan.tools = plan + .tools + .iter() + .map(|(name, spec)| (name.clone(), spec.normalized())) + .collect(); + plan.system_packages = plan + .system_packages + .iter() + .map(|(name, spec)| (name.clone(), spec.normalized())) + .collect(); + plan.tasks = plan + .tasks + .iter() + .map(|(name, task)| (name.clone(), task.normalized())) + .collect(); + normalize_strings(&mut plan.platforms); + plan.sources = plan + .sources + .iter() + .map(normalize_environment_source) + .collect(); + plan.sources.sort_by(|left, right| { + (left.manager, &left.path, &left.lock_path, &left.digest).cmp(&( + right.manager, + &right.path, + &right.lock_path, + &right.digest, + )) + }); + plan.sources.dedup(); + plan + } + + pub fn validate(&self, mode: EnvironmentValidationMode) -> Result<(), EnvironmentPlanV2Error> { + if self.schema == 0 || self.schema > Self::CURRENT_SCHEMA { + return Err(EnvironmentPlanV2Error::UnsupportedSchema { + found: self.schema, + supported: Self::CURRENT_SCHEMA, + }); + } + if self.schema < 2 && self.uses_v2_features() { + return Err(EnvironmentPlanV2Error::FeatureRequiresSchema { + feature: "multi-version tools, env, vars, tasks, options, or extensions" + .to_string(), + found: self.schema, + required: 2, + }); + } + + validate_legacy_plan_shell(self, mode)?; + validate_tool_map("tools", &self.tools, mode)?; + for (name, package) in &self.system_packages { + validate_name("system package", name)?; + package.validate(name, &format!("system-packages.{name}"), mode)?; + } + validate_value_map("env", &self.env, true)?; + validate_value_map("vars", &self.vars, false)?; + validate_extension_map("extensions", &self.extensions)?; + self.validate_tasks(mode) + } + + fn uses_v2_features(&self) -> bool { + !self.env.is_empty() + || !self.vars.is_empty() + || !self.tasks.is_empty() + || !self.extensions.is_empty() + || self.tools.values().any(ToolSpec::uses_v2_features) + || self + .system_packages + .values() + .any(SystemPackageSpec::uses_v2_features) + } + + fn validate_tasks( + &self, + mode: EnvironmentValidationMode, + ) -> Result<(), EnvironmentPlanV2Error> { + let mut aliases = BTreeMap::::new(); + for (name, task) in &self.tasks { + validate_name("task", name)?; + task.validate(name, mode)?; + for alias in &task.aliases { + if let Some((real_task, _)) = self.tasks.get_key_value(alias) + && real_task != name + { + return Err(EnvironmentPlanV2Error::DuplicateTaskAlias { + alias: alias.clone(), + first: real_task.to_string(), + second: name.clone(), + }); + } + if let Some(first) = aliases.insert(alias.clone(), name.clone()) + && first != *name + { + return Err(EnvironmentPlanV2Error::DuplicateTaskAlias { + alias: alias.clone(), + first, + second: name.clone(), + }); + } + } + } + + for (name, task) in &self.tasks { + for dependency in task.referenced_tasks() { + if is_task_pattern(dependency) { + continue; + } + if !self.tasks.contains_key(dependency) && !aliases.contains_key(dependency) { + return Err(EnvironmentPlanV2Error::UnknownTaskDependency { + task: name.clone(), + dependency: dependency.to_string(), + }); + } + } + } + + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut stack = Vec::new(); + for task in self.tasks.keys() { + visit_task( + task, + &self.tasks, + &aliases, + &mut visiting, + &mut visited, + &mut stack, + )?; + } + Ok(()) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum EnvironmentPlanV2Error { + #[error("unsupported environment plan schema {found}; this build supports {supported}")] + UnsupportedSchema { found: u32, supported: u32 }, + #[error("environment schema {found} cannot represent {feature}; schema {required} is required")] + FeatureRequiresSchema { + feature: String, + found: u32, + required: u32, + }, + #[error("{field}: {source}")] + LegacyValidation { + field: String, + #[source] + source: EnvironmentPlanV1Error, + }, + #[error("{field} must not be empty")] + EmptyField { field: String }, + #[error("invalid {kind} name `{name}`; names cannot contain whitespace or controls")] + InvalidName { kind: &'static str, name: String }, + #[error("{field} contains an invalid table key `{key}`")] + InvalidTableKey { field: String, key: String }, + #[error("{field} has an invalid environment key `{key}`")] + InvalidEnvironmentKey { field: String, key: String }, + #[error("{field} must include at least one tool version")] + ToolWithoutVersions { field: String }, + #[error("{field} contains a non-finite floating-point value")] + NonFiniteValue { field: String }, + #[error("{field} contains a JSON null, which has no portable TOML representation")] + NullExtensionValue { field: String }, + #[error("task alias `{alias}` is claimed by both `{first}` and `{second}`")] + DuplicateTaskAlias { + alias: String, + first: String, + second: String, + }, + #[error("task `{task}` references unknown task `{dependency}`")] + UnknownTaskDependency { task: String, dependency: String }, + #[error("task dependency cycle detected: {cycle}")] + TaskDependencyCycle { cycle: String }, + #[error("{field} contains an empty task command")] + EmptyTaskCommand { field: String }, + #[error("{field} contains an empty task group")] + EmptyTaskGroup { field: String }, + #[error("{field} must be a safe project-relative path or pattern, got `{value}`")] + UnsafeRelativePath { field: String, value: String }, + #[error("{field} contains invalid shell entry `{value}`")] + InvalidShell { field: String, value: String }, + #[error("{field} contains invalid timeout `{value}`")] + InvalidTimeout { field: String, value: String }, + #[error("environment plan {format} deserialization failed: {detail}")] + Deserialization { + format: &'static str, + detail: String, + }, + #[error("environment plan serialization failed: {0}")] + Serialization(String), +} + +fn validate_legacy_plan_shell( + plan: &EnvironmentPlanV2, + mode: EnvironmentValidationMode, +) -> Result<(), EnvironmentPlanV2Error> { + let legacy = EnvironmentPlanV1 { + schema: EnvironmentPlanV1::CURRENT_SCHEMA, + tools: BTreeMap::new(), + system_packages: BTreeMap::new(), + platforms: plan.platforms.clone(), + activation: plan.activation, + sources: plan.sources.clone(), + }; + legacy + .validate(mode) + .map_err(|source| EnvironmentPlanV2Error::LegacyValidation { + field: "environment plan".to_string(), + source, + }) +} + +fn validate_tool_map( + field: &str, + tools: &BTreeMap, + mode: EnvironmentValidationMode, +) -> Result<(), EnvironmentPlanV2Error> { + for (name, spec) in tools { + validate_name("tool", name)?; + spec.validate(name, &format!("{field}.{name}"), mode)?; + } + Ok(()) +} + +fn validate_tool_requirement( + name: &str, + field: &str, + requirement: &ToolRequirement, + mode: EnvironmentValidationMode, +) -> Result<(), EnvironmentPlanV2Error> { + let mut legacy = EnvironmentPlanV1::default(); + legacy.tools.insert(name.to_string(), requirement.clone()); + legacy + .validate(mode) + .map_err(|source| EnvironmentPlanV2Error::LegacyValidation { + field: field.to_string(), + source, + }) +} + +fn validate_system_package_requirement( + name: &str, + field: &str, + requirement: &SystemPackageRequirement, + mode: EnvironmentValidationMode, +) -> Result<(), EnvironmentPlanV2Error> { + let mut legacy = EnvironmentPlanV1::default(); + legacy + .system_packages + .insert(name.to_string(), requirement.clone()); + legacy + .validate(mode) + .map_err(|source| EnvironmentPlanV2Error::LegacyValidation { + field: field.to_string(), + source, + }) +} + +fn validate_name(kind: &'static str, name: &str) -> Result<(), EnvironmentPlanV2Error> { + if name.is_empty() + || name.trim() != name + || name + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err(EnvironmentPlanV2Error::InvalidName { + kind, + name: name.to_string(), + }); + } + Ok(()) +} + +fn validate_table_key(field: &str, key: &str) -> Result<(), EnvironmentPlanV2Error> { + if key.trim().is_empty() || key.trim() != key || key.chars().any(char::is_control) { + return Err(EnvironmentPlanV2Error::InvalidTableKey { + field: field.to_string(), + key: key.to_string(), + }); + } + Ok(()) +} + +fn validate_value_map( + field: &str, + values: &BTreeMap, + environment_keys: bool, +) -> Result<(), EnvironmentPlanV2Error> { + for (key, value) in values { + let invalid = key.is_empty() + || key.trim() != key + || key.chars().any(char::is_control) + || (environment_keys && (key.contains('=') || key.chars().any(char::is_whitespace))); + if invalid { + return Err(EnvironmentPlanV2Error::InvalidEnvironmentKey { + field: field.to_string(), + key: key.clone(), + }); + } + value.validate(&format!("{field}.{key}"))?; + } + Ok(()) +} + +fn validate_extension_map( + field: &str, + extensions: &BTreeMap, +) -> Result<(), EnvironmentPlanV2Error> { + for (key, value) in extensions { + validate_table_key(field, key)?; + validate_extension_value(&format!("{field}.{key}"), value)?; + } + Ok(()) +} + +fn validate_extension_value( + field: &str, + value: &serde_json::Value, +) -> Result<(), EnvironmentPlanV2Error> { + match value { + serde_json::Value::Null => Err(EnvironmentPlanV2Error::NullExtensionValue { + field: field.to_string(), + }), + serde_json::Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + validate_extension_value(&format!("{field}[{index}]"), value)?; + } + Ok(()) + } + serde_json::Value::Object(values) => { + for (key, value) in values { + validate_table_key(field, key)?; + validate_extension_value(&format!("{field}.{key}"), value)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn validate_project_pattern(field: &str, value: &str) -> Result<(), EnvironmentPlanV2Error> { + let trimmed = value.trim(); + let has_drive_prefix = trimmed.as_bytes().get(1) == Some(&b':'); + let segments = trimmed.split(['/', '\\']).collect::>(); + let has_parent = segments.contains(&".."); + let has_empty_middle = segments + .iter() + .enumerate() + .any(|(index, segment)| segment.is_empty() && index != segments.len().saturating_sub(1)); + let unsafe_reference = trimmed.starts_with('/') + || trimmed.starts_with('\\') + || trimmed.starts_with('~') + || trimmed.starts_with("$HOME") + || trimmed.starts_with("${HOME}") + || trimmed.starts_with("%USERPROFILE%") + || has_drive_prefix + || has_parent; + if trimmed.is_empty() + || trimmed != value + || trimmed.chars().any(char::is_control) + || has_empty_middle + || unsafe_reference + { + return Err(EnvironmentPlanV2Error::UnsafeRelativePath { + field: field.to_string(), + value: value.to_string(), + }); + } + Ok(()) +} + +fn normalize_project_path(value: &str) -> String { + let mut normalized = value.trim(); + while let Some(stripped) = normalized.strip_prefix("./") { + normalized = stripped; + } + normalized.to_string() +} + +fn normalize_tool_requirement(requirement: &ToolRequirement) -> ToolRequirement { + let mut requirement = requirement.clone(); + requirement.requirement = requirement.requirement.trim().to_string(); + requirement.resolved = normalize_optional(&requirement.resolved); + requirement.provider = normalize_optional(&requirement.provider); + requirement.backend = normalize_optional(&requirement.backend); + requirement.source = requirement.source.as_ref().map(normalize_immutable_source); + normalize_checksums(&mut requirement.checksums); + normalize_strings(&mut requirement.platforms); + requirement +} + +fn normalize_system_package_requirement( + requirement: &SystemPackageRequirement, +) -> SystemPackageRequirement { + let mut requirement = requirement.clone(); + requirement.requirement = requirement.requirement.trim().to_string(); + requirement.resolved = normalize_optional(&requirement.resolved); + requirement.provider = normalize_optional(&requirement.provider); + requirement.package_ref = normalize_optional(&requirement.package_ref); + requirement.source = requirement.source.as_ref().map(normalize_immutable_source); + normalize_checksums(&mut requirement.checksums); + normalize_strings(&mut requirement.platforms); + requirement +} + +fn normalize_immutable_source(source: &ImmutableSource) -> ImmutableSource { + let mut source = source.clone(); + source.url = source.url.trim().to_string(); + source.revision = source.revision.trim().to_ascii_lowercase(); + source.subdir = source.subdir.as_deref().map(normalize_project_path); + normalize_checksums(&mut source.checksums); + source +} + +fn normalize_environment_source(source: &EnvironmentSource) -> EnvironmentSource { + EnvironmentSource { + manager: source.manager, + path: normalize_project_path(&source.path), + lock_path: source.lock_path.as_deref().map(normalize_project_path), + digest: source.digest.as_ref().map(normalize_checksum), + } +} + +fn normalize_checksum(checksum: &Checksum) -> Checksum { + Checksum { + algorithm: checksum.algorithm, + value: checksum.value.trim().to_ascii_lowercase(), + } +} + +fn normalize_checksums(checksums: &mut Vec) { + *checksums = checksums.iter().map(normalize_checksum).collect(); + checksums.sort(); + checksums.dedup(); +} + +fn normalize_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn normalize_strings(values: &mut Vec) { + *values = values + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect(); + values.sort(); + values.dedup(); +} + +fn stable_dedup_strings(values: &mut Vec) { + let mut seen = BTreeSet::new(); + values.retain(|value| seen.insert(value.clone())); +} + +fn stable_dedup_by_json(values: &mut Vec) { + let mut seen = BTreeSet::new(); + values.retain(|value| { + let key = serde_json::to_string(value) + .unwrap_or_else(|error| format!("")); + seen.insert(key) + }); +} + +fn is_task_pattern(task: &str) -> bool { + task.contains(['*', '?', '[']) +} + +fn visit_task( + task: &str, + tasks: &BTreeMap, + aliases: &BTreeMap, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + stack: &mut Vec, +) -> Result<(), EnvironmentPlanV2Error> { + let canonical = aliases.get(task).map(String::as_str).unwrap_or(task); + if visited.contains(canonical) { + return Ok(()); + } + if visiting.contains(canonical) { + let start = stack + .iter() + .position(|entry| entry == canonical) + .unwrap_or(0); + let mut cycle = stack[start..].to_vec(); + cycle.push(canonical.to_string()); + return Err(EnvironmentPlanV2Error::TaskDependencyCycle { + cycle: cycle.join(" -> "), + }); + } + + let Some(spec) = tasks.get(canonical) else { + return Ok(()); + }; + visiting.insert(canonical.to_string()); + stack.push(canonical.to_string()); + for dependency in spec.referenced_tasks() { + if is_task_pattern(dependency) { + continue; + } + visit_task(dependency, tasks, aliases, visiting, visited, stack)?; + } + stack.pop(); + visiting.remove(canonical); + visited.insert(canonical.to_string()); + Ok(()) +} + +fn default_true() -> bool { + true +} + +fn is_true(value: &bool) -> bool { + *value +} + +fn is_false(value: &bool) -> bool { + !*value +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::environment::{ChecksumAlgorithm, EnvironmentManager}; + + fn exact_tool(resolved: &str) -> ToolRequirement { + ToolRequirement { + requirement: "^22".to_string(), + resolved: Some(resolved.to_string()), + provider: Some("core".to_string()), + backend: None, + source: None, + checksums: Vec::new(), + platforms: vec!["x86_64-linux".to_string()], + } + } + + fn command_task(command: &str) -> TaskSpec { + TaskSpec { + run: vec![TaskStep::Command(command.to_string())], + ..TaskSpec::default() + } + } + + fn sha256(digit: char) -> Checksum { + Checksum { + algorithm: ChecksumAlgorithm::Sha256, + value: digit.to_string().repeat(64), + } + } + + #[test] + fn default_plan_uses_schema_two() { + assert_eq!(EnvironmentPlanV2::default().schema, 2); + } + + #[test] + fn schema_one_single_tool_shape_remains_valid() { + let input = r#"{ + "schema": 1, + "tools": { + "node": { + "requirement": "22", + "resolved": "22.11.0", + "provider": "core" + } + }, + "activation": "none" + }"#; + let plan = EnvironmentPlanV2::parse_json(input).unwrap(); + assert!(matches!(plan.tools.get("node"), Some(ToolSpec::One(_)))); + plan.validate(EnvironmentValidationMode::FrozenPortable) + .unwrap(); + } + + #[test] + fn schema_one_rejects_v2_features() { + let mut plan = EnvironmentPlanV2 { + schema: 1, + ..EnvironmentPlanV2::default() + }; + plan.env.insert( + "NODE_ENV".to_string(), + EnvironmentValue::String("test".to_string()), + ); + assert!(matches!( + plan.validate(EnvironmentValidationMode::Authoring), + Err(EnvironmentPlanV2Error::FeatureRequiresSchema { .. }) + )); + } + + #[test] + fn authoring_accepts_ranges_but_frozen_requires_resolution() { + let mut plan = EnvironmentPlanV2::default(); + let mut tool = exact_tool("22.11.0"); + tool.resolved = None; + plan.tools.insert("node".to_string(), tool.into()); + + plan.validate(EnvironmentValidationMode::Authoring).unwrap(); + assert!(matches!( + plan.validate(EnvironmentValidationMode::FrozenPortable), + Err(EnvironmentPlanV2Error::LegacyValidation { .. }) + )); + } + + #[test] + fn multiple_tool_versions_validate_in_frozen_mode() { + let mut plan = EnvironmentPlanV2::default(); + plan.tools.insert( + "node".to_string(), + ToolSpec::Many(vec![ + ToolVersion::new(exact_tool("20.18.0")), + ToolVersion::new(exact_tool("22.11.0")), + ]), + ); + plan.validate(EnvironmentValidationMode::FrozenPortable) + .unwrap(); + } + + #[test] + fn one_and_single_item_many_normalize_identically() { + let mut one = EnvironmentPlanV2::default(); + one.tools.insert( + "node".to_string(), + ToolSpec::One(Box::new(ToolVersion::new(exact_tool("22.11.0")))), + ); + let mut many = EnvironmentPlanV2::default(); + many.tools.insert( + "node".to_string(), + ToolSpec::Many(vec![ToolVersion::new(exact_tool("22.11.0"))]), + ); + assert_eq!( + one.canonical_json_bytes().unwrap(), + many.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn multiple_tool_version_order_is_preserved() { + let mut plan = EnvironmentPlanV2::default(); + plan.tools.insert( + "node".to_string(), + ToolSpec::Many(vec![ + ToolVersion::new(exact_tool("22.11.0")), + ToolVersion::new(exact_tool("20.18.0")), + ]), + ); + let normalized = plan.normalized(); + let versions = normalized.tools["node"].versions(); + assert_eq!(versions[0].requirement.resolved.as_deref(), Some("22.11.0")); + assert_eq!(versions[1].requirement.resolved.as_deref(), Some("20.18.0")); + } + + #[test] + fn typed_values_roundtrip_through_toml() { + let mut plan = EnvironmentPlanV2::default(); + plan.env.insert( + "ZED_MATRIX".to_string(), + EnvironmentValue::Table(BTreeMap::from([ + ("enabled".to_string(), EnvironmentValue::Boolean(true)), + ("retries".to_string(), EnvironmentValue::Integer(3)), + ( + "ratios".to_string(), + EnvironmentValue::Array(vec![ + EnvironmentValue::Float(0.5), + EnvironmentValue::Float(1.5), + ]), + ), + ])), + ); + let text = plan.to_toml_string().unwrap(); + assert_eq!( + EnvironmentPlanV2::parse_toml(&text).unwrap(), + plan.normalized() + ); + } + + #[test] + fn non_finite_values_are_rejected() { + let mut plan = EnvironmentPlanV2::default(); + plan.vars + .insert("bad".to_string(), EnvironmentValue::Float(f64::NAN)); + assert!(matches!( + plan.validate(EnvironmentValidationMode::Authoring), + Err(EnvironmentPlanV2Error::NonFiniteValue { .. }) + )); + } + + #[test] + fn null_extensions_are_rejected_before_toml_serialization() { + let mut plan = EnvironmentPlanV2::default(); + plan.extensions + .insert("mise.future".to_string(), serde_json::Value::Null); + assert!(matches!( + plan.validate(EnvironmentValidationMode::Authoring), + Err(EnvironmentPlanV2Error::NullExtensionValue { .. }) + )); + } + + #[test] + fn task_cycles_are_rejected() { + let mut plan = EnvironmentPlanV2::default(); + let mut build = command_task("cargo build"); + build.depends.push("test".to_string()); + let mut test = command_task("cargo test"); + test.depends.push("build".to_string()); + plan.tasks.insert("build".to_string(), build); + plan.tasks.insert("test".to_string(), test); + assert!(matches!( + plan.validate(EnvironmentValidationMode::Authoring), + Err(EnvironmentPlanV2Error::TaskDependencyCycle { .. }) + )); + } + + #[test] + fn aliases_resolve_and_unknown_tasks_fail() { + let mut plan = EnvironmentPlanV2::default(); + let mut build = command_task("cargo build"); + build.aliases.push("b".to_string()); + let mut test = command_task("cargo test"); + test.depends.push("b".to_string()); + plan.tasks.insert("build".to_string(), build); + plan.tasks.insert("test".to_string(), test); + plan.validate(EnvironmentValidationMode::Authoring).unwrap(); + + plan.tasks + .get_mut("test") + .unwrap() + .depends + .push("missing".to_string()); + assert!(matches!( + plan.validate(EnvironmentValidationMode::Authoring), + Err(EnvironmentPlanV2Error::UnknownTaskDependency { .. }) + )); + } + + #[test] + fn duplicate_aliases_are_rejected() { + let mut plan = EnvironmentPlanV2::default(); + let mut build = command_task("cargo build"); + build.aliases.push("x".to_string()); + let mut test = command_task("cargo test"); + test.aliases.push("x".to_string()); + plan.tasks.insert("build".to_string(), build); + plan.tasks.insert("test".to_string(), test); + assert!(matches!( + plan.validate(EnvironmentValidationMode::Authoring), + Err(EnvironmentPlanV2Error::DuplicateTaskAlias { .. }) + )); + } + + #[test] + fn task_command_order_survives_normalization() { + let mut plan = EnvironmentPlanV2::default(); + let mut task = command_task("echo first"); + task.run.push(TaskStep::Command("echo second".to_string())); + task.aliases = vec!["z".to_string(), "a".to_string()]; + plan.tasks.insert("ordered".to_string(), task); + let normalized = plan.normalized(); + let task = &normalized.tasks["ordered"]; + assert_eq!(task.aliases, vec!["a".to_string(), "z".to_string()]); + assert_eq!( + task.run, + vec![ + TaskStep::Command("echo first".to_string()), + TaskStep::Command("echo second".to_string()) + ] + ); + } + + #[test] + fn sequential_task_group_order_survives_normalization() { + let group = TaskGroup { + tasks: vec!["second".to_string(), "first".to_string()], + parallel: false, + }; + assert_eq!(group.normalized().tasks, group.tasks); + } + + #[test] + fn task_local_tools_receive_frozen_validation() { + let mut plan = EnvironmentPlanV2::default(); + let mut task = command_task("node test.js"); + task.tools + .insert("node".to_string(), exact_tool("latest").into()); + plan.tasks.insert("test".to_string(), task); + assert!(matches!( + plan.validate(EnvironmentValidationMode::FrozenPortable), + Err(EnvironmentPlanV2Error::LegacyValidation { .. }) + )); + } + + #[test] + fn task_paths_cannot_escape_the_project() { + let mut plan = EnvironmentPlanV2::default(); + let mut task = command_task("cargo build"); + task.outputs.push("../outside/result".to_string()); + plan.tasks.insert("build".to_string(), task); + assert!(matches!( + plan.validate(EnvironmentValidationMode::FrozenPortable), + Err(EnvironmentPlanV2Error::UnsafeRelativePath { .. }) + )); + assert!(matches!( + plan.validate(EnvironmentValidationMode::FrozenLocal), + Err(EnvironmentPlanV2Error::UnsafeRelativePath { .. }) + )); + } + + #[test] + fn canonical_bytes_ignore_set_order_and_duplicates() { + let mut first = EnvironmentPlanV2 { + platforms: vec![ + "x86_64-linux".to_string(), + "aarch64-darwin".to_string(), + "x86_64-linux".to_string(), + ], + activation: ActivationPolicy::FrozenInstall, + sources: vec![ + EnvironmentSource { + manager: EnvironmentManager::Mise, + path: "mise.toml".to_string(), + lock_path: Some("mise.lock".to_string()), + digest: Some(sha256('b')), + }, + EnvironmentSource { + manager: EnvironmentManager::Mise, + path: "mise.toml".to_string(), + lock_path: Some("mise.lock".to_string()), + digest: Some(sha256('b')), + }, + ], + ..EnvironmentPlanV2::default() + }; + let mut node = exact_tool("22.11.0"); + node.platforms = vec![ + "x86_64-linux".to_string(), + "aarch64-darwin".to_string(), + "x86_64-linux".to_string(), + ]; + first.tools.insert("node".to_string(), node.into()); + let mut build = command_task("cargo build"); + build.aliases = vec!["b".to_string(), "build-all".to_string()]; + build.depends = vec!["lint".to_string(), "lint".to_string()]; + first.tasks.insert("build".to_string(), build); + first + .tasks + .insert("lint".to_string(), command_task("cargo clippy")); + + let mut second = first.clone(); + second.platforms.reverse(); + second.sources.reverse(); + second.tools.get_mut("node").unwrap().versions_mut()[0] + .requirement + .platforms + .reverse(); + second.tasks.get_mut("build").unwrap().aliases.reverse(); + second.tasks.get_mut("build").unwrap().depends.reverse(); + + assert_eq!( + first.canonical_json_bytes().unwrap(), + second.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn normalization_does_not_hide_invalid_map_keys() { + let mut plan = EnvironmentPlanV2::default(); + plan.tools + .insert(" node ".to_string(), exact_tool("22.11.0").into()); + assert!(matches!( + plan.normalized() + .validate(EnvironmentValidationMode::FrozenPortable), + Err(EnvironmentPlanV2Error::InvalidName { .. }) + )); + } +} diff --git a/src/rust/examples/generate_schemas.rs b/src/rust/examples/generate_schemas.rs new file mode 100644 index 0000000..0eae946 --- /dev/null +++ b/src/rust/examples/generate_schemas.rs @@ -0,0 +1,66 @@ +//! Regenerates the JSON Schemas under `schemas/`. They are the source of +//! truth for every non-Rust consumer: `codegen/generate.mjs` turns the +//! front-end-facing subset (see `schemas/index.json`) into `src/dart/` and +//! `src/ts/`, and the client libraries in `zed-clients` codegen/validate +//! against the same files. +//! +//! Run with: `cargo run --example generate_schemas` + +use std::fs; +use std::path::Path; + +use schemars::{JsonSchema, schema_for}; + +fn write(dir: &Path, name: &str) { + let schema = schema_for!(T); + let json = serde_json::to_string_pretty(&schema).expect("schema serializes"); + let path = dir.join(format!("{name}.json")); + fs::write(&path, json + "\n").expect("schema file writes"); + println!("wrote {}", path.display()); +} + +fn main() { + // Manifest-relative (this crate is `src/rust/`, the schemas are at the + // repository root) so the output is the same from any working directory. + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas"); + let dir = dir.as_path(); + fs::create_dir_all(dir).expect("schemas dir"); + + write::(dir, "manifest"); + write::(dir, "lockfile"); + write::(dir, "environment-plan-v1"); + write::(dir, "environment-plan"); + write::(dir, "environment-lock-v1"); + write::(dir, "nix-export-section"); + write::(dir, "nix-export-plan"); + write::(dir, "nix-adapter-record"); + write::(dir, "native-registry-adapter-record"); + write::(dir, "native-dependency-lock"); + write::(dir, "oci-adapter-record"); + write::(dir, "package-metadata"); + write::(dir, "version-metadata"); + write::(dir, "publish-meta"); + write::(dir, "publish-response"); + write::(dir, "search-response"); + write::(dir, "claim-org-request"); + write::(dir, "claim-org-response"); + write::(dir, "yank-request"); + write::(dir, "yank-response"); + // Governance/audit reads, so non-Rust clients can consume the trail and + // verify the chain against the same shape the server serves. + write::(dir, "audit-log-response"); + write::(dir, "audit-integrity-response"); + write::(dir, "api-error"); + + // Sync contract types shared with zed-sync + zed-clients. + write::(dir, "sync-change-event"); + write::(dir, "sync-write-mode"); + write::(dir, "sync-error-policy"); + write::(dir, "sync-conflict-resolution"); + + // Registry list + RAG/embedding search DTOs. + write::(dir, "package-list-response"); + write::(dir, "semantic-search-request"); + write::(dir, "semantic-search-response"); + write::(dir, "embedding-upsert-request"); +} diff --git a/src/rust/excludes.rs b/src/rust/excludes.rs new file mode 100644 index 0000000..2dee1a6 --- /dev/null +++ b/src/rust/excludes.rs @@ -0,0 +1,158 @@ +//! Publish-time exclusion rules. +//! +//! zed-pkg's core disk-space promise: published artifacts carry runtime files, +//! not development machinery. Tests, CI configuration, VCS metadata, and +//! release documentation are stripped by default; packages can opt into their +//! README and changelog together, while license files are always kept. + +/// Glob patterns excluded from every published artifact by default. +/// Matching is case-insensitive in the CLI. +pub const DEFAULT_EXCLUDES: &[&str] = &[ + ".git/**", + ".hg/**", + ".svn/**", + "tests/**", + "test/**", + "spec/**", + "src/test/**", + "**/__tests__/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.go", + // Python tests come in both spellings; `test_*.py` is what unittest + // discovers by default, so omitting it leaks tests into every published + // Python slice. + "**/*_test.py", + "**/test_*.py", + "**/conftest.py", + // Ruby tests living outside `spec/` (minitest's `*_test.rb`, and RSpec + // files kept beside their subject). + "**/*_test.rb", + "**/*_spec.rb", + ".github/**", + ".gitlab/**", + ".gitlab-ci.yml", + "bitbucket-pipelines.yml", + ".circleci/**", + ".travis.yml", + "azure-pipelines.yml", + "README*", + "CHANGELOG*", + ".zedignore", + ".zed/**", + ".zed-pack/**", + ".zpkg.lock", + "zed_modules/**", + "node_modules/**", + "**/node_modules/**", + "target/**", + "**/target/**", + ".dart_tool/**", + "**/.dart_tool/**", + ".gradle/**", + "**/.gradle/**", + "build/**", + "**/build/**", + "__pycache__/**", + "**/__pycache__/**", + ".venv/**", + "**/.venv/**", + "_build/**", + "**/_build/**", + "deps/**", + "**/deps/**", +]; + +/// Patterns that are always kept, even when an exclude matches them. +/// Shipping license texts with artifacts is non-negotiable. +pub const ALWAYS_INCLUDE: &[&str] = &["LICENSE*", "LICENCE*", "COPYING*", "NOTICE*", ".zpkg.toml"]; + +/// Doc patterns `include_readme` un-excludes: the human-facing files a package +/// registry expects to find in a published artifact. +/// +/// `CHANGELOG` is here rather than stripped because native registries ask for it +/// by name — `dart pub publish` fails the package outright for its absence, and +/// `publish.exclude` can only *add* patterns, so a repo has no way to keep it +/// otherwise. A package that opted into shipping its README wants its changelog +/// shipped too. +const REGISTRY_DOC_PATTERNS: &[&str] = &["README", "CHANGELOG"]; + +/// The effective exclusion list for a package: built-in defaults (minus the +/// registry-facing doc patterns when `include_readme` is set), plus the +/// manifest's own `publish.exclude` globs. `.zedignore` lines are appended by +/// the CLI on top of this. +/// +/// A leading `!` negates a built-in or earlier extra exclusion with the same +/// normalized path. This supports the common root-directory override contract, +/// for example `!target`, `!target/`, or `!target/**` to publish a checked-in +/// Rust target directory. The negation itself is removed before the CLI builds +/// its positive-only glob set. +pub fn effective_excludes(extra: &[String], include_readme: bool) -> Vec { + let mut out: Vec = Vec::new(); + for pattern in DEFAULT_EXCLUDES { + if include_readme && REGISTRY_DOC_PATTERNS.iter().any(|d| pattern.starts_with(d)) { + continue; + } + out.push((*pattern).to_string()); + } + + for pattern in extra { + if let Some(negated) = pattern.strip_prefix('!') { + let normalized = normalize_pattern(negated, false); + if normalized.is_empty() { + continue; + } + out.retain(|existing| normalize_pattern(existing, true) != normalized); + } else { + out.push(pattern.clone()); + } + } + out +} + +fn normalize_pattern(pattern: &str, strip_recursive_prefix: bool) -> String { + let mut value = pattern.trim().replace('\\', "/"); + if strip_recursive_prefix { + value = value.strip_prefix("**/").unwrap_or(&value).to_string(); + } + while let Some(stripped) = value.strip_suffix("/**") { + value = stripped.to_string(); + } + value.trim_matches('/').to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_negation_removes_root_and_recursive_defaults() { + for negation in ["!target", "!target/", "!target/**"] { + let excludes = effective_excludes(&[negation.to_string()], false); + assert!(!excludes.iter().any(|pattern| pattern == "target/**")); + assert!(!excludes.iter().any(|pattern| pattern == "**/target/**")); + assert!(!excludes.iter().any(|pattern| pattern.starts_with('!'))); + } + } + + #[test] + fn negation_only_removes_the_matching_default_family() { + let excludes = effective_excludes(&["!target".to_string()], false); + assert!(excludes.iter().any(|pattern| pattern == "node_modules/**")); + assert!(excludes.iter().any(|pattern| pattern == "build/**")); + } + + #[test] + fn later_exclusion_can_reapply_after_negation() { + let excludes = effective_excludes( + &["!target".to_string(), "target/private/**".to_string()], + false, + ); + assert!( + excludes + .iter() + .any(|pattern| pattern == "target/private/**") + ); + assert!(!excludes.iter().any(|pattern| pattern == "target/**")); + } +} diff --git a/src/rust/language.rs b/src/rust/language.rs new file mode 100644 index 0000000..11a6ee2 --- /dev/null +++ b/src/rust/language.rs @@ -0,0 +1,684 @@ +//! Language and ecosystem tagging for multi-language packages. +//! +//! A repository that generates API clients for many languages cannot ship as +//! one zed package: a Java consumer would download the Node, Python and Dart +//! trees it will never load, and nothing would stop a Node project from +//! installing the Java client and silently getting an unusable +//! `zed_modules` entry. Such a repo instead publishes one package **per +//! language**, all at one version — `acme/acme-clients-java@1.1.2`, +//! `acme/acme-clients-nodejs@1.1.2`, … — and each declares what it is for. +//! +//! Two axes are needed, and conflating them breaks one case or the other: +//! +//! * [`Language`] **names** the package. Java, Kotlin, Scala and Clojure are +//! four distinct clients a human asks for by name, so they are four packages. +//! * [`Ecosystem`] decides **who may install it** and how the toolchain sees +//! it. Those same four languages are one ecosystem ([`Ecosystem::Jvm`]): +//! consumed off a classpath, and all equally valid in a Gradle project. +//! +//! Keying only on language would leave the install guard unable to tell that a +//! Kotlin package belongs in a Gradle build. Keying only on ecosystem would +//! collapse `-java` and `-kotlin` into one name. Hence both, with +//! [`Language::ecosystem`] supplying the default mapping so a manifest normally +//! declares just `language`. +//! +//! Both default to `universal`, which is never gated — every manifest written +//! before this module existed keeps its old meaning. + +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The language a package's code is written for. Names the published package +/// (`-`), and implies an [`Ecosystem`] via +/// [`Language::ecosystem`]. +/// +/// Canonical tokens are the colloquial names people search for — `nodejs`, not +/// `node`; `golang`, not `go` — because that is what ends up in a package name +/// a human has to recall. The shorter spellings, and near-synonyms like +/// `typescript`, are accepted by [`Language::from_token`] and by umbrella +/// variant aliases, so both spellings resolve. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum Language { + /// Not language-specific: protocol schemas, docs, `.proto` files, an + /// umbrella package. Never subject to the install ecosystem guard. + #[default] + Universal, + C, + Clojure, + Cpp, + Crystal, + Csharp, + Dart, + Elixir, + Erlang, + /// Flutter is a framework rather than a language, but a Flutter client is a + /// separate package from a plain Dart one (different `pubspec.yaml`, + /// different deps), so it gets its own token. + Flutter, + Fsharp, + Gleam, + Golang, + Haskell, + Java, + Julia, + Kotlin, + Lua, + Matlab, + Nim, + /// Covers JavaScript and TypeScript alike: for a client library they are + /// one artifact published to one ecosystem, so `typescript`, `javascript`, + /// `ts`, `js` and `node` all resolve here rather than splitting the package. + Nodejs, + Ocaml, + Php, + Powershell, + Python, + R, + Ruby, + Rust, + /// Rust compiled to WebAssembly. Distinct from [`Language::Rust`] because + /// the artifact is consumed by a JS bundler, not by Cargo. + #[serde(rename = "rust-wasm")] + RustWasm, + Scala, + Shell, + Swift, + Zig, +} + +/// How a consumer's build system takes a dependency in: the resolution +/// mechanism, not the language. Drives the install guard and which toolchain +/// wiring file `zed install` writes. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum Ecosystem { + /// No ecosystem constraint. Installs into any project; never gated. + #[default] + Universal, + Cargo, + Cmake, + Composer, + Cran, + Gem, + Gomod, + Hackage, + /// Elixir, Erlang and Gleam share Hex and the BEAM. + Hex, + /// Anything consumed off a JVM classpath: Java, Kotlin, Scala, Clojure. + Jvm, + Julia, + Luarocks, + Matlab, + Nimble, + Npm, + Nuget, + Opam, + Psgallery, + /// Dart and Flutter (`pub`). + Pub, + Pypi, + Shards, + Shell, + Swiftpm, + Zig, +} + +impl Language { + pub fn as_str(&self) -> &'static str { + use Language::*; + match self { + Universal => "universal", + C => "c", + Clojure => "clojure", + Cpp => "cpp", + Crystal => "crystal", + Csharp => "csharp", + Dart => "dart", + Elixir => "elixir", + Erlang => "erlang", + Flutter => "flutter", + Fsharp => "fsharp", + Gleam => "gleam", + Golang => "golang", + Haskell => "haskell", + Java => "java", + Julia => "julia", + Kotlin => "kotlin", + Lua => "lua", + Matlab => "matlab", + Nim => "nim", + Nodejs => "nodejs", + Ocaml => "ocaml", + Php => "php", + Powershell => "powershell", + Python => "python", + R => "r", + Ruby => "ruby", + Rust => "rust", + RustWasm => "rust-wasm", + Scala => "scala", + Shell => "shell", + Swift => "swift", + Zig => "zig", + } + } + + /// True for the default (`universal`); lets manifests omit the field. + pub fn is_default(&self) -> bool { + matches!(self, Language::Universal) + } + + /// The ecosystem this language publishes into by default. A package may + /// override it (`[package] ecosystem = "..."`) when the language does not + /// determine consumption — a `rust-wasm` client is consumed by a JS + /// bundler, but a Rust `cdylib` of the same source would be Cargo. + pub fn ecosystem(&self) -> Ecosystem { + use Language::*; + match self { + Universal => Ecosystem::Universal, + C | Cpp => Ecosystem::Cmake, + Clojure | Java | Kotlin | Scala => Ecosystem::Jvm, + Crystal => Ecosystem::Shards, + Csharp | Fsharp => Ecosystem::Nuget, + Dart | Flutter => Ecosystem::Pub, + Elixir | Erlang | Gleam => Ecosystem::Hex, + Golang => Ecosystem::Gomod, + Haskell => Ecosystem::Hackage, + Julia => Ecosystem::Julia, + Lua => Ecosystem::Luarocks, + Matlab => Ecosystem::Matlab, + Nim => Ecosystem::Nimble, + // wasm-pack output is a JS package, not a Cargo one. + Nodejs | RustWasm => Ecosystem::Npm, + Ocaml => Ecosystem::Opam, + Php => Ecosystem::Composer, + Powershell => Ecosystem::Psgallery, + Python => Ecosystem::Pypi, + R => Ecosystem::Cran, + Ruby => Ecosystem::Gem, + Rust => Ecosystem::Cargo, + Shell => Ecosystem::Shell, + Swift => Ecosystem::Swiftpm, + Zig => Ecosystem::Zig, + } + } + + /// Resolve a user-supplied token — a canonical name, a common short form, + /// or a source-directory name — to a language. This is what makes + /// `zed add …-go` find `…-golang`, and what lets a repo whose directory is + /// `clients/ts/` publish as `-nodejs`. + /// + /// Case- and separator-insensitive: `RustWasm`, `rust_wasm` and + /// `rust-wasm` are the same token. + pub fn from_token(token: &str) -> Option { + use Language::*; + let normalized: String = token + .trim() + .to_ascii_lowercase() + .chars() + .map(|c| if c == '_' || c == ' ' { '-' } else { c }) + .collect(); + Some(match normalized.as_str() { + "universal" | "any" | "none" => Universal, + "c" => C, + "clojure" | "clj" => Clojure, + "cpp" | "c++" | "cxx" => Cpp, + "crystal" => Crystal, + "csharp" | "c#" | "cs" | "dotnet" | "net" => Csharp, + "dart" => Dart, + "elixir" | "ex" => Elixir, + "erlang" | "erl" => Erlang, + "flutter" => Flutter, + "fsharp" | "f#" | "fs" => Fsharp, + "gleam" => Gleam, + "golang" | "go" => Golang, + "haskell" | "hs" => Haskell, + "java" => Java, + "julia" | "jl" => Julia, + "kotlin" | "kt" => Kotlin, + "lua" => Lua, + "matlab" => Matlab, + "nim" => Nim, + "nodejs" | "node" | "javascript" | "js" | "typescript" | "ts" => Nodejs, + "ocaml" | "ml" => Ocaml, + "php" => Php, + "powershell" | "pwsh" | "ps1" => Powershell, + "python" | "py" => Python, + "r" => R, + "ruby" | "rb" => Ruby, + "rust" | "rs" => Rust, + "rust-wasm" | "rustwasm" | "wasm" => RustWasm, + "scala" => Scala, + "shell" | "sh" | "bash" => Shell, + "swift" => Swift, + "zig" => Zig, + _ => return None, + }) + } + + /// Parse a stored value, degrading unknown input to `universal` rather than + /// failing a read. Mirrors [`crate::version::VersionScheme::from_str_lenient`]: + /// a value written by a newer zed must never wedge an older one. + pub fn from_str_lenient(s: &str) -> Language { + Language::from_token(s).unwrap_or(Language::Universal) + } +} + +impl std::fmt::Display for Language { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Ecosystem { + pub fn as_str(&self) -> &'static str { + use Ecosystem::*; + match self { + Universal => "universal", + Cargo => "cargo", + Cmake => "cmake", + Composer => "composer", + Cran => "cran", + Gem => "gem", + Gomod => "gomod", + Hackage => "hackage", + Hex => "hex", + Jvm => "jvm", + Julia => "julia", + Luarocks => "luarocks", + Matlab => "matlab", + Nimble => "nimble", + Npm => "npm", + Nuget => "nuget", + Opam => "opam", + Psgallery => "psgallery", + Pub => "pub", + Pypi => "pypi", + Shards => "shards", + Shell => "shell", + Swiftpm => "swiftpm", + Zig => "zig", + } + } + + /// True for the default (`universal`); lets manifests omit the field. + pub fn is_default(&self) -> bool { + matches!(self, Ecosystem::Universal) + } + + pub fn from_token(token: &str) -> Option { + use Ecosystem::*; + Some(match token.trim().to_ascii_lowercase().as_str() { + "universal" | "any" | "none" => Universal, + "cargo" | "crates" | "crates-io" => Cargo, + "cmake" => Cmake, + "composer" => Composer, + "cran" => Cran, + "gem" | "rubygems" => Gem, + "gomod" | "go" | "goproxy" => Gomod, + "hackage" => Hackage, + "hex" => Hex, + "jvm" | "maven" | "gradle" => Jvm, + "julia" => Julia, + "luarocks" => Luarocks, + "matlab" => Matlab, + "nimble" => Nimble, + "npm" | "node" | "nodejs" | "yarn" | "pnpm" => Npm, + "nuget" | "dotnet" => Nuget, + "opam" => Opam, + "psgallery" | "powershell" => Psgallery, + "pub" | "dart" | "flutter" => Pub, + "pypi" | "pip" | "python" => Pypi, + "shards" => Shards, + "shell" | "sh" => Shell, + "swiftpm" | "spm" => Swiftpm, + "zig" => Zig, + _ => return None, + }) + } + + pub fn from_str_lenient(s: &str) -> Ecosystem { + Ecosystem::from_token(s).unwrap_or(Ecosystem::Universal) + } + + /// Filenames in a project root that identify this ecosystem. A leading + /// `*.` matches by extension; everything else is an exact filename. + /// + /// Empty for ecosystems with no conventional marker file + /// ([`Ecosystem::Matlab`], [`Ecosystem::Shell`]) — those are never + /// auto-detected, so a project consuming one must say so explicitly with + /// `[install] adapter` or `--adapter`. + pub fn marker_files(&self) -> &'static [&'static str] { + use Ecosystem::*; + match self { + Universal | Matlab | Shell => &[], + Cargo => &["Cargo.toml"], + Cmake => &["CMakeLists.txt"], + Composer => &["composer.json"], + Cran => &["DESCRIPTION"], + Gem => &["Gemfile", "*.gemspec"], + Gomod => &["go.mod", "go.work"], + Hackage => &["stack.yaml", "*.cabal"], + Hex => &["mix.exs", "rebar.config", "gleam.toml"], + Jvm => &[ + "pom.xml", + "build.gradle", + "build.gradle.kts", + "build.sbt", + "deps.edn", + "project.clj", + ], + Julia => &["Project.toml", "JuliaProject.toml"], + Luarocks => &["*.rockspec"], + Nimble => &["*.nimble"], + Npm => &["package.json"], + Nuget => &["*.csproj", "*.fsproj", "*.sln"], + Opam => &["dune-project", "*.opam"], + Psgallery => &["*.psd1"], + Pub => &["pubspec.yaml"], + Pypi => &[ + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + ], + Shards => &["shard.yml"], + Swiftpm => &["Package.swift"], + Zig => &["build.zig.zon", "build.zig"], + } + } + + /// Every ecosystem, for exhaustive detection and for error messages that + /// list what a project could be. + pub const ALL: &'static [Ecosystem] = &[ + Ecosystem::Cargo, + Ecosystem::Cmake, + Ecosystem::Composer, + Ecosystem::Cran, + Ecosystem::Gem, + Ecosystem::Gomod, + Ecosystem::Hackage, + Ecosystem::Hex, + Ecosystem::Jvm, + Ecosystem::Julia, + Ecosystem::Luarocks, + Ecosystem::Matlab, + Ecosystem::Nimble, + Ecosystem::Npm, + Ecosystem::Nuget, + Ecosystem::Opam, + Ecosystem::Psgallery, + Ecosystem::Pub, + Ecosystem::Pypi, + Ecosystem::Shards, + Ecosystem::Shell, + Ecosystem::Swiftpm, + Ecosystem::Zig, + ]; + + /// True when `filename` is one of this ecosystem's markers. + pub fn matches_marker(&self, filename: &str) -> bool { + self.marker_files().iter().any(|pattern| { + match pattern.strip_prefix("*.") { + // Extension pattern: `*.gemspec` matches `acme.gemspec` but not + // a file literally named `.gemspec`. + Some(ext) => filename + .rsplit_once('.') + .is_some_and(|(stem, e)| !stem.is_empty() && e == ext), + None => filename == *pattern, + } + }) + } +} + +impl std::fmt::Display for Ecosystem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Identify every ecosystem present in a project root, given its top-level +/// filenames. Returns a set because polyglot repos are normal — a Rust service +/// with a TypeScript frontend is both `cargo` and `npm`, and a dependency for +/// either belongs there. +/// +/// Takes filenames rather than a path so it stays pure and trivially testable; +/// callers in `zed-cli` supply a directory listing. +pub fn detect_ecosystems<'a, I>(filenames: I) -> BTreeSet +where + I: IntoIterator, +{ + let names: Vec<&str> = filenames.into_iter().collect(); + Ecosystem::ALL + .iter() + .filter(|eco| names.iter().any(|name| eco.matches_marker(name))) + .copied() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_language_round_trips_through_its_own_token() { + // Guards the three parallel match arms (as_str / from_token / + // ecosystem): adding a variant to one and forgetting another is the + // easy mistake, and it would silently mis-tag a published package. + for lang in [ + Language::Universal, + Language::C, + Language::Clojure, + Language::Cpp, + Language::Crystal, + Language::Csharp, + Language::Dart, + Language::Elixir, + Language::Erlang, + Language::Flutter, + Language::Fsharp, + Language::Gleam, + Language::Golang, + Language::Haskell, + Language::Java, + Language::Julia, + Language::Kotlin, + Language::Lua, + Language::Matlab, + Language::Nim, + Language::Nodejs, + Language::Ocaml, + Language::Php, + Language::Powershell, + Language::Python, + Language::R, + Language::Ruby, + Language::Rust, + Language::RustWasm, + Language::Scala, + Language::Shell, + Language::Swift, + Language::Zig, + ] { + assert_eq!( + Language::from_token(lang.as_str()), + Some(lang), + "`{lang}` does not parse back from its own as_str()" + ); + } + } + + #[test] + fn language_serde_uses_the_canonical_token() { + // The manifest is the wire format; a rename here silently invalidates + // every published manifest. + let json = serde_json::to_string(&Language::RustWasm).unwrap(); + assert_eq!(json, "\"rust-wasm\""); + assert_eq!( + serde_json::from_str::("\"nodejs\"").unwrap(), + Language::Nodejs + ); + assert_eq!( + serde_json::to_string(&Language::Golang).unwrap(), + "\"golang\"" + ); + } + + #[test] + fn colloquial_and_short_tokens_resolve_to_one_package() { + // The whole point of the alias table: a user who types `-go` or `-ts` + // must land on the package that actually exists. + for token in ["go", "golang", "GoLang"] { + assert_eq!(Language::from_token(token), Some(Language::Golang)); + } + for token in ["node", "nodejs", "js", "javascript", "ts", "typescript"] { + assert_eq!(Language::from_token(token), Some(Language::Nodejs)); + } + assert_eq!(Language::from_token("rust_wasm"), Some(Language::RustWasm)); + assert_eq!(Language::from_token(" Python "), Some(Language::Python)); + assert_eq!(Language::from_token("cobol"), None); + } + + #[test] + fn unknown_tokens_degrade_to_universal_rather_than_failing() { + // A manifest written by a newer zed must not wedge an older one. + assert_eq!(Language::from_str_lenient("brainfuck"), Language::Universal); + assert_eq!(Ecosystem::from_str_lenient("bazel"), Ecosystem::Universal); + } + + #[test] + fn jvm_languages_share_one_ecosystem_but_keep_distinct_names() { + // This is the reason for two axes. Same ecosystem => a Kotlin client + // installs fine in a Gradle project. Distinct languages => `-java` and + // `-kotlin` are separately installable packages. + for lang in [ + Language::Java, + Language::Kotlin, + Language::Scala, + Language::Clojure, + ] { + assert_eq!(lang.ecosystem(), Ecosystem::Jvm); + } + assert_ne!(Language::Java.as_str(), Language::Kotlin.as_str()); + } + + #[test] + fn rust_wasm_is_consumed_by_npm_not_cargo() { + assert_eq!(Language::Rust.ecosystem(), Ecosystem::Cargo); + assert_eq!(Language::RustWasm.ecosystem(), Ecosystem::Npm); + } + + #[test] + fn universal_maps_to_universal_so_untagged_packages_are_never_gated() { + assert_eq!(Language::Universal.ecosystem(), Ecosystem::Universal); + assert!(Language::default().is_default()); + assert!(Ecosystem::default().is_default()); + assert!(Ecosystem::Universal.marker_files().is_empty()); + } + + #[test] + fn detects_a_single_ecosystem_from_its_marker() { + assert_eq!( + detect_ecosystems(["package.json", "README.md"]), + BTreeSet::from([Ecosystem::Npm]) + ); + assert_eq!( + detect_ecosystems(["build.gradle.kts"]), + BTreeSet::from([Ecosystem::Jvm]) + ); + assert_eq!( + detect_ecosystems(["go.mod"]), + BTreeSet::from([Ecosystem::Gomod]) + ); + } + + #[test] + fn detects_every_ecosystem_in_a_polyglot_project() { + // A Rust service with a TS frontend is both; a dependency for either + // one belongs here, so the guard must not pick a single winner. + let found = detect_ecosystems(["Cargo.toml", "package.json", "pyproject.toml"]); + assert_eq!( + found, + BTreeSet::from([Ecosystem::Cargo, Ecosystem::Npm, Ecosystem::Pypi]) + ); + } + + #[test] + fn extension_markers_match_by_suffix_only() { + assert_eq!( + detect_ecosystems(["acme-client.gemspec"]), + BTreeSet::from([Ecosystem::Gem]) + ); + assert_eq!( + detect_ecosystems(["Acme.Client.csproj"]), + BTreeSet::from([Ecosystem::Nuget]) + ); + // A bare dotfile named after the extension is not a project marker. + assert!(detect_ecosystems([".gemspec"]).is_empty()); + assert!(detect_ecosystems(["gemspec"]).is_empty()); + } + + #[test] + fn detects_nothing_in_a_directory_with_no_markers() { + // Must stay empty rather than guessing: the guard treats "no detected + // ecosystem" as "cannot verify", not as "wrong ecosystem". + assert!(detect_ecosystems(["README.md", "LICENSE"]).is_empty()); + assert!(detect_ecosystems(Vec::<&str>::new()).is_empty()); + } + + #[test] + fn all_covers_every_non_default_ecosystem() { + // A new ecosystem missing from ALL would never be detected, so the + // guard would silently pass everything in such a project. + assert!(!Ecosystem::ALL.contains(&Ecosystem::Universal)); + for lang in [ + Language::Java, + Language::Nodejs, + Language::Golang, + Language::Python, + Language::Rust, + Language::Dart, + Language::Ruby, + Language::Php, + Language::Swift, + Language::Csharp, + Language::Gleam, + Language::Zig, + ] { + assert!( + Ecosystem::ALL.contains(&lang.ecosystem()), + "{lang} maps to {} which is missing from ALL", + lang.ecosystem() + ); + } + } +} diff --git a/src/rust/lib.rs b/src/rust/lib.rs new file mode 100644 index 0000000..ae88849 --- /dev/null +++ b/src/rust/lib.rs @@ -0,0 +1,78 @@ +//! Core interface definitions for the zed-pkg universal package manager. +//! +//! This crate is the single source of truth for the on-disk formats +//! (`.zpkg.toml`, `.zpkg.lock`, store layout), the registry REST API DTOs, and +//! the publish-time exclusion rules. It is consumed by `zed-cli`, +//! `zed-api-server`, `zed-web-server`, and (via generated JSON Schemas in +//! `schemas/`) by the non-Rust client libraries in `zed-clients`. + +pub mod artifact; +pub mod environment; +pub mod environment_lock; +pub mod environment_v2; +pub mod excludes; +pub mod language; +pub mod lockfile; +pub mod manifest; +pub mod native_dependency; +pub mod native_registry; +pub mod nix; +pub mod nix_plan; +pub mod oci; +pub mod paths; +pub mod registry; +pub mod sync; +pub mod vcs; +pub mod version; + +pub use artifact::ArtifactFormat; +pub use environment::{ + ActivationPolicy, Checksum, ChecksumAlgorithm, EnvironmentManager, EnvironmentPlan, + EnvironmentPlanError, EnvironmentSource, EnvironmentValidationMode, ImmutableSource, + SystemPackageRequirement, ToolRequirement, differ_only_in_build_metadata, + validate_semver_export, +}; +pub use environment_lock::{ + ENVIRONMENT_LOCK_SCHEMA_VERSION, EnvironmentLock, EnvironmentLockError, + EnvironmentLockValidationMode, LockedArtifact, LockedArtifactFormat, LockedExecutable, + LockedInstall, LockedPlatform, LockedSignature, LockedSource, LockedSourceKind, LockedTool, +}; +pub use environment_v2::{ + EnvironmentPlanV2, EnvironmentPlanV2Error, EnvironmentValue, SystemPackageSpec, + TaskConfirmation, TaskGroup, TaskInvocation, TaskSpec, TaskStep, ToolSpec, ToolVersion, +}; +pub use language::{Ecosystem, Language, detect_ecosystems}; +pub use lockfile::{LockedPackage, Lockfile, LockfileError}; +pub use manifest::{ + InstallHooksSection, Manifest, ManifestError, NATIVE_PACKAGE_MANAGERS, NativeDependencies, + NixExportRoute, +}; +pub use native_dependency::{ + NATIVE_DEPENDENCY_LOCK_SCHEMA_V1, NativeDependencyError, NativeDependencyLock, + NativeVersionCandidate, NativeVersionRequirement, +}; +pub use native_registry::{ + NATIVE_REGISTRY_ADAPTER_SCHEMA_V1, NativeArtifact, NativePackageIdentity, NativePlatform, + NativePlatformPackage, NativePublication, NativePublicationKind, NativeRegistry, + NativeRegistryAdapterRecord, NativeRegistryError, ZedNativePackageIdentity, + native_versions_collide, semver_precedence_identity, +}; +pub use nix::{ + NIX_ADAPTER_SCHEMA_V1, NixAdapterRecord, NixBuilderNetwork, NixExportMode, NixExportSection, + NixInteropArtifact, NixInteropError, NixOutputOrigin, NixPackageIdentity, NixPolicyEvidence, + NixPolicyProfile, NixRealizedOutput, NixStoreReference, ZedArtifactOrigin, +}; +pub use nix_plan::{ + NIX_EXPORT_PLAN_SCHEMA_V1, NixExportPackageClass, NixExportPlan, NixExportPlanError, + PlannedNixExportDependency, PlannedZedExportArtifact, ResolvedNixExportIntent, +}; +pub use oci::{ + CYCLONEDX_JSON_MEDIA_TYPE, IN_TOTO_JSON_MEDIA_TYPE, OCI_ADAPTER_SCHEMA_V1, + OCI_IMAGE_MANIFEST_MEDIA_TYPE, OciAdapterRecord, OciDescriptor, OciDigest, OciInteropError, + OciLayer, OciLayerKind, OciPackageIdentity, OciPlatform, OciReference, SPDX_JSON_MEDIA_TYPE, + ZED_OCI_BINARY_MEDIA_TYPE_V1, ZED_OCI_CONFIG_MEDIA_TYPE_V1, ZED_OCI_LOCK_MEDIA_TYPE_V1, + ZED_OCI_MANIFEST_MEDIA_TYPE_V1, ZED_OCI_PACKAGE_TAR_GZ_MEDIA_TYPE_V1, + ZED_OCI_PACKAGE_ZIP_MEDIA_TYPE_V1, +}; +pub use vcs::Vcs; +pub use version::{Requirement, VersionScheme}; diff --git a/src/rust/lockfile.rs b/src/rust/lockfile.rs new file mode 100644 index 0000000..cbb7916 --- /dev/null +++ b/src/rust/lockfile.rs @@ -0,0 +1,605 @@ +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::artifact::ArtifactFormat; +use crate::native_dependency::NativeDependencyLock; +use crate::native_registry::NativeRegistry; +use crate::nix::NixAdapterRecord; + +type NativeDependencyKey = (NativeRegistry, String); +type NixAdapterKey = (String, String, String, Option, u8, String, String); + +const ARTIFACT_REVISION_PREFIX: &str = "artifact-sha256:"; + +/// The `.zpkg.lock` file written next to `.zpkg.toml` after resolution. +/// +/// Serialized as TOML with one `[[package]]` table per locked Zed package, +/// optional `[[native-dependency]]` tables for exact npm/Cargo resolutions, +/// and optional `[[nix-adapter]]` tables for completed Nix translations. +/// Every entry pins exact immutable identity so frozen restore never needs to +/// reinterpret a native range or repeat an environment translation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Lockfile { + pub version: u32, + #[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")] + pub packages: Vec, + /// Exact source-aware npm/Cargo resolutions. This additive field keeps + /// existing lockfile version 1 documents readable while newer writers can + /// preserve native requirement translation and immutable artifact identity. + #[serde( + default, + rename = "native-dependency", + skip_serializing_if = "Vec::is_empty" + )] + pub native_dependencies: Vec, + /// Optional immutable provenance for completed Nix interoperability + /// translations. This additive field keeps lockfile version 1 readable by + /// current consumers while allowing newer writers to preserve evidence. + #[serde(default, rename = "nix-adapter", skip_serializing_if = "Vec::is_empty")] + pub nix_adapters: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct LockedPackage { + #[schemars(length(min = 1))] + pub org: String, + #[schemars(length(min = 1))] + pub name: String, + #[schemars(length(min = 1))] + pub version: String, + /// Canonical lowercase hex sha256 of the artifact archive; also its store address. + #[schemars(length(equal = 64), regex(pattern = r"^[0-9a-f]{64}$"))] + pub sha256: String, + /// Artifact size in bytes. Zero-byte package artifacts are invalid. + #[schemars(range(min = 1))] + pub size: u64, + /// Explicit archive format. A missing value must never be inferred during + /// a frozen install because the format is part of immutable artifact identity. + pub format: ArtifactFormat, + /// VCS tag the version was published from, e.g. `v1.2.0`. + #[schemars(length(min = 1))] + pub vcs_tag: String, + /// Exact immutable source revision associated with the published artifact. + /// The optional Rust representation preserves API compatibility for + /// builders. Parsing and JSON Schema validation require an explicit value; + /// the canonical writer upgrades a legacy in-memory `None` to the exact + /// content-addressed `artifact-sha256:` revision before emission. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars( + !default, + required, + length(min = 7, max = 128), + regex(pattern = r"^[A-Za-z0-9._+:/-]+$") + )] + pub vcs_commit: Option, + /// Base URL of the registry the artifact was resolved from. + #[schemars(length(min = 1))] + pub source: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum LockfileError { + #[error("lockfile toml error: {0}")] + Toml(String), + #[error("unsupported lockfile version {0} (this build supports {1})")] + UnsupportedVersion(u32, u32), + #[error("invalid locked package metadata for `{package}`: {reason}")] + InvalidPackageMetadata { package: String, reason: String }, + #[error("duplicate locked package identity `{0}`")] + DuplicatePackage(String), + #[error("invalid native dependency provenance: {0}")] + InvalidNativeDependency(String), + #[error("duplicate native dependency key `{0}`")] + DuplicateNativeDependency(String), + #[error("invalid Nix adapter provenance: {0}")] + InvalidNixAdapter(String), + #[error("duplicate Nix adapter provenance key `{0}`")] + DuplicateNixAdapter(String), +} + +impl Default for Lockfile { + fn default() -> Self { + Self { + version: Self::CURRENT_VERSION, + packages: Vec::new(), + native_dependencies: Vec::new(), + nix_adapters: Vec::new(), + } + } +} + +impl Lockfile { + pub const CURRENT_VERSION: u32 = 1; + + pub fn parse(input: &str) -> Result { + let lockfile: Lockfile = + toml::from_str(input).map_err(|error| LockfileError::Toml(error.to_string()))?; + if lockfile.version > Self::CURRENT_VERSION { + return Err(LockfileError::UnsupportedVersion( + lockfile.version, + Self::CURRENT_VERSION, + )); + } + lockfile.validate_packages()?; + lockfile.validate_native_dependencies()?; + lockfile.validate_nix_adapters()?; + Ok(lockfile) + } + + pub fn to_toml_string(&self) -> Result { + let mut normalized = self.clone(); + normalized.normalize_missing_package_revisions()?; + normalized.validate_packages()?; + normalized.validate_native_dependencies()?; + normalized.validate_nix_adapters()?; + normalized + .packages + .sort_by(|left, right| (&left.org, &left.name).cmp(&(&right.org, &right.name))); + normalized + .native_dependencies + .sort_by_key(native_dependency_key); + normalized.nix_adapters.sort_by_key(nix_adapter_key); + toml::to_string_pretty(&normalized).map_err(|error| LockfileError::Toml(error.to_string())) + } + + pub fn find(&self, org: &str, name: &str) -> Option<&LockedPackage> { + self.packages + .iter() + .find(|package| package.org == org && package.name == name) + } + + /// Insert or replace the entry for `org/name`, keeping entries sorted. + pub fn upsert(&mut self, package: LockedPackage) { + self.packages + .retain(|existing| !(existing.org == package.org && existing.name == package.name)); + self.packages.push(package); + self.packages + .sort_by(|a, b| (&a.org, &a.name).cmp(&(&b.org, &b.name))); + } + + /// Return one exact native resolution by source registry and package name. + pub fn find_native_dependency( + &self, + registry: NativeRegistry, + package_name: &str, + ) -> Option<&NativeDependencyLock> { + self.native_dependencies.iter().find(|dependency| { + dependency.requirement.registry == registry && dependency.package.name == package_name + }) + } + + /// Validate and insert or replace one exact native resolution. V1 identity + /// is `(registry, package.name)`, so a project cannot silently carry two + /// different exact resolutions of the same native package. + pub fn upsert_native_dependency( + &mut self, + dependency: NativeDependencyLock, + ) -> Result<(), LockfileError> { + dependency + .validate() + .map_err(|error| LockfileError::InvalidNativeDependency(error.to_string()))?; + let key = native_dependency_key(&dependency); + self.native_dependencies + .retain(|existing| native_dependency_key(existing) != key); + self.native_dependencies.push(dependency); + self.native_dependencies.sort_by_key(native_dependency_key); + Ok(()) + } + + /// Insert or replace one completed Nix translation. Identity includes + /// package/target, direction, system, and selected output, so platform + /// variants never overwrite each other. + pub fn upsert_nix_adapter(&mut self, adapter: NixAdapterRecord) -> Result<(), LockfileError> { + adapter + .validate() + .map_err(|error| LockfileError::InvalidNixAdapter(error.to_string()))?; + let key = nix_adapter_key(&adapter); + self.nix_adapters + .retain(|existing| nix_adapter_key(existing) != key); + self.nix_adapters.push(adapter); + self.nix_adapters.sort_by_key(nix_adapter_key); + Ok(()) + } + + fn normalize_missing_package_revisions(&mut self) -> Result<(), LockfileError> { + for package in &mut self.packages { + if package.vcs_commit.is_some() { + continue; + } + let label = package.full_name(); + if !is_canonical_sha256(&package.sha256) { + return invalid_package( + &label, + "sha256 must be canonical before deriving content-addressed provenance", + ); + } + if package.sha256.bytes().all(|byte| byte == b'0') { + return invalid_package( + &label, + "sha256 must not be all-zero before deriving content-addressed provenance", + ); + } + package.vcs_commit = Some(artifact_revision(&package.sha256)); + } + Ok(()) + } + + fn validate_packages(&self) -> Result<(), LockfileError> { + let mut seen = BTreeSet::new(); + for package in &self.packages { + let label = package.full_name(); + if !seen.insert((package.org.clone(), package.name.clone())) { + return Err(LockfileError::DuplicatePackage(label)); + } + if package.org.trim().is_empty() { + return invalid_package(&label, "org must not be empty"); + } + if package.name.trim().is_empty() { + return invalid_package(&label, "name must not be empty"); + } + if package.version.trim().is_empty() { + return invalid_package(&label, "version must not be empty"); + } + if !is_canonical_sha256(&package.sha256) { + return invalid_package( + &label, + "sha256 must be 64 lowercase hexadecimal characters", + ); + } + if package.sha256.bytes().all(|byte| byte == b'0') { + return invalid_package(&label, "sha256 must not be the all-zero digest"); + } + if package.size == 0 { + return invalid_package(&label, "size must be greater than zero"); + } + if package.vcs_tag.trim().is_empty() { + return invalid_package(&label, "vcs_tag must not be empty"); + } + let Some(commit) = package.vcs_commit.as_deref() else { + return invalid_package(&label, "vcs_commit must be explicitly present"); + }; + if !is_immutable_vcs_revision(commit) { + return invalid_package( + &label, + "vcs_commit must be a bounded immutable revision, not a mutable ref", + ); + } + if package.source.trim().is_empty() { + return invalid_package(&label, "source must not be empty"); + } + } + Ok(()) + } + + fn validate_native_dependencies(&self) -> Result<(), LockfileError> { + let mut seen = BTreeSet::new(); + for dependency in &self.native_dependencies { + dependency + .validate() + .map_err(|error| LockfileError::InvalidNativeDependency(error.to_string()))?; + let key = native_dependency_key(dependency); + if !seen.insert(key) { + return Err(LockfileError::DuplicateNativeDependency( + native_dependency_label(dependency), + )); + } + } + Ok(()) + } + + fn validate_nix_adapters(&self) -> Result<(), LockfileError> { + let mut seen = BTreeSet::new(); + for adapter in &self.nix_adapters { + adapter + .validate() + .map_err(|error| LockfileError::InvalidNixAdapter(error.to_string()))?; + let key = nix_adapter_key(adapter); + if !seen.insert(key) { + return Err(LockfileError::DuplicateNixAdapter(nix_adapter_label( + adapter, + ))); + } + } + Ok(()) + } +} + +impl LockedPackage { + pub fn full_name(&self) -> String { + format!("{}/{}", self.org, self.name) + } +} + +fn invalid_package(package: &str, reason: &str) -> Result { + Err(LockfileError::InvalidPackageMetadata { + package: package.to_string(), + reason: reason.to_string(), + }) +} + +fn artifact_revision(sha256: &str) -> String { + format!("{ARTIFACT_REVISION_PREFIX}{sha256}") +} + +fn is_canonical_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// VCS backends do not all use Git's 40-hex object IDs, so lockfiles accept a +/// conservative printable revision alphabet while rejecting branch-like or +/// otherwise mutable names. The published tag is retained separately; this +/// field must identify one immutable source state. A canonical writer may use +/// `artifact-sha256:` when legacy or explicitly VCS-skipped registry +/// metadata has no stronger source revision; the digest still pins exact bytes. +fn is_immutable_vcs_revision(value: &str) -> bool { + if value != value.trim() || !(7..=128).contains(&value.len()) { + return false; + } + if value.bytes().all(|byte| byte == b'0') { + return false; + } + if !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'+' | b':' | b'/') + }) { + return false; + } + let lower = value.to_ascii_lowercase(); + !matches!( + lower.as_str(), + "head" | "main" | "master" | "trunk" | "latest" + ) && !lower.starts_with("refs/heads/") + && !lower.starts_with("heads/") +} + +fn native_dependency_key(dependency: &NativeDependencyLock) -> NativeDependencyKey { + ( + dependency.requirement.registry, + dependency.package.name.clone(), + ) +} + +fn native_dependency_label(dependency: &NativeDependencyLock) -> String { + format!( + "{:?}:{}", + dependency.requirement.registry, dependency.package.name + ) +} + +fn nix_adapter_key(adapter: &NixAdapterRecord) -> NixAdapterKey { + match adapter { + NixAdapterRecord::ZedToNix { package, .. } => ( + package.org.clone(), + package.name.clone(), + package.version.clone(), + package.target.clone(), + 0, + String::new(), + String::new(), + ), + NixAdapterRecord::NixToZed { + package, source, .. + } => ( + package.org.clone(), + package.name.clone(), + package.version.clone(), + package.target.clone(), + 1, + source.realized.system.clone(), + source.realized.output.clone(), + ), + } +} + +fn nix_adapter_label(adapter: &NixAdapterRecord) -> String { + let (direction, package, system, output) = match adapter { + NixAdapterRecord::ZedToNix { package, .. } => ("zed-to-nix", package, "-", "-"), + NixAdapterRecord::NixToZed { + package, source, .. + } => ( + "nix-to-zed", + package, + source.realized.system.as_str(), + source.realized.output.as_str(), + ), + }; + format!( + "{}/{}@{} target={} direction={direction} system={system} output={output}", + package.org, + package.name, + package.version, + package.target.as_deref().unwrap_or("-") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_LOCK: &str = r#"version = 1 + +[[package]] +org = "zed-pkg" +name = "fixture" +version = "1.2.3" +sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +size = 42 +format = "tar.gz" +vcs_tag = "v1.2.3" +vcs_commit = "fedcba9876543210fedcba9876543210fedcba98" +source = "file:///tmp/registry" +"#; + + const VALID_COMMIT_LINE: &str = "vcs_commit = \"fedcba9876543210fedcba9876543210fedcba98\""; + + fn lock_with_commit(revision: &str) -> String { + VALID_LOCK.replacen( + VALID_COMMIT_LINE, + &format!("vcs_commit = \"{revision}\""), + 1, + ) + } + + fn package_without_commit(sha256: &str) -> LockedPackage { + LockedPackage { + org: "zed-pkg".to_string(), + name: "fixture".to_string(), + version: "1.2.3".to_string(), + sha256: sha256.to_string(), + size: 42, + format: ArtifactFormat::TarGz, + vcs_tag: "v1.2.3".to_string(), + vcs_commit: None, + source: "file:///tmp/registry".to_string(), + } + } + + #[test] + fn complete_package_metadata_round_trips() { + let lock = Lockfile::parse(VALID_LOCK).unwrap(); + let serialized = lock.to_toml_string().unwrap(); + assert_eq!(Lockfile::parse(&serialized).unwrap(), lock); + } + + #[test] + fn missing_artifact_format_is_not_inferred() { + let input = VALID_LOCK.replace("format = \"tar.gz\"\n", ""); + let error = Lockfile::parse(&input).unwrap_err().to_string(); + assert!(error.contains("format"), "unexpected error: {error}"); + } + + #[test] + fn missing_vcs_commit_is_rejected() { + let input = VALID_LOCK.replace(&format!("{VALID_COMMIT_LINE}\n"), ""); + let error = Lockfile::parse(&input).unwrap_err().to_string(); + assert!(error.contains("vcs_commit"), "unexpected error: {error}"); + } + + #[test] + fn malformed_zero_and_empty_artifact_metadata_are_rejected() { + for (needle, replacement, expected) in [ + ( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "ABCDEF", + "sha256", + ), + ( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "0000000000000000000000000000000000000000000000000000000000000000", + "all-zero", + ), + ("size = 42", "size = 0", "size"), + ("vcs_tag = \"v1.2.3\"", "vcs_tag = \"\"", "vcs_tag"), + ( + "source = \"file:///tmp/registry\"", + "source = \"\"", + "source", + ), + ] { + let input = VALID_LOCK.replacen(needle, replacement, 1); + let error = Lockfile::parse(&input).unwrap_err().to_string(); + assert!(error.contains(expected), "unexpected error: {error}"); + } + } + + #[test] + fn mutable_malformed_and_zero_vcs_revisions_are_rejected() { + for revision in [ + "main", + "refs/heads/main", + "latest", + "0000000", + "short", + "revision with spaces", + "revision@host", + ] { + let input = lock_with_commit(revision); + let error = Lockfile::parse(&input).unwrap_err().to_string(); + assert!(error.contains("vcs_commit"), "unexpected error: {error}"); + } + } + + #[test] + fn non_git_and_content_addressed_immutable_revisions_remain_supported() { + for revision in [ + "fossil:0123456789abcdef", + "hg/0123456789abcdef0123456789abcdef01234567", + "pijul+ABCdef0123456789_-", + "artifact-sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ] { + let input = lock_with_commit(revision); + assert!( + Lockfile::parse(&input).is_ok(), + "revision rejected: {revision}" + ); + } + } + + #[test] + fn duplicate_package_identities_are_rejected() { + let package = VALID_LOCK.split_once("[[package]]").unwrap().1; + let input = format!("{VALID_LOCK}\n[[package]]{package}"); + let error = Lockfile::parse(&input).unwrap_err().to_string(); + assert!(error.contains("duplicate locked package identity")); + } + + #[test] + fn writer_normalizes_missing_commit_to_exact_artifact_revision() { + let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let lock = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: vec![package_without_commit(digest)], + native_dependencies: Vec::new(), + nix_adapters: Vec::new(), + }; + let serialized = lock.to_toml_string().unwrap(); + assert!(serialized.contains(&format!("vcs_commit = \"artifact-sha256:{digest}\""))); + assert!(lock.packages[0].vcs_commit.is_none()); + let parsed = Lockfile::parse(&serialized).unwrap(); + assert_eq!( + parsed.packages[0].vcs_commit.as_deref(), + Some(format!("artifact-sha256:{digest}").as_str()) + ); + } + + #[test] + fn writer_refuses_to_derive_provenance_from_invalid_hashes() { + for digest in [ + "not-a-sha256", + "0000000000000000000000000000000000000000000000000000000000000000", + ] { + let lock = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: vec![package_without_commit(digest)], + native_dependencies: Vec::new(), + nix_adapters: Vec::new(), + }; + let error = lock.to_toml_string().unwrap_err().to_string(); + assert!(error.contains("sha256"), "unexpected error: {error}"); + } + } + + #[test] + fn public_schema_requires_complete_package_provenance() { + let schema = schemars::schema_for!(Lockfile); + let value = serde_json::to_value(schema).unwrap(); + let package = &value["$defs"]["LockedPackage"]; + let required = package["required"].as_array().unwrap(); + let names = required + .iter() + .filter_map(|item| item.as_str()) + .collect::>(); + assert!(names.contains("format")); + assert!(names.contains("vcs_commit")); + assert_eq!(package["properties"]["vcs_commit"]["type"], "string"); + assert_eq!(package["properties"]["vcs_commit"]["minLength"], 7); + assert_eq!(package["properties"]["vcs_commit"]["maxLength"], 128); + assert_eq!(package["properties"]["sha256"]["minLength"], 64); + assert_eq!(package["properties"]["sha256"]["maxLength"], 64); + assert_eq!(package["properties"]["size"]["minimum"], 1); + } +} diff --git a/src/rust/manifest.rs b/src/rust/manifest.rs new file mode 100644 index 0000000..e75a798 --- /dev/null +++ b/src/rust/manifest.rs @@ -0,0 +1,1658 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::language::{Ecosystem, Language}; +use crate::nix::NixExportSection; +use crate::vcs::Vcs; +use crate::version::{Requirement, VersionScheme}; + +/// The `.zpkg.toml` manifest at the root of every package repository. +/// TOML only — never YAML or JSON. +/// +/// ```toml +/// [package] +/// org = "acme" +/// name = "http-kit" +/// version = "1.2.0" +/// description = "Tiny HTTP helpers" +/// license = "MIT" +/// +/// [package.repository] +/// vcs = "git" +/// url = "https://github.com/acme/http-kit" +/// +/// [dependencies] +/// "acme/logkit" = "^0.3" +/// +/// [publish] +/// exclude = ["benches/**"] +/// smoke_test = "sh scripts/smoke.sh" +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Manifest { + pub package: PackageSection, + /// Monorepo workspace declaration (zed-docs issue #7); only meaningful in + /// a workspace root manifest. When present, `zed install` at this root + /// resolves every member against one store and writes one `.zpkg.lock`; + /// member→member dependencies link by path instead of going through the + /// registry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, + /// Dependencies keyed by `org/name`, valued by a semver requirement. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub dependencies: BTreeMap, + /// Tools needed only while running this package's `[build]` command + /// (compilers, codegen). They are made available in the build sandbox and + /// never linked into a consumer's `zed_modules/`. See [`BuildSection`] + /// and zed-docs issue #5. Canonical TOML key is Cargo-style + /// `[build-dependencies]`; the snake_case spelling is accepted on read. + #[serde( + default, + rename = "build-dependencies", + alias = "build_dependencies", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub build_dependencies: BTreeMap, + /// Host-native packages required before this package's install hooks or + /// build step can run. Keys are supported package-manager names (`apt`, + /// `apk`, `brew`, `nix`, ...); values are package specs passed as argv, + /// never interpolated into a shell command. Installing these prerequisites + /// is an explicitly consented zed operation, separate from build-hook + /// consent. + #[serde( + default, + rename = "native-dependencies", + alias = "native_dependencies", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub native_dependencies: NativeDependencies, + /// Package-local lifecycle hooks. They run in a writable staging copy, + /// never in the immutable source store and never in the consumer project. + /// `pre-install` runs before `[build]`; `post-install` runs after it and + /// before the finalized artifact is promoted to the platform cache. + #[serde(default, skip_serializing_if = "InstallHooksSection::is_empty")] + pub hooks: InstallHooksSection, + /// This package's own post-extract build step (compiled extensions, + /// codegen), run when the package ships source that needs compiling. + /// Builds run in an isolated staging copy — never inside the immutable + /// source store — and results are cached per (sha256, target, command). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, + /// Consumer-side patches for dependencies (e.g. fixing a dependency's + /// broken or missing `[build]` step without waiting on upstream). + #[serde(default, skip_serializing_if = "OverridesSection::is_empty")] + pub overrides: OverridesSection, + /// Executables this package exposes, keyed by command name, valued by a + /// path relative to the package root. On install they are hoisted into + /// `zed_modules/.bin/` and runnable via `zed run ` (zed-docs + /// issue #7). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bin: BTreeMap, + #[serde(default)] + pub publish: PublishSection, + #[serde(default)] + pub scripts: ScriptsSection, + /// Where zed materializes the (few, hand-picked) dependencies it sources — + /// zed complements npm/maven/etc. rather than replacing them, so this dir + /// sits alongside the native one and the ecosystem adapter wires it into + /// the toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir` + /// defaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or + /// `.deps/.zed`. + #[serde(default, skip_serializing_if = "InstallSection::is_empty")] + pub install: InstallSection, + /// Language subtrees for a **polyglot package** — one repo shipping the + /// same library for several ecosystems (e.g. `node/`, `python/`, `go/`). + /// Keyed by ecosystem name; the value says which subdirectory is that + /// ecosystem's package root. On install the consumer resolves one target + /// and only that subtree is materialized, so a Python project gets the + /// Python source at its import root rather than a tree it has to reach + /// into. Absent (the common case) = single-language package, whole tree. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub targets: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageSection { + /// Namespace the package is published under. Lowercase slug. + pub org: String, + /// Package name, unique within the org. Lowercase slug. + pub name: String, + /// Version of this package, interpreted according to `version_scheme` + /// (semver by default). + pub version: String, + /// How `version` (and published tags) should be interpreted. Semver by + /// default; `calver` for calendar versions, `opaque` for arbitrary tags. + #[serde( + default, + skip_serializing_if = "crate::version::VersionScheme::is_default" + )] + pub version_scheme: VersionScheme, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + pub repository: RepositorySection, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub keywords: Vec, + /// The language this package's code targets. A multi-language repository + /// publishes one package per language, all at one version, and each names + /// its own — `acme-clients-java`, `acme-clients-nodejs`. Defaults to + /// `universal` (language-agnostic), which is never subject to the install + /// ecosystem guard. + #[serde(default, skip_serializing_if = "Language::is_default")] + pub language: Language, + /// How consumers take this package in: `jvm`, `npm`, `gomod`, … Drives the + /// install-time guard that refuses to drop a Java client into a Node + /// project, and picks the toolchain wiring `zed install` writes. + /// + /// Omit it and it is derived from `language` (see + /// [`Language::ecosystem`]) — declare it only when the language does not + /// determine consumption. Read through [`PackageSection::ecosystem`] + /// rather than touching this field, so the fallback always applies. + #[serde(default, skip_serializing_if = "Ecosystem::is_default")] + pub ecosystem: Ecosystem, +} + +impl PackageSection { + /// The effective ecosystem: the explicit `ecosystem` when declared, else + /// the one implied by `language`. + /// + /// Always use this instead of reading the field directly — a manifest that + /// says only `language = "java"` must still guard as `jvm`. + pub fn ecosystem(&self) -> Ecosystem { + if self.ecosystem.is_default() { + self.language.ecosystem() + } else { + self.ecosystem + } + } +} + +/// Where the package's source of truth lives. Any Git or Mercurial host +/// works: GitHub, GitLab, Bitbucket, or a self-hosted server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RepositorySection { + #[serde(default)] + pub vcs: Vcs, + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct PublishSection { + /// Extra glob patterns to exclude on top of the built-in defaults. + pub exclude: Vec, + /// Keep README files in the published artifact (stripped by default). + pub include_readme: bool, + /// Command run by `zed r2g` (alias `zed test-local`) inside a throwaway + /// consumer project that has this package installed the same way a real + /// consumer would. + pub smoke_test: Option, + /// VCS tag template that must exist and point at the published commit. + /// `{version}` is substituted with `package.version`. + pub tag_format: String, + /// Optional native-registry route for a single-language package whose + /// package-manager manifest lives at the repository root. Polyglot + /// packages declare this metadata on each `[targets.*.native]` section + /// instead. + #[serde(skip_serializing_if = "Option::is_none")] + pub native: Option, + /// Optional deterministic export of this single-language package as a + /// standalone Nix flake. Nix is a typed interop adapter, not a native + /// registry destination, so its intent remains separate from `native`. + #[serde(skip_serializing_if = "Option::is_none")] + pub nix: Option, +} + +impl Default for PublishSection { + fn default() -> Self { + Self { + exclude: Vec::new(), + include_readme: false, + smoke_test: None, + tag_format: "v{version}".to_string(), + native: None, + nix: None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct ScriptsSection { + /// Test command run from the repository (not from published artifacts; + /// tests are stripped at publish time). + pub test: Option, +} + +/// Install-layout controls: where zed's dependency tree lands and which +/// ecosystem adapter to emit so those deps are visible to the native toolchain. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct InstallSection { + /// Project-relative directory for the installed tree (`//`). + /// Defaults to `zed_modules`. Common overrides: `.vendor/.zed`, `.deps/.zed`. + /// Must be a safe relative path (no leading `/`, no `..`). + #[serde(skip_serializing_if = "Option::is_none")] + pub dir: Option, + /// Force the ecosystem adapter: `node`, `java`, or `none`. Omitted = + /// auto-detect (or the CLI `--adapter`). Mirrors the CLI's values. + #[serde(skip_serializing_if = "Option::is_none")] + pub adapter: Option, + /// Which language subtree to take from **polyglot** dependencies (see + /// [`TargetSection`]). Omitted = infer from the project (`package.json` → + /// `node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a + /// target a dependency does not publish is an error rather than a silent + /// fallback: the consumer asked for something specific. + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +impl InstallSection { + pub fn is_empty(&self) -> bool { + self.dir.is_none() && self.adapter.is_none() && self.target.is_none() + } +} + +/// One ecosystem's slice of a polyglot package — and, on publish, its own +/// independently installable package. +/// +/// A repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and +/// `clients/go` declares one target each. `zed publish` then emits **one +/// artifact per target**, named `-` by default: +/// +/// ```text +/// fiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only +/// fiducia/fiducia-clients-java@1.1.2 <- clients/java only +/// fiducia/fiducia-clients-golang@1.1.2 <- clients/go only +/// ``` +/// +/// One source of truth and one version in the repo; N packages on the wire. +/// A Java consumer downloads only Java bytes — the decisive advantage over +/// shipping one fat artifact and slicing it at install time. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TargetSection { + /// Package-relative directory that is this ecosystem's package root, e.g. + /// `python` or `clients/go`. Must be a safe relative path (no leading `/`, + /// no `..`) so a target can never escape the package. + pub dir: String, + /// Published package name for this target. Defaults to + /// `-` (e.g. `fiducia-clients-java`). Set it to + /// break out of the suffix convention when an ecosystem expects a + /// different spelling. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Ecosystem adapter consumers of THIS target should use (`node`, `java`, + /// `none`). Recorded in the published per-target manifest so a consumer + /// gets the right wiring without configuring it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, + /// Optional routing to this target's native ecosystem registry. This is + /// declarative metadata only: the native manifest remains authoritative, + /// and arbitrary commands are intentionally not representable here. + /// + /// Note the difference from [`TargetSection::ecosystem`]: `native` is + /// **outbound** (where this slice is mirrored to — npm, crates.io), while + /// `ecosystem` is **inbound** (what toolchain a consumer must have to + /// install it from zed). They describe the same ecosystem from two sides, + /// so when both are set they must agree — see + /// [`NativeRegistry::ecosystem`] and the check in [`Manifest::validate`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native: Option, + /// Optional deterministic Nix export intent for this isolated target. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nix: Option, + /// Native prerequisites added by this target. Entries merge with the + /// package-level `[native-dependencies]` table when this target is selected. + #[serde( + default, + rename = "native-dependencies", + alias = "native_dependencies", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub native_dependencies: NativeDependencies, + /// Target-specific lifecycle hooks, appended after package-level hooks in + /// each phase when the target is selected. + #[serde(default, skip_serializing_if = "InstallHooksSection::is_empty")] + pub hooks: InstallHooksSection, + /// Override the ecosystem this target publishes into. Omit it (the normal + /// case) and it is derived from the target key via [`Language::ecosystem`]. + /// Declare it when the key does not determine consumption — a `rust-wasm` + /// target is consumed by a JS bundler, not by Cargo. + #[serde(default, skip_serializing_if = "Ecosystem::is_default")] + pub ecosystem: Ecosystem, +} + +impl TargetSection { + /// The language this target ships, from its explicit key. `universal` when + /// the key is not a language zed knows — such a target still publishes and + /// installs, it just is not ecosystem-gated. + pub fn language_for(&self, target_key: &str) -> Language { + Language::from_token(target_key).unwrap_or_default() + } + + /// The ecosystem a consumer must have to install this target: the explicit + /// `ecosystem`, else the one implied by the target key. + pub fn ecosystem_for(&self, target_key: &str) -> Ecosystem { + if !self.ecosystem.is_default() { + return self.ecosystem; + } + self.language_for(target_key).ecosystem() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseSection { + pub registry: NativeRegistry, + pub package: String, + /// Optional VCS tag template for native ecosystems whose package is + /// resolved from a tag rather than uploaded. It must contain `{version}`. + /// Go modules below a repository subdirectory need the directory prefix, + /// for example `clients/go/v{version}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tag_format: Option, + /// Optional copies in package registries run by source forges. The native + /// registry remains the canonical ecosystem destination; these mirrors + /// use the same native package format and version. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub forge: Vec, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum NativeRegistry { + Npm, + CratesIo, + #[serde(rename = "pub.dev")] + PubDev, + #[serde(rename = "pypi")] + PyPi, + MavenCentral, + #[serde(rename = "rubygems")] + RubyGems, + #[serde(rename = "nuget")] + NuGet, + Packagist, + GoModules, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum ForgeRegistry { + GithubPackages, + GitlabPackages, + BitbucketPackages, +} + +impl ForgeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::GithubPackages => "github-packages", + Self::GitlabPackages => "gitlab-packages", + Self::BitbucketPackages => "bitbucket-packages", + } + } + + /// Package-manager protocols currently documented by each forge. Failing + /// closed here prevents a manifest from promising e.g. Cargo support in + /// GitHub Packages when that registry has no Cargo endpoint. + pub fn supports(self, native: NativeRegistry) -> bool { + match self { + Self::GithubPackages => matches!( + native, + NativeRegistry::Npm + | NativeRegistry::MavenCentral + | NativeRegistry::RubyGems + | NativeRegistry::NuGet + ), + Self::GitlabPackages => matches!( + native, + NativeRegistry::Npm + | NativeRegistry::PyPi + | NativeRegistry::MavenCentral + | NativeRegistry::RubyGems + | NativeRegistry::NuGet + | NativeRegistry::Packagist + | NativeRegistry::GoModules + ), + Self::BitbucketPackages => { + matches!(native, NativeRegistry::Npm | NativeRegistry::MavenCentral) + } + } + } +} + +impl std::fmt::Display for ForgeRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl NativeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + Self::PubDev => "pub.dev", + Self::PyPi => "pypi", + Self::MavenCentral => "maven-central", + Self::RubyGems => "rubygems", + Self::NuGet => "nuget", + Self::Packagist => "packagist", + Self::GoModules => "go-modules", + } + } + + fn validate_package(self, package: &str) -> Result<(), String> { + let valid = match self { + Self::Npm => is_valid_npm_package(package), + Self::CratesIo => is_valid_crates_package(package), + Self::PubDev => is_valid_pubdev_package(package), + Self::PyPi => is_valid_pypi_package(package), + Self::MavenCentral => is_valid_maven_package(package), + Self::RubyGems => is_valid_rubygems_package(package), + Self::NuGet => is_valid_nuget_package(package), + Self::Packagist => is_valid_packagist_package(package), + Self::GoModules => is_valid_go_module(package), + }; + if valid { + Ok(()) + } else { + Err(format!( + "package `{package}` is not a valid {} package identity", + self.as_str() + )) + } + } + + fn canonical_package(self, package: &str) -> String { + match self { + Self::PyPi => normalize_pypi_package(package), + Self::NuGet => package.to_ascii_lowercase(), + _ => package.to_string(), + } + } + + /// The zed [`Ecosystem`] this native registry corresponds to. + /// + /// The two enums describe the same thing from opposite directions — + /// `NativeRegistry` is where a slice is *mirrored to*, `Ecosystem` is what a + /// consumer needs to *install* it — so a target declaring both must not + /// disagree. Mapping them here keeps that check in one place instead of + /// letting each caller re-derive it. + pub fn ecosystem(self) -> Ecosystem { + match self { + Self::Npm => Ecosystem::Npm, + Self::CratesIo => Ecosystem::Cargo, + Self::PubDev => Ecosystem::Pub, + Self::PyPi => Ecosystem::Pypi, + Self::MavenCentral => Ecosystem::Jvm, + Self::RubyGems => Ecosystem::Gem, + Self::NuGet => Ecosystem::Nuget, + Self::Packagist => Ecosystem::Composer, + Self::GoModules => Ecosystem::Gomod, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: NativeRegistry, + pub package: String, + pub vcs_tag: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ForgeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: ForgeRegistry, + pub format: NativeRegistry, + pub package: String, + pub vcs_tag: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixExportRoute { + pub target: String, + pub dir: String, + pub package: String, + pub intent: NixExportSection, +} + +fn is_valid_npm_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 214 + && !value.starts_with('.') + && !value.starts_with('_') + && !value.contains("..") + && value.chars().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.' | '~') + }) +} + +fn is_valid_npm_package(value: &str) -> bool { + if let Some(scoped) = value.strip_prefix('@') { + let Some((scope, package)) = scoped.split_once('/') else { + return false; + }; + !package.contains('/') && is_valid_npm_component(scope) && is_valid_npm_component(package) + } else { + !value.contains('/') && is_valid_npm_component(value) + } +} + +fn is_valid_crates_package(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && !value.starts_with('-') + && !value.starts_with('_') + && !value.ends_with('-') + && !value.ends_with('_') + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) +} + +fn is_valid_pypi_package(value: &str) -> bool { + !value.is_empty() + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn normalize_pypi_package(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + let mut separator = false; + for byte in value.bytes() { + if matches!(byte, b'.' | b'_' | b'-') { + separator = true; + continue; + } + if separator && !normalized.is_empty() { + normalized.push('-'); + } + separator = false; + normalized.push((byte as char).to_ascii_lowercase()); + } + normalized +} + +fn is_valid_pubdev_package(value: &str) -> bool { + const RESERVED: &[&str] = &[ + "assert", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", + "enum", "extends", "false", "final", "finally", "for", "if", "in", "is", "new", "null", + "rethrow", "return", "super", "switch", "this", "throw", "true", "try", "var", "void", + "while", "with", "async", "await", "yield", + ]; + !value.is_empty() + && !value.as_bytes()[0].is_ascii_digit() + && !RESERVED.contains(&value) + && value + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +fn is_valid_maven_component(value: &str) -> bool { + !value.is_empty() + && !value.starts_with(['.', '-']) + && !value.ends_with(['.', '-']) + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) +} + +fn is_valid_maven_package(value: &str) -> bool { + let Some((group, artifact)) = value.split_once(':') else { + return false; + }; + !artifact.contains(':') && is_valid_maven_component(group) && is_valid_maven_component(artifact) +} + +fn is_valid_rubygems_package(value: &str) -> bool { + !value.is_empty() + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn is_valid_nuget_package(value: &str) -> bool { + value.len() <= 100 && is_valid_rubygems_package(value) +} + +fn is_valid_packagist_package(value: &str) -> bool { + let Some((vendor, package)) = value.split_once('/') else { + return false; + }; + !package.contains('/') && is_valid_npm_component(vendor) && is_valid_npm_component(package) +} + +fn is_valid_go_module(value: &str) -> bool { + !value.is_empty() + && value.contains('/') + && !value.starts_with('/') + && !value.ends_with('/') + && !value.contains("..") + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '-' | '_' | '~')) +} + +/// Supported host package managers for `[native-dependencies]`. +/// +/// The manifest names *packages*, never an installer command. zed maps these +/// stable identifiers to fixed argv templates and rejects unknown keys, so a +/// package cannot disguise arbitrary privileged shell execution as dependency +/// installation. +pub const NATIVE_PACKAGE_MANAGERS: &[&str] = &[ + "apk", "apt", "brew", "choco", "dnf", "nix", "pacman", "pkg", "port", "scoop", "winget", + "xbps", "yum", "zypper", +]; + +/// Manager name to package specs. A manager may intentionally map to an empty +/// list to state that it is supported without adding target-specific packages. +pub type NativeDependencies = BTreeMap>; + +/// Package-local lifecycle hooks. Commands are author-controlled shell code, +/// but execution is separately consented by the installer and occurs only in +/// a writable staging tree. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct InstallHooksSection { + #[serde( + rename = "pre-install", + alias = "pre_install", + skip_serializing_if = "Vec::is_empty" + )] + pub pre_install: Vec, + #[serde( + rename = "post-install", + alias = "post_install", + skip_serializing_if = "Vec::is_empty" + )] + pub post_install: Vec, +} + +impl InstallHooksSection { + pub fn is_empty(&self) -> bool { + self.pre_install.is_empty() && self.post_install.is_empty() + } + + /// Package hooks run before target-specific hooks in each phase. + pub fn merged(&self, target: &Self) -> Self { + let mut merged = self.clone(); + merged + .pre_install + .extend(target.pre_install.iter().cloned()); + merged + .post_install + .extend(target.post_install.iter().cloned()); + merged + } +} + +fn merged_native_dependencies( + package: &NativeDependencies, + target: &NativeDependencies, +) -> NativeDependencies { + let mut merged = package.clone(); + for (manager, packages) in target { + let existing = merged.entry(manager.clone()).or_default(); + let mut seen: BTreeSet = existing.iter().cloned().collect(); + for package in packages { + if seen.insert(package.clone()) { + existing.push(package.clone()); + } + } + } + merged +} + +fn validate_native_dependencies( + dependencies: &NativeDependencies, + context: &str, +) -> Result<(), ManifestError> { + for (manager, packages) in dependencies { + if !NATIVE_PACKAGE_MANAGERS.contains(&manager.as_str()) { + return Err(ManifestError::InvalidNativeDependency( + context.to_string(), + format!( + "package manager `{manager}` is unsupported; expected one of {}", + NATIVE_PACKAGE_MANAGERS.join(", ") + ), + )); + } + let mut seen = BTreeSet::new(); + for package in packages { + if package.trim() != package + || package.is_empty() + || package.len() > 256 + || package.starts_with('-') + || package.chars().any(char::is_whitespace) + || package.chars().any(char::is_control) + { + return Err(ManifestError::InvalidNativeDependency( + context.to_string(), + format!( + "invalid `{manager}` package spec `{package}`; specs must be 1-256 non-whitespace, non-control characters and cannot begin with `-`" + ), + )); + } + if !seen.insert(package) { + return Err(ManifestError::InvalidNativeDependency( + context.to_string(), + format!("duplicate `{manager}` package spec `{package}`"), + )); + } + } + } + Ok(()) +} + +fn validate_install_hooks(hooks: &InstallHooksSection, context: &str) -> Result<(), ManifestError> { + for (phase, commands) in [ + ("pre-install", &hooks.pre_install), + ("post-install", &hooks.post_install), + ] { + for (index, command) in commands.iter().enumerate() { + if command.trim().is_empty() { + return Err(ManifestError::InvalidInstallHook( + context.to_string(), + format!("{phase} command {} must not be empty", index + 1), + )); + } + if command.contains('\0') { + return Err(ManifestError::InvalidInstallHook( + context.to_string(), + format!("{phase} command {} contains NUL", index + 1), + )); + } + if command.len() > 32 * 1024 { + return Err(ManifestError::InvalidInstallHook( + context.to_string(), + format!("{phase} command {} exceeds 32768 bytes", index + 1), + )); + } + } + } + Ok(()) +} + +/// A post-extract build step. Because compiled output is OS/arch-specific, +/// zed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the +/// source and caches the result in a build cache keyed by +/// `(source sha256, target triple, command)` — separate from the universal, +/// platform-independent source store (zed-docs issue #5). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct BuildSection { + /// Command executed after extraction, e.g. `make` or `cargo build --release`. + pub command: String, + /// Paths (relative to the package root) to keep from the staging build. + /// Empty means keep everything. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, +} + +/// Monorepo workspace membership. `members` are glob patterns (relative to +/// the workspace root) selecting directories that each contain a `.zpkg.toml`, +/// e.g. `["packages/*", "apps/*"]`. Dependencies that resolve to a member are +/// linked straight to the member's source directory instead of going through +/// the registry. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct WorkspaceSection { + pub members: Vec, +} + +/// Consumer-side dependency patches, keyed by `org/name`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct OverridesSection { + /// Replace or provide a dependency's `[build]` step. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub build: BTreeMap, +} + +impl OverridesSection { + pub fn is_empty(&self) -> bool { + self.build.is_empty() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ManifestError { + #[error("invalid org slug `{0}`: must match [a-z0-9][a-z0-9-]*[a-z0-9]")] + InvalidOrg(String), + #[error("invalid package name `{0}`: must match [a-z0-9][a-z0-9-]*[a-z0-9]")] + InvalidName(String), + #[error("invalid version `{0}`: {1}")] + InvalidVersion(String, String), + #[error("invalid dependency key `{0}`: expected `org/name`")] + InvalidDependencyKey(String), + #[error("invalid requirement `{1}` for dependency `{0}`: {2}")] + InvalidDependencyReq(String, String, String), + #[error("invalid repository url `{0}`: {1}")] + InvalidRepositoryUrl(String, String), + #[error("invalid bin entry `{0}`: {1}")] + InvalidBin(String, String), + #[error("invalid build section: {0}")] + InvalidBuild(String), + #[error("invalid native dependency declaration for `{0}`: {1}")] + InvalidNativeDependency(String, String), + #[error("invalid install hook declaration for `{0}`: {1}")] + InvalidInstallHook(String, String), + #[error("invalid workspace member pattern `{0}`")] + InvalidWorkspaceMember(String), + #[error("invalid install dir `{0}`: {1}")] + InvalidInstallDir(String, String), + #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), + #[error("invalid native release route for target `{0}`: {1}")] + InvalidNativeRoute(String, String), + #[error("invalid Nix export route for target `{0}`: {1}")] + InvalidNixRoute(String, String), + #[error("manifest toml error: {0}")] + Toml(String), +} + +/// Ecosystem adapter names accepted by `[install].adapter` and +/// `[targets.*].adapter`. Kept here rather than in the CLI so a manifest is +/// validated the same way by the CLI, the registry, and the web UI. +/// +/// Each name is a toolchain zed can wire dependencies into. `none` opts out, +/// installing to `zed_modules/` only. This is deliberately smaller than +/// [`crate::language::Ecosystem`]: an ecosystem says what a package *is*, an +/// adapter says what zed can *wire*, and the second list grows more slowly. +pub const ADAPTERS: &[&str] = &["node", "java", "go", "python", "rust", "dart", "none"]; + +/// True for the lowercase slugs zed-pkg accepts as org and package names. +pub fn is_slug(s: &str) -> bool { + !s.is_empty() + && !s.starts_with('-') + && !s.ends_with('-') + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +/// True for a well-formed polyglot target name (`node`, `python`, `go`, …). +/// Same shape as a slug: these appear in manifests, CLI flags, and messages. +pub fn is_target_name(s: &str) -> bool { + is_slug(s) +} + +/// True for a well-formed `org/name` dependency key. +pub fn is_dependency_key(key: &str) -> bool { + let mut parts = key.splitn(2, '/'); + match (parts.next(), parts.next()) { + (Some(org), Some(name)) => is_slug(org) && is_slug(name), + _ => false, + } +} + +/// True for a 64-character lowercase-hex sha256 digest. Registry responses +/// and lockfiles feed digests into filesystem paths, so anything else is +/// rejected before it can reach the disk layer. +pub fn is_sha256_hex(s: &str) -> bool { + s.len() == 64 + && s.chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) +} + +/// True when `path` is a relative path with no `..` components — the only +/// shape allowed for manifest-declared paths (bin targets, build outputs). +/// Keeps hoisted bins and build outputs contained inside the package. +pub fn is_safe_relative_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.starts_with('\\') + && !path.contains('\0') + // windows drive/UNC prefixes + && !(path.len() >= 2 && path.as_bytes()[1] == b':') + && !path + .split(['/', '\\']) + .any(|seg| seg == ".." || seg.is_empty()) +} + +fn is_allowed_repo_url(url: &str) -> bool { + // The repo URL renders as a link in registry UIs and is shelled to VCS + // tooling, so restrict it to the schemes those consumers expect. + ["https://", "http://", "ssh://", "git://", "git+ssh://"] + .iter() + .any(|scheme| url.starts_with(scheme)) + // scp-like git syntax: git@github.com:org/repo.git + || (url.contains('@') && url.contains(':') && !url.contains("://")) +} + +fn validate_native_release_section( + native: &NativeReleaseSection, + route_name: &str, + route_dir: &str, + inbound: Ecosystem, +) -> Result<(), ManifestError> { + native + .registry + .validate_package(&native.package) + .map_err(|reason| ManifestError::InvalidNativeRoute(route_name.to_string(), reason))?; + + // The outbound mirror and the inbound install gate describe the same + // ecosystem. If they disagree, one of them is wrong and the package would + // either be mirrored to the wrong registry or refused for the wrong + // consumers — both silent until someone hits it, so fail here instead. + let outbound = native.registry.ecosystem(); + if !inbound.is_default() && inbound != outbound { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ + set the package or target ecosystem if the mirror is right", + native.registry.as_str() + ), + )); + } + + if let Some(tag_format) = &native.tag_format { + if !tag_format.contains("{version}") { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + "native `tag_format` must contain `{version}`".to_string(), + )); + } + if tag_format.trim() != tag_format + || tag_format.is_empty() + || tag_format.chars().any(char::is_whitespace) + { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + "native `tag_format` must be a non-empty VCS ref without whitespace".to_string(), + )); + } + } + if native.registry == NativeRegistry::GoModules && route_dir != "." { + let required_prefix = format!("{}/", route_dir.trim_end_matches('/')); + let Some(tag_format) = &native.tag_format else { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "Go module in `{route_dir}` requires `tag_format = \"{required_prefix}v{{version}}\"`" + ), + )); + }; + if !tag_format.starts_with(&required_prefix) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "Go module in `{route_dir}` requires a native tag prefixed by `{required_prefix}`" + ), + )); + } + } + + let mut forge_registries = std::collections::BTreeSet::new(); + for forge in &native.forge { + if !forge_registries.insert(*forge) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!("forge registry `{forge}` is listed more than once"), + )); + } + if !forge.supports(native.registry) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "forge registry `{forge}` does not support {} packages", + native.registry.as_str() + ), + )); + } + } + Ok(()) +} + +impl Manifest { + /// Parse and validate a `.zpkg.toml` document. + pub fn parse(input: &str) -> Result { + let manifest: Manifest = + toml::from_str(input).map_err(|e| ManifestError::Toml(e.to_string()))?; + manifest.validate()?; + Ok(manifest) + } + + pub fn to_toml_string(&self) -> Result { + toml::to_string_pretty(self).map_err(|e| ManifestError::Toml(e.to_string())) + } + + pub fn validate(&self) -> Result<(), ManifestError> { + if !is_slug(&self.package.org) { + return Err(ManifestError::InvalidOrg(self.package.org.clone())); + } + if !is_slug(&self.package.name) { + return Err(ManifestError::InvalidName(self.package.name.clone())); + } + self.package + .version_scheme + .validate_version(&self.package.version) + .map_err(|e| ManifestError::InvalidVersion(self.package.version.clone(), e))?; + if !is_allowed_repo_url(&self.package.repository.url) { + return Err(ManifestError::InvalidRepositoryUrl( + self.package.repository.url.clone(), + "expected an https/http/ssh/git URL or scp-like git syntax".to_string(), + )); + } + for (key, req) in self.dependencies.iter().chain(&self.build_dependencies) { + if !is_dependency_key(key) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } + // A requirement is either a semver range or an exact (opaque) tag. + if req.trim().is_empty() { + return Err(ManifestError::InvalidDependencyReq( + key.clone(), + req.clone(), + "requirement must not be empty".to_string(), + )); + } + // Ranges that *look* like semver ranges but do not parse are + // rejected rather than silently degrading to an opaque tag. + if let Err(reason) = Requirement::validate(req) { + return Err(ManifestError::InvalidDependencyReq( + key.clone(), + req.clone(), + reason, + )); + } + } + let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new(); + let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new(); + let mut nix_attributes = BTreeMap::::new(); + if let Some(native) = &self.publish.native { + if !self.targets.is_empty() { + return Err(ManifestError::InvalidNativeRoute( + "repository".to_string(), + "root `[publish.native]` is only valid for a single-language package; \ + polyglot packages declare `[targets..native]`" + .to_string(), + )); + } + validate_native_release_section(native, "repository", ".", self.package.ecosystem())?; + native_routes.insert( + ( + native.registry, + native.registry.canonical_package(&native.package), + ), + "repository", + ); + } + if let Some(nix) = &self.publish.nix { + if !self.targets.is_empty() { + return Err(ManifestError::InvalidNixRoute( + "repository".to_string(), + "root `[publish.nix]` is only valid for a single-language package; polyglot packages declare `[targets..nix]`" + .to_string(), + )); + } + nix.validate(&self.package.name).map_err(|error| { + ManifestError::InvalidNixRoute("repository".to_string(), error.to_string()) + })?; + nix_attributes.insert(nix.resolved_attribute(&self.package.name), "repository"); + } + validate_native_dependencies(&self.native_dependencies, "package")?; + validate_install_hooks(&self.hooks, "package")?; + for (name, target) in &self.targets { + if !is_target_name(name) { + return Err(ManifestError::InvalidTarget( + name.clone(), + "target names use [a-z0-9][a-z0-9-]* (e.g. `node`, `python`, `go`)".to_string(), + )); + } + if !is_safe_relative_path(&target.dir) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!("dir `{}` must be a relative path without `..`", target.dir), + )); + } + if let Some(previous) = target_dirs.insert(target.dir.as_str(), name.as_str()) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "dir `{}` is already owned by target `{previous}`; every target must have an isolated source root", + target.dir + ), + )); + } + let published_name = target + .name + .clone() + .unwrap_or_else(|| format!("{}-{name}", self.package.name)); + if !is_slug(&published_name) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "published name `{published_name}` must match [a-z0-9][a-z0-9-]*[a-z0-9]" + ), + )); + } + if published_name == self.package.name { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "published name `{published_name}` collides with the polyglot source package" + ), + )); + } + if let Some(previous) = published_names.insert(published_name.clone(), name.as_str()) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "published name `{published_name}` is already used by target `{previous}`" + ), + )); + } + if let Some(nix) = &target.nix { + nix.validate(&published_name).map_err(|error| { + ManifestError::InvalidNixRoute(name.clone(), error.to_string()) + })?; + let attribute = nix.resolved_attribute(&published_name); + if let Some(previous) = nix_attributes.insert(attribute.clone(), name.as_str()) { + return Err(ManifestError::InvalidNixRoute( + name.clone(), + format!( + "Nix attribute `{attribute}` is already used by target `{previous}`" + ), + )); + } + } + if let Some(adapter) = target.adapter.as_deref() + && !ADAPTERS.contains(&adapter) + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected one of {}", + ADAPTERS.join(", ") + ), + )); + } + validate_native_dependencies(&target.native_dependencies, &format!("target `{name}`"))?; + validate_install_hooks(&target.hooks, &format!("target `{name}`"))?; + if let Some(native) = &target.native { + if target.dir == "." { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + "the whole-repository target cannot publish to a native registry" + .to_string(), + )); + } + validate_native_release_section( + native, + name, + &target.dir, + target.ecosystem_for(name), + )?; + let canonical_package = native.registry.canonical_package(&native.package); + let route = (native.registry, canonical_package); + if let Some(previous) = native_routes.insert(route, name.as_str()) { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + format!( + "{} package `{}` is already routed by target `{previous}`", + native.registry.as_str(), + native.package + ), + )); + } + } + } + // A blank request means "no target", the same way a blank + // `[install].dir` falls back to the default rather than erroring. + if let Some(requested) = self.requested_target() + && !is_target_name(requested) + { + return Err(ManifestError::InvalidTarget( + requested.to_string(), + "target names use [a-z0-9][a-z0-9-]*".to_string(), + )); + } + for (bin_name, target) in &self.bin { + if bin_name.is_empty() + || bin_name.starts_with('.') + || bin_name + .chars() + .any(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')) + { + return Err(ManifestError::InvalidBin( + bin_name.clone(), + "names use [A-Za-z0-9._-] and cannot start with `.`".to_string(), + )); + } + if !is_safe_relative_path(target) { + return Err(ManifestError::InvalidBin( + bin_name.clone(), + format!("target `{target}` must be a relative path without `..`"), + )); + } + } + let overriding = self.overrides.build.values(); + for build in self.build.iter().chain(overriding) { + if build.command.trim().is_empty() { + return Err(ManifestError::InvalidBuild( + "command must not be empty".to_string(), + )); + } + for output in &build.outputs { + if !is_safe_relative_path(output) { + return Err(ManifestError::InvalidBuild(format!( + "output `{output}` must be a relative path without `..`" + ))); + } + } + } + for key in self.overrides.build.keys() { + if !is_dependency_key(key) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } + } + if let Some(ws) = &self.workspace { + for pat in &ws.members { + if pat.trim().is_empty() { + return Err(ManifestError::InvalidWorkspaceMember(pat.clone())); + } + } + } + if let Some(dir) = &self.install.dir + && !is_safe_relative_path(dir) + { + return Err(ManifestError::InvalidInstallDir( + dir.clone(), + "must be a relative path without `..` or a leading `/`".to_string(), + )); + } + Ok(()) + } + + /// Project-relative directory dependencies install into. Honors + /// `[install].dir` (e.g. `.vendor/.zed`), else the default `zed_modules`. + pub fn modules_dir(&self) -> &str { + self.install + .dir + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(crate::paths::MODULES_DIR) + } + + /// The consumer's requested polyglot target, if it named one explicitly. + pub fn requested_target(&self) -> Option<&str> { + self.install + .target + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + } + + /// True when this package ships per-ecosystem subtrees. + pub fn is_polyglot(&self) -> bool { + !self.targets.is_empty() + } + + /// The package name a target publishes under: its explicit `name`, else + /// the `-` convention (`fiducia-clients` + `java` → + /// `fiducia-clients-java`). + pub fn target_package_name(&self, target: &str) -> Option { + self.targets.get(target).map(|t| { + t.name + .clone() + .unwrap_or_else(|| format!("{}-{}", self.package.name, target)) + }) + } + + /// Nix export routes sorted by target name. These contain author intent only; + /// realization hashes and store paths live in versioned lock provenance. + pub fn nix_export_routes(&self) -> Vec { + let mut routes = Vec::new(); + if let Some(intent) = &self.publish.nix { + routes.push(NixExportRoute { + target: "repository".to_string(), + dir: ".".to_string(), + package: self.package.name.clone(), + intent: intent.clone(), + }); + } + for (target, section) in &self.targets { + if let Some(intent) = §ion.nix { + routes.push(NixExportRoute { + target: target.clone(), + dir: section.dir.clone(), + package: section + .name + .clone() + .unwrap_or_else(|| format!("{}-{target}", self.package.name)), + intent: intent.clone(), + }); + } + } + routes + } + + /// Native release routes sorted by target name, suitable for deterministic + /// credential-free planning before any registry adapter executes. + pub fn native_release_routes(&self) -> Vec { + self.publish + .native + .iter() + .map(|native| NativeReleaseRoute { + target: "repository".to_string(), + dir: ".".to_string(), + registry: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) + .chain(self.targets.iter().filter_map(|(target, section)| { + section.native.as_ref().map(|native| NativeReleaseRoute { + target: target.clone(), + dir: section.dir.clone(), + registry: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) + })) + .collect() + } + + /// Forge package-registry mirrors, flattened and sorted by target then + /// registry for deterministic release plans and CI matrices. + pub fn forge_release_routes(&self) -> Vec { + self.publish + .native + .iter() + .flat_map(|native| { + native + .forge + .iter() + .copied() + .map(move |registry| ForgeReleaseRoute { + target: "repository".to_string(), + dir: ".".to_string(), + registry, + format: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) + }) + .chain(self.targets.iter().flat_map(|(target, section)| { + section.native.iter().flat_map(move |native| { + native + .forge + .iter() + .copied() + .map(move |registry| ForgeReleaseRoute { + target: target.clone(), + dir: section.dir.clone(), + registry, + format: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) + }) + })) + .collect() + } + + /// Every `(target, published name)` pair this manifest fans out to, sorted + /// by target for deterministic publish order and output. + pub fn target_package_names(&self) -> Vec<(String, String)> { + let mut names: Vec<(String, String)> = self + .targets + .keys() + .filter_map(|target| { + self.target_package_name(target) + .map(|name| (target.clone(), name)) + }) + .collect(); + names.sort(); + names + } + + /// Derive the per-target manifest that ships *inside* that target's + /// artifact: same org/version/repo, the target's own package name, its + /// adapter, and no `[targets]` (the slice is single-language by + /// construction). Dependencies are carried over so a target's own zed + /// deps still resolve. + pub fn manifest_for_target(&self, target: &str) -> Option { + let name = self.target_package_name(target)?; + let section = self.targets.get(target)?; + let mut derived = self.clone(); + derived.package.name = name; + derived.package.description = Some(match &self.package.description { + Some(base) => format!("{base} ({target})"), + None => format!("{} ({target} client)", self.package.name), + }); + // Once re-rooted, a polyglot slice is a standalone package. Preserve + // its outbound native/forge routing under the single-package shape so + // the manifest inside the Zed artifact remains self-describing. + derived.publish.native = section.native.clone(); + derived.publish.nix = section.nix.clone(); + derived.native_dependencies = + merged_native_dependencies(&self.native_dependencies, §ion.native_dependencies); + derived.hooks = self.hooks.merged(§ion.hooks); + derived.targets = BTreeMap::new(); + derived.workspace = None; + // The consumer-facing wiring for this ecosystem. + derived.install.adapter = section.adapter.clone().or(self.install.adapter.clone()); + derived.install.target = None; + // Stamp the slice's identity so the *published* package self-describes + // as single-language. This is what lets a consumer's install refuse to + // drop `-java` into a Node-only project: the artifact says `jvm`, and + // the guard has something to compare against. + if let Some(language) = Language::from_token(target) { + derived.package.language = language; + derived.package.ecosystem = section.ecosystem_for(target); + } + Some(derived) + } + + /// The target key matching `requested`, honoring language synonyms. + /// + /// Target keys are chosen by the package author (`nodejs`, `node`, `ts`) + /// while a consumer's request comes from a flag, their manifest, or + /// inference — so the two spellings routinely differ for the same language. + /// Exact match wins; otherwise `requested` and each key are normalized + /// through [`Language::from_token`] and compared, which is what makes a + /// project detected as `node` resolve a `[targets.nodejs]` package (and + /// `go` reach `golang`). + pub fn resolve_target_key(&self, requested: &str) -> Option<&str> { + if let Some((key, _)) = self.targets.get_key_value(requested) { + return Some(key.as_str()); + } + let wanted = Language::from_token(requested).filter(|l| !l.is_default())?; + let mut matches = self + .targets + .keys() + .filter(|key| Language::from_token(key) == Some(wanted)); + let first = matches.next()?; + if matches.next().is_none() { + return Some(first.as_str()); + } + // Several targets share this language — a repo shipping separate `ts` + // and `js` clients has two `nodejs` targets. A synonym request like + // `node` must land somewhere predictable rather than on whichever key + // sorts first, so prefer the one named after the language itself. + self.targets + .keys() + .find(|key| key.as_str() == wanted.as_str()) + .or_else(|| { + self.targets + .keys() + .find(|key| Language::from_token(key) == Some(wanted)) + }) + .map(String::as_str) + } + + /// Resolve which subdirectory of *this* (dependency) package a consumer + /// asking for `requested` should get. + /// + /// * Not polyglot → `Ok(None)`: the whole tree, exactly as before. + /// * Polyglot + a matching target → `Ok(Some(dir))`. + /// * Polyglot + `requested` names a target this package does not publish + /// → `Err` listing what it does publish. An explicit request that cannot + /// be honored is a mistake worth surfacing, not something to paper over + /// by installing a tree the consumer's toolchain cannot read. + /// * Polyglot + nothing requested → `Ok(None)` (whole tree), so a consumer + /// that has not opted in keeps working. + pub fn target_subdir(&self, requested: Option<&str>) -> Result, ManifestError> { + if self.targets.is_empty() { + return Ok(None); + } + let Some(requested) = requested else { + return Ok(None); + }; + // Synonym-aware: a project inferred as `node` must resolve a package + // that spells its target `nodejs`, or every such consumer would hit the + // error below despite the package shipping exactly what they need. + match self + .resolve_target_key(requested) + .and_then(|key| self.targets.get(key)) + { + Some(target) => Ok(Some(target.dir.as_str())), + None => { + let mut available: Vec<&str> = self.targets.keys().map(String::as_str).collect(); + available.sort_unstable(); + Err(ManifestError::InvalidTarget( + requested.to_string(), + format!( + "package `{}/{}` publishes no such target; it provides: {}", + self.package.org, + self.package.name, + available.join(", ") + ), + )) + } + } + } + + /// Native prerequisites for the selected polyglot target. Package-level + /// entries are inherited; target entries append in declaration order and + /// are deterministically de-duplicated per manager. + pub fn effective_native_dependencies( + &self, + requested: Option<&str>, + ) -> Result { + let Some(requested) = requested else { + return Ok(self.native_dependencies.clone()); + }; + if self.targets.is_empty() { + return Ok(self.native_dependencies.clone()); + } + let key = self.resolve_target_key(requested).ok_or_else(|| { + let mut available: Vec<&str> = self.targets.keys().map(String::as_str).collect(); + available.sort_unstable(); + ManifestError::InvalidTarget( + requested.to_string(), + format!( + "package `{}/{}` publishes no such target; it provides: {}", + self.package.org, + self.package.name, + available.join(", ") + ), + ) + })?; + let target = self.targets.get(key).expect("resolved target exists"); + Ok(merged_native_dependencies( + &self.native_dependencies, + &target.native_dependencies, + )) + } + + /// Lifecycle hooks for the selected target. Package hooks run before + /// target hooks in each phase. + pub fn effective_install_hooks( + &self, + requested: Option<&str>, + ) -> Result { + let Some(requested) = requested else { + return Ok(self.hooks.clone()); + }; + if self.targets.is_empty() { + return Ok(self.hooks.clone()); + } + let key = self.resolve_target_key(requested).ok_or_else(|| { + let mut available: Vec<&str> = self.targets.keys().map(String::as_str).collect(); + available.sort_unstable(); + ManifestError::InvalidTarget( + requested.to_string(), + format!( + "package `{}/{}` publishes no such target; it provides: {}", + self.package.org, + self.package.name, + available.join(", ") + ), + ) + })?; + Ok(self + .hooks + .merged(&self.targets.get(key).expect("resolved target exists").hooks)) + } + + /// True when this manifest declares a non-empty monorepo workspace. + pub fn is_workspace_root(&self) -> bool { + self.workspace + .as_ref() + .is_some_and(|w| !w.members.is_empty()) + } + + /// The effective build step for a dependency `org/name`: this manifest's + /// `[overrides.build]` entry if present, else the dependency's own + /// `[build]`. Returns `None` when neither declares one. + pub fn effective_build( + &self, + dep_key: &str, + dep_build: Option<&BuildSection>, + ) -> Option { + self.overrides.build.get(dep_key).or(dep_build).cloned() + } + + /// `org/name`, the canonical package identifier. + pub fn full_name(&self) -> String { + format!("{}/{}", self.package.org, self.package.name) + } + + /// Parsed semver version. Only call after `validate()` has passed. + pub fn version(&self) -> Result { + semver::Version::parse(&self.package.version) + .map_err(|e| ManifestError::InvalidVersion(self.package.version.clone(), e.to_string())) + } + + /// The VCS tag that must exist for this version, e.g. `v1.2.0`. + pub fn vcs_tag(&self) -> String { + self.publish + .tag_format + .replace("{version}", &self.package.version) + } +} diff --git a/src/rust/native_dependency.rs b/src/rust/native_dependency.rs new file mode 100644 index 0000000..af005a3 --- /dev/null +++ b/src/rust/native_dependency.rs @@ -0,0 +1,1142 @@ +//! Lossless native-registry requirement translation and exact dependency locks. +//! +//! Native ecosystems do not assign identical semantics to every identical- +//! looking requirement. In npm, `1.2.3` is exact and `1.2` is an x-range. In +//! Cargo, both are caret-compatible requirements. Zed therefore records the +//! source registry, original declaration, and deterministic canonical SemVer +//! requirement before binding one exact resolved version to immutable artifact +//! identity. + +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use semver::{BuildMetadata, Version, VersionReq}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{NativeArtifact, NativePackageIdentity, NativeRegistry}; + +/// Current schema for one exact native dependency resolution. +pub const NATIVE_DEPENDENCY_LOCK_SCHEMA_V1: &str = "zed.native-dependency-lock/v1"; + +const MAX_REQUIREMENT_LEN: usize = 512; +const NPM_MAX_SAFE_COMPONENT: u64 = 9_007_199_254_740_991; + +/// One source-aware native requirement and its canonical SemVer translation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NativeVersionRequirement { + pub registry: NativeRegistry, + /// Exact project declaration before translation. + pub declared: String, + /// Deterministic `semver::VersionReq` representation used by Zed. + pub canonical: String, +} + +impl NativeVersionRequirement { + /// Translate the lossless v1 subset for npm or Cargo. + pub fn parse( + registry: NativeRegistry, + declared: impl Into, + ) -> Result { + let declared = declared.into(); + validate_requirement_input(registry, &declared)?; + let requirement = match registry { + NativeRegistry::Npm => translate_npm_requirement(&declared)?, + NativeRegistry::Cargo => translate_cargo_requirement(&declared)?, + }; + Ok(Self { + registry, + declared, + canonical: requirement.to_string(), + }) + } + + /// Recompute the canonical requirement so serialized translation receipts + /// cannot be edited independently from their source declaration. + pub fn validate(&self) -> Result<(), NativeDependencyError> { + let translated = Self::parse(self.registry, self.declared.clone())?; + if translated.canonical != self.canonical { + return Err(NativeDependencyError::CanonicalRequirementDrift { + declared: self.declared.clone(), + expected: translated.canonical, + found: self.canonical.clone(), + }); + } + Ok(()) + } + + /// Test one strict native version against the translated requirement. + pub fn matches(&self, version: &str) -> Result { + self.validate()?; + let requirement = parse_canonical_requirement(self.registry, &self.canonical)?; + let version = parse_native_version(self.registry, "version", version)?; + Ok(requirement.matches(&version)) + } + + fn parsed(&self) -> Result { + self.validate()?; + parse_canonical_requirement(self.registry, &self.canonical) + } +} + +/// One eligible version and immutable artifact returned by a native registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NativeVersionCandidate { + pub version: String, + pub artifact: NativeArtifact, +} + +/// Frozen native dependency identity. The declaration remains auditable, while +/// `package.version` and `artifact` are the only restore-time identities. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NativeDependencyLock { + pub schema: String, + pub requirement: NativeVersionRequirement, + pub package: NativePackageIdentity, + pub artifact: NativeArtifact, +} + +impl NativeDependencyLock { + pub const SCHEMA_V1: &'static str = NATIVE_DEPENDENCY_LOCK_SCHEMA_V1; + + /// Resolve the highest satisfying candidate independent of candidate + /// presentation order. Callers must prefilter registry policy such as + /// yanked versions; this function validates every supplied identity. + pub fn resolve( + registry: NativeRegistry, + package_name: impl Into, + declared_requirement: impl Into, + candidates: &[NativeVersionCandidate], + ) -> Result { + let package_name = package_name.into(); + validate_native_package_name(registry, &package_name)?; + let requirement = NativeVersionRequirement::parse(registry, declared_requirement)?; + let parsed_requirement = requirement.parsed()?; + + let mut seen = BTreeSet::new(); + let mut selected: Option<(&NativeVersionCandidate, Version)> = None; + for candidate in candidates { + let version = parse_native_version(registry, "candidate.version", &candidate.version)?; + validate_native_artifact(&candidate.artifact, "candidate.artifact")?; + if !seen.insert(version.clone()) { + return Err(NativeDependencyError::DuplicateCandidateVersion { + version: candidate.version.clone(), + }); + } + if parsed_requirement.matches(&version) + && selected + .as_ref() + .is_none_or(|(_, current)| version > *current) + { + selected = Some((candidate, version)); + } + } + + let (candidate, _) = selected.ok_or_else(|| NativeDependencyError::NoMatchingVersion { + registry, + package: package_name.clone(), + requirement: requirement.canonical.clone(), + })?; + + let lock = Self { + schema: Self::SCHEMA_V1.to_string(), + requirement, + package: NativePackageIdentity { + name: package_name, + version: candidate.version.clone(), + }, + artifact: candidate.artifact.clone(), + }; + lock.validate()?; + Ok(lock) + } + + pub fn validate(&self) -> Result<(), NativeDependencyError> { + if self.schema != Self::SCHEMA_V1 { + return Err(NativeDependencyError::UnsupportedSchema { + found: self.schema.clone(), + supported: Self::SCHEMA_V1.to_string(), + }); + } + self.requirement.validate()?; + validate_native_package_name(self.requirement.registry, &self.package.name)?; + let resolved = parse_native_version( + self.requirement.registry, + "package.version", + &self.package.version, + )?; + let requirement = self.requirement.parsed()?; + if !requirement.matches(&resolved) { + return Err(NativeDependencyError::ResolvedVersionDoesNotMatch { + package: self.package.name.clone(), + version: self.package.version.clone(), + requirement: self.requirement.canonical.clone(), + }); + } + validate_native_artifact(&self.artifact, "artifact") + } + + /// Stable compact JSON for lock provenance, signatures, and generated + /// client fixtures. + pub fn canonical_json_bytes(&self) -> Result, NativeDependencyError> { + self.validate()?; + serde_json::to_vec(self) + .map_err(|error| NativeDependencyError::Serialization(error.to_string())) + } +} + +fn validate_requirement_input( + registry: NativeRegistry, + declared: &str, +) -> Result<(), NativeDependencyError> { + if declared.is_empty() { + return Err(NativeDependencyError::EmptyRequirement { registry }); + } + if declared.len() > MAX_REQUIREMENT_LEN { + return Err(NativeDependencyError::RequirementTooLong { + registry, + length: declared.len(), + maximum: MAX_REQUIREMENT_LEN, + }); + } + if declared != declared.trim() { + return Err(NativeDependencyError::SurroundingWhitespace { + registry, + declared: declared.to_string(), + }); + } + if declared.chars().any(char::is_control) { + return Err(NativeDependencyError::ControlCharacter { + registry, + declared: declared.to_string(), + }); + } + if declared.contains("||") { + return Err(unsupported( + registry, + declared, + "logical unions are outside the lossless v1 subset", + )); + } + if declared.split_whitespace().any(|token| token == "-") { + return Err(unsupported( + registry, + declared, + "hyphen ranges are outside the lossless v1 subset", + )); + } + if declared.contains('+') { + return Err(NativeDependencyError::BuildMetadataNotAllowed { + field: "declared".to_string(), + version: declared.to_string(), + }); + } + + let lower = declared.to_ascii_lowercase(); + let source_prefixes = [ + "workspace:", + "file:", + "link:", + "git:", + "git+", + "http:", + "https:", + "ssh:", + "github:", + "npm:", + ]; + if declared.contains("://") + || source_prefixes + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + return Err(unsupported( + registry, + declared, + "source protocols, aliases, and workspace requirements are not SemVer ranges", + )); + } + Ok(()) +} + +fn translate_npm_requirement(declared: &str) -> Result { + if declared.contains(',') { + return Err(unsupported( + NativeRegistry::Npm, + declared, + "npm comparator intersections use whitespace, not Cargo commas, in strict v1", + )); + } + let normalized = coalesce_npm_tokens(declared)? + .iter() + .map(|token| normalize_npm_token(token)) + .collect::, _>>()? + .join(", "); + parse_requirement(NativeRegistry::Npm, declared, &normalized) +} + +fn coalesce_npm_tokens(declared: &str) -> Result, NativeDependencyError> { + let mut normalized = Vec::new(); + let mut words = declared.split_whitespace(); + while let Some(word) = words.next() { + if is_operator_token(word) { + let body = words.next().ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + declared, + "missing version after comparator", + ) + })?; + if !split_operator(body).0.is_empty() { + return Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "multiple comparator operators may not be separated by whitespace", + )); + } + normalized.push(format!("{word}{body}")); + } else { + normalized.push(word.to_string()); + } + } + Ok(normalized) +} + +fn normalize_npm_token(token: &str) -> Result { + let (operator, body) = split_operator(token); + if body.is_empty() { + return Err(invalid_requirement( + NativeRegistry::Npm, + token, + "missing version after comparator", + )); + } + let body = strip_numeric_v_prefix(body); + let body = normalize_x_components(body); + + if operator.is_empty() || operator == "=" { + return normalize_npm_bare(&body).ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + token, + "expected an exact version, partial version, or wildcard", + ) + }); + } + normalize_npm_comparator(operator, &body, token) +} + +fn normalize_npm_bare(body: &str) -> Option { + if let Ok(version) = Version::parse(body) { + if version.build != BuildMetadata::EMPTY || !npm_components_supported(&version) { + return None; + } + return Some(format!("={version}")); + } + + let partial = parse_npm_partial(body)?; + match partial.components.as_slice() { + [] if partial.wildcard => Some("*".to_string()), + [major] => Some(format!("{major}.*")), + [major, minor] => Some(format!("{major}.{minor}.*")), + [major, minor, patch] if !partial.wildcard => Some(format!("={major}.{minor}.{patch}")), + _ => None, + } +} + +fn normalize_npm_comparator( + operator: &str, + body: &str, + declared: &str, +) -> Result { + if let Ok(version) = Version::parse(body) { + if version.build != BuildMetadata::EMPTY { + return Err(NativeDependencyError::BuildMetadataNotAllowed { + field: "declared".to_string(), + version: declared.to_string(), + }); + } + if !npm_components_supported(&version) { + return Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "npm numeric components must not exceed Number.MAX_SAFE_INTEGER", + )); + } + return Ok(format!("{operator}{version}")); + } + + let partial = parse_npm_partial(body).ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + declared, + "expected a strict or partial numeric version after comparator", + ) + })?; + if partial.components.is_empty() { + return Err(unsupported( + NativeRegistry::Npm, + declared, + "comparators against an unconstrained wildcard are outside strict v1", + )); + } + + match operator { + "^" | "~" => Ok(format!("{operator}{}", partial.numeric_prefix())), + ">=" => Ok(format!(">={}", partial.lower_bound())), + "<" => Ok(format!("<{}", partial.lower_bound())), + ">" => Ok(format!(">={}", partial.next_prefix(declared)?)), + "<=" => Ok(format!("<{}", partial.next_prefix(declared)?)), + _ => Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "unsupported comparator operator", + )), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NpmPartialVersion { + components: Vec, + wildcard: bool, +} + +impl NpmPartialVersion { + fn numeric_prefix(&self) -> String { + self.components + .iter() + .map(u64::to_string) + .collect::>() + .join(".") + } + + fn lower_bound(&self) -> String { + match self.components.as_slice() { + [major] => format!("{major}.0.0"), + [major, minor] => format!("{major}.{minor}.0"), + [major, minor, patch] => format!("{major}.{minor}.{patch}"), + _ => unreachable!("validated npm partial has one to three numeric components"), + } + } + + fn next_prefix(&self, declared: &str) -> Result { + match self.components.as_slice() { + [major] => Ok(format!( + "{}.0.0", + increment_npm_component(*major, declared)? + )), + [major, minor] => Ok(format!( + "{major}.{}.0", + increment_npm_component(*minor, declared)? + )), + [major, minor, patch] if !self.wildcard => Ok(format!( + "{major}.{minor}.{}", + increment_npm_component(*patch, declared)? + )), + _ => Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "cannot advance this partial comparator without changing its meaning", + )), + } + } +} + +fn parse_npm_partial(body: &str) -> Option { + let parts: Vec<&str> = body.split('.').collect(); + if parts.is_empty() || parts.len() > 3 { + return None; + } + + let mut components = Vec::new(); + let mut wildcard = false; + for part in parts { + if part == "*" { + wildcard = true; + continue; + } + if wildcard + || part.is_empty() + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || (part.len() > 1 && part.starts_with('0')) + { + return None; + } + let component: u64 = part.parse().ok()?; + if component > NPM_MAX_SAFE_COMPONENT { + return None; + } + components.push(component); + } + + Some(NpmPartialVersion { + components, + wildcard, + }) +} + +fn increment_npm_component(component: u64, declared: &str) -> Result { + let incremented = component.checked_add(1).ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + declared, + "partial comparator component overflows SemVer", + ) + })?; + if incremented > NPM_MAX_SAFE_COMPONENT { + return Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "partial comparator increment exceeds Number.MAX_SAFE_INTEGER", + )); + } + Ok(incremented) +} + +fn translate_cargo_requirement(declared: &str) -> Result { + if cargo_contains_x_wildcard(declared) { + return Err(unsupported( + NativeRegistry::Cargo, + declared, + "Cargo wildcards use `*`; npm-style `x` and `X` are rejected", + )); + } + + let normalized = declared + .split(',') + .map(str::trim) + .map(|segment| normalize_cargo_segment(declared, segment)) + .collect::, _>>()? + .join(", "); + parse_requirement(NativeRegistry::Cargo, declared, &normalized) +} + +fn normalize_cargo_segment(declared: &str, segment: &str) -> Result { + if segment.is_empty() { + return Err(invalid_requirement( + NativeRegistry::Cargo, + declared, + "empty comparator in comma-separated requirement", + )); + } + let words: Vec<&str> = segment.split_whitespace().collect(); + match words.as_slice() { + [single] => Ok((*single).to_string()), + [operator, body] if is_operator_token(operator) && split_operator(body).0.is_empty() => { + Ok(format!("{operator}{body}")) + } + _ => Err(unsupported( + NativeRegistry::Cargo, + declared, + "multiple Cargo comparators require commas; whitespace is only allowed between one operator and its version", + )), + } +} + +fn is_operator_token(token: &str) -> bool { + matches!(token, ">=" | "<=" | "^" | "~" | ">" | "<" | "=") +} + +fn split_operator(token: &str) -> (&str, &str) { + for operator in [">=", "<=", "^", "~", ">", "<", "="] { + if let Some(body) = token.strip_prefix(operator) { + return (operator, body); + } + } + ("", token) +} + +fn strip_numeric_v_prefix(body: &str) -> &str { + body.strip_prefix('v') + .filter(|rest| { + rest.bytes() + .next() + .is_some_and(|byte| byte.is_ascii_digit()) + }) + .unwrap_or(body) +} + +fn normalize_x_components(body: &str) -> String { + body.split('.') + .map(|part| { + if part.eq_ignore_ascii_case("x") { + "*" + } else { + part + } + }) + .collect::>() + .join(".") +} + +fn cargo_contains_x_wildcard(declared: &str) -> bool { + declared + .split(|character: char| character.is_whitespace() || character == ',') + .filter(|token| !token.is_empty()) + .any(|token| { + let (_, body) = split_operator(token); + let core = body.split('-').next().unwrap_or(body); + core.split('.').any(|part| part.eq_ignore_ascii_case("x")) + }) +} + +fn parse_requirement( + registry: NativeRegistry, + declared: &str, + normalized: &str, +) -> Result { + VersionReq::parse(normalized).map_err(|error| NativeDependencyError::InvalidRequirement { + registry, + declared: declared.to_string(), + detail: error.to_string(), + }) +} + +fn parse_canonical_requirement( + registry: NativeRegistry, + canonical: &str, +) -> Result { + VersionReq::parse(canonical).map_err(|error| { + NativeDependencyError::InvalidCanonicalRequirement { + registry, + canonical: canonical.to_string(), + detail: error.to_string(), + } + }) +} + +fn parse_native_version( + registry: NativeRegistry, + field: &str, + raw: &str, +) -> Result { + let version = parse_strict_version(field, raw)?; + if registry == NativeRegistry::Npm && !npm_components_supported(&version) { + return Err(NativeDependencyError::InvalidVersion { + field: field.to_string(), + version: raw.to_string(), + detail: "npm numeric components must not exceed Number.MAX_SAFE_INTEGER".to_string(), + }); + } + Ok(version) +} + +fn npm_components_supported(version: &Version) -> bool { + version.major <= NPM_MAX_SAFE_COMPONENT + && version.minor <= NPM_MAX_SAFE_COMPONENT + && version.patch <= NPM_MAX_SAFE_COMPONENT +} + +fn parse_strict_version(field: &str, raw: &str) -> Result { + let version = Version::parse(raw).map_err(|error| NativeDependencyError::InvalidVersion { + field: field.to_string(), + version: raw.to_string(), + detail: error.to_string(), + })?; + if version.build != BuildMetadata::EMPTY { + return Err(NativeDependencyError::BuildMetadataNotAllowed { + field: field.to_string(), + version: raw.to_string(), + }); + } + Ok(version) +} + +fn validate_native_package_name( + registry: NativeRegistry, + name: &str, +) -> Result<(), NativeDependencyError> { + match registry { + NativeRegistry::Cargo => { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(NativeDependencyError::InvalidPackageName { + registry, + name: name.to_string(), + detail: "expected 1-64 ASCII alphanumeric, `-`, or `_` characters".to_string(), + }); + } + } + NativeRegistry::Npm => validate_npm_package_name(name)?, + } + Ok(()) +} + +fn validate_npm_package_name(name: &str) -> Result<(), NativeDependencyError> { + if name.is_empty() || name.len() > 214 || name.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "expected a lowercase npm name no longer than 214 bytes".to_string(), + }); + } + let components: Vec<&str> = if let Some(scoped) = name.strip_prefix('@') { + let mut parts = scoped.split('/'); + let scope = parts.next().unwrap_or_default(); + let package = parts.next().unwrap_or_default(); + if scope.is_empty() || package.is_empty() || parts.next().is_some() { + return Err(NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "scoped names must use exactly `@scope/package`".to_string(), + }); + } + vec![scope, package] + } else { + if name.contains('/') { + return Err(NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "unscoped names may not contain `/`".to_string(), + }); + } + vec![name] + }; + + for component in components { + let first = + component + .bytes() + .next() + .ok_or_else(|| NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must not be empty".to_string(), + })?; + if !first.is_ascii_lowercase() && !first.is_ascii_digit() { + return Err(NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must start with a lowercase letter or digit".to_string(), + }); + } + if !component.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') + }) { + return Err(NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components may contain lowercase letters, digits, `-`, `_`, or `.`" + .to_string(), + }); + } + } + Ok(()) +} + +fn validate_native_artifact( + artifact: &NativeArtifact, + field: &str, +) -> Result<(), NativeDependencyError> { + if artifact.sha256.len() != 64 + || !artifact + .sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || artifact.sha256.bytes().all(|byte| byte == b'0') + { + return Err(NativeDependencyError::InvalidSha256 { + field: format!("{field}.sha256"), + value: artifact.sha256.clone(), + }); + } + if artifact.size == 0 { + return Err(NativeDependencyError::EmptyArtifact { + field: field.to_string(), + }); + } + Ok(()) +} + +fn unsupported(registry: NativeRegistry, declared: &str, reason: &str) -> NativeDependencyError { + NativeDependencyError::UnsupportedRequirement { + registry, + declared: declared.to_string(), + reason: reason.to_string(), + } +} + +fn invalid_requirement( + registry: NativeRegistry, + declared: &str, + detail: &str, +) -> NativeDependencyError { + NativeDependencyError::InvalidRequirement { + registry, + declared: declared.to_string(), + detail: detail.to_string(), + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum NativeDependencyError { + #[error("unsupported native dependency lock schema `{found}`; expected `{supported}`")] + UnsupportedSchema { found: String, supported: String }, + #[error("empty {registry:?} requirement is not reproducible")] + EmptyRequirement { registry: NativeRegistry }, + #[error("{registry:?} requirement length {length} exceeds the {maximum}-byte limit")] + RequirementTooLong { + registry: NativeRegistry, + length: usize, + maximum: usize, + }, + #[error("{registry:?} requirement must not contain surrounding whitespace: `{declared}`")] + SurroundingWhitespace { + registry: NativeRegistry, + declared: String, + }, + #[error("{registry:?} requirement contains a control character: `{declared}`")] + ControlCharacter { + registry: NativeRegistry, + declared: String, + }, + #[error("unsupported {registry:?} requirement `{declared}`: {reason}")] + UnsupportedRequirement { + registry: NativeRegistry, + declared: String, + reason: String, + }, + #[error("invalid {registry:?} requirement `{declared}`: {detail}")] + InvalidRequirement { + registry: NativeRegistry, + declared: String, + detail: String, + }, + #[error("invalid canonical {registry:?} requirement `{canonical}`: {detail}")] + InvalidCanonicalRequirement { + registry: NativeRegistry, + canonical: String, + detail: String, + }, + #[error("canonical requirement drift for `{declared}`: expected `{expected}`, found `{found}`")] + CanonicalRequirementDrift { + declared: String, + expected: String, + found: String, + }, + #[error("invalid strict SemVer `{version}` at `{field}`: {detail}")] + InvalidVersion { + field: String, + version: String, + detail: String, + }, + #[error("SemVer build metadata is not allowed at `{field}` (`{version}`)")] + BuildMetadataNotAllowed { field: String, version: String }, + #[error("invalid native package name `{name}` for {registry:?}: {detail}")] + InvalidPackageName { + registry: NativeRegistry, + name: String, + detail: String, + }, + #[error("duplicate native candidate version `{version}`")] + DuplicateCandidateVersion { version: String }, + #[error("no {registry:?} version of `{package}` satisfies `{requirement}`")] + NoMatchingVersion { + registry: NativeRegistry, + package: String, + requirement: String, + }, + #[error("resolved native package `{package}@{version}` does not satisfy `{requirement}`")] + ResolvedVersionDoesNotMatch { + package: String, + version: String, + requirement: String, + }, + #[error("invalid lowercase nonzero SHA-256 `{value}` at `{field}`")] + InvalidSha256 { field: String, value: String }, + #[error("artifact at `{field}` must contain at least one byte")] + EmptyArtifact { field: String }, + #[error("failed to serialize native dependency lock: {0}")] + Serialization(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ArtifactFormat; + + fn artifact(digit: char) -> NativeArtifact { + NativeArtifact { + sha256: std::iter::repeat_n(digit, 64).collect(), + size: 128, + format: ArtifactFormat::TarGz, + } + } + + fn candidate(version: &str, digit: char) -> NativeVersionCandidate { + NativeVersionCandidate { + version: version.to_string(), + artifact: artifact(digit), + } + } + + #[test] + fn npm_bare_exact_and_cargo_default_caret_are_distinct() { + let npm = NativeVersionRequirement::parse(NativeRegistry::Npm, "1.2.3").unwrap(); + let cargo = NativeVersionRequirement::parse(NativeRegistry::Cargo, "1.2.3").unwrap(); + assert_eq!(npm.canonical, "=1.2.3"); + assert_eq!(cargo.canonical, "^1.2.3"); + assert!(npm.matches("1.2.3").unwrap()); + assert!(!npm.matches("1.2.4").unwrap()); + assert!(cargo.matches("1.9.9").unwrap()); + assert!(!cargo.matches("2.0.0").unwrap()); + } + + #[test] + fn npm_partial_versions_are_x_ranges_while_cargo_partials_are_caret() { + let npm_minor = NativeVersionRequirement::parse(NativeRegistry::Npm, "1.2").unwrap(); + let cargo_minor = NativeVersionRequirement::parse(NativeRegistry::Cargo, "1.2").unwrap(); + let npm_major = NativeVersionRequirement::parse(NativeRegistry::Npm, "1").unwrap(); + let cargo_major = NativeVersionRequirement::parse(NativeRegistry::Cargo, "1").unwrap(); + + assert_eq!(npm_minor.canonical, "1.2.*"); + assert_eq!(cargo_minor.canonical, "^1.2"); + assert_eq!(npm_major.canonical, "1.*"); + assert_eq!(cargo_major.canonical, "^1"); + assert!(!npm_minor.matches("1.3.0").unwrap()); + assert!(cargo_minor.matches("1.3.0").unwrap()); + } + + #[test] + fn native_wildcards_and_comparator_intersections_are_source_aware() { + let npm = NativeVersionRequirement::parse(NativeRegistry::Npm, ">=1.2.3 <2.0.0").unwrap(); + let cargo = + NativeVersionRequirement::parse(NativeRegistry::Cargo, ">=1.2.3, <2.0.0").unwrap(); + let npm_x = NativeVersionRequirement::parse(NativeRegistry::Npm, "1.2.X").unwrap(); + + assert_eq!(npm.canonical, ">=1.2.3, <2.0.0"); + assert_eq!(cargo.canonical, ">=1.2.3, <2.0.0"); + assert_eq!(npm_x.canonical, "1.2.*"); + assert!(npm.matches("1.9.0").unwrap()); + assert!(!cargo.matches("2.0.0").unwrap()); + } + + #[test] + fn major_zero_and_prerelease_rules_follow_semver() { + let cargo = NativeVersionRequirement::parse(NativeRegistry::Cargo, "0.2.3").unwrap(); + assert!(cargo.matches("0.2.9").unwrap()); + assert!(!cargo.matches("0.3.0").unwrap()); + + let ordinary = NativeVersionRequirement::parse(NativeRegistry::Npm, "^1.2.3").unwrap(); + assert!(!ordinary.matches("1.3.0-beta.1").unwrap()); + let explicit = + NativeVersionRequirement::parse(NativeRegistry::Npm, "1.3.0-beta.1").unwrap(); + assert!(explicit.matches("1.3.0-beta.1").unwrap()); + } + + #[test] + fn npm_partial_comparators_follow_node_semver_boundaries() { + let gt_major = NativeVersionRequirement::parse(NativeRegistry::Npm, ">1").unwrap(); + let gt_minor = NativeVersionRequirement::parse(NativeRegistry::Npm, ">1.2").unwrap(); + let lte_minor = NativeVersionRequirement::parse(NativeRegistry::Npm, "<=1.2").unwrap(); + let spaced = + NativeVersionRequirement::parse(NativeRegistry::Npm, ">= 1.2.3 < 2.0.0").unwrap(); + + assert_eq!(gt_major.canonical, ">=2.0.0"); + assert_eq!(gt_minor.canonical, ">=1.3.0"); + assert_eq!(lte_minor.canonical, "<1.3.0"); + assert_eq!(spaced.canonical, ">=1.2.3, <2.0.0"); + assert!(!gt_major.matches("1.99.99").unwrap()); + assert!(gt_major.matches("2.0.0").unwrap()); + assert!(!gt_minor.matches("1.2.999").unwrap()); + assert!(gt_minor.matches("1.3.0").unwrap()); + assert!(lte_minor.matches("1.2.999").unwrap()); + assert!(!lte_minor.matches("1.3.0").unwrap()); + } + + #[test] + fn cargo_allows_operator_whitespace_but_still_requires_comma_intersections() { + let requirement = + NativeVersionRequirement::parse(NativeRegistry::Cargo, ">= 1.2, < 1.5").unwrap(); + assert_eq!(requirement.canonical, ">=1.2, <1.5"); + assert!(requirement.matches("1.4.99").unwrap()); + assert!(!requirement.matches("1.5.0").unwrap()); + assert!(NativeVersionRequirement::parse(NativeRegistry::Cargo, ">= 1.2 < 1.5",).is_err()); + } + + #[test] + fn npm_rejects_leading_zero_and_unsafe_integer_components() { + for requirement in [ + "01.2", + "1.02", + ">1.02", + "^01.2", + "9007199254740992.0.0", + ">9007199254740991", + ] { + assert!(NativeVersionRequirement::parse(NativeRegistry::Npm, requirement).is_err()); + } + + assert!(matches!( + NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "*", + &[candidate("9007199254740992.0.0", 'a')], + ), + Err(NativeDependencyError::InvalidVersion { .. }) + )); + } + + #[test] + fn resolution_is_highest_satisfying_and_order_independent() { + let candidates = vec![ + candidate("1.2.3", 'a'), + candidate("1.9.0", 'b'), + candidate("2.0.0", 'c'), + candidate("1.10.0", 'd'), + ]; + let first = NativeDependencyLock::resolve( + NativeRegistry::Cargo, + "fiducia_core", + "1.2.3", + &candidates, + ) + .unwrap(); + let mut reversed = candidates; + reversed.reverse(); + let second = NativeDependencyLock::resolve( + NativeRegistry::Cargo, + "fiducia_core", + "1.2.3", + &reversed, + ) + .unwrap(); + + assert_eq!(first.package.version, "1.10.0"); + assert_eq!(first, second); + assert_eq!( + first.canonical_json_bytes().unwrap(), + second.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn npm_exact_resolution_does_not_float() { + let lock = NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "1.2.3", + &[candidate("1.2.3", 'a'), candidate("1.2.4", 'b')], + ) + .unwrap(); + assert_eq!(lock.package.version, "1.2.3"); + assert_eq!(lock.requirement.canonical, "=1.2.3"); + } + + #[test] + fn unsupported_or_mutable_native_syntax_fails_closed() { + for requirement in [ + "1.0.0 || 2.0.0", + "1.0.0 - 2.0.0", + "latest", + "workspace:^1.0.0", + "file:../core", + "npm:@scope/core@1.0.0", + ">=1.0.0, <2.0.0", + ] { + assert!(NativeVersionRequirement::parse(NativeRegistry::Npm, requirement).is_err()); + } + for requirement in [ + "1.0.0 || 2.0.0", + ">=1.0.0 <2.0.0", + "1.x", + "git+https://example.invalid/core", + ] { + assert!(NativeVersionRequirement::parse(NativeRegistry::Cargo, requirement).is_err()); + } + } + + #[test] + fn exact_lock_rejects_translation_version_and_artifact_drift() { + let mut lock = NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "^1.2.3", + &[candidate("1.9.0", 'a')], + ) + .unwrap(); + + lock.requirement.canonical = "^1.3.0".to_string(); + assert!(matches!( + lock.validate(), + Err(NativeDependencyError::CanonicalRequirementDrift { .. }) + )); + + let mut lock = NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "^1.2.3", + &[candidate("1.9.0", 'a')], + ) + .unwrap(); + lock.package.version = "2.0.0".to_string(); + assert!(matches!( + lock.validate(), + Err(NativeDependencyError::ResolvedVersionDoesNotMatch { .. }) + )); + + let mut lock = NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "^1.2.3", + &[candidate("1.9.0", 'a')], + ) + .unwrap(); + lock.artifact.sha256 = "0".repeat(64); + assert!(matches!( + lock.validate(), + Err(NativeDependencyError::InvalidSha256 { .. }) + )); + } + + #[test] + fn candidate_identity_is_strict_and_unambiguous() { + let duplicate = [candidate("1.2.3", 'a'), candidate("1.2.3", 'b')]; + assert!(matches!( + NativeDependencyLock::resolve( + NativeRegistry::Cargo, + "fiducia_core", + "1.2.3", + &duplicate, + ), + Err(NativeDependencyError::DuplicateCandidateVersion { .. }) + )); + + let build_metadata = [candidate("1.2.3+linux", 'a')]; + assert!(matches!( + NativeDependencyLock::resolve( + NativeRegistry::Cargo, + "fiducia_core", + "1.2.3", + &build_metadata, + ), + Err(NativeDependencyError::BuildMetadataNotAllowed { .. }) + )); + + let malformed_artifact = [NativeVersionCandidate { + version: "1.2.3".to_string(), + artifact: NativeArtifact { + sha256: "A".repeat(64), + size: 128, + format: ArtifactFormat::TarGz, + }, + }]; + assert!(matches!( + NativeDependencyLock::resolve( + NativeRegistry::Cargo, + "fiducia_core", + "1.2.3", + &malformed_artifact, + ), + Err(NativeDependencyError::InvalidSha256 { .. }) + )); + } +} diff --git a/src/rust/native_registry.rs b/src/rust/native_registry.rs new file mode 100644 index 0000000..20ee3c5 --- /dev/null +++ b/src/rust/native_registry.rs @@ -0,0 +1,909 @@ +//! Immutable publication contracts for npm, Cargo, and future native registries. +//! +//! Zed may resolve versions using its broader polyglot version model, but a +//! publication crossing a strict native-registry boundary must use one exact +//! Semantic Versioning 2.0.0 identity. Platform identity is represented by +//! explicit operating-system, architecture, and libc selectors. It is never +//! encoded in SemVer build metadata, because build metadata does not +//! participate in version precedence and some registries treat versions that +//! differ only there as the same release. + +use std::collections::{BTreeMap, BTreeSet}; + +use schemars::JsonSchema; +use semver::{BuildMetadata, Version}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::ArtifactFormat; + +/// Current immutable native-registry adapter-record schema. +pub const NATIVE_REGISTRY_ADAPTER_SCHEMA_V1: &str = "zed.native-registry-adapter/v1"; + +/// Native registries with a first-class publication identity contract. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NativeRegistry { + Npm, + Cargo, +} + +/// Role of one package in a native-registry publication family. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum NativePublicationKind { + /// One package whose payload is portable across all supported platforms. + Portable, + /// A generic wrapper package that selects platform-specific packages. + Meta, + /// One package containing bytes for exactly one explicit platform. + Platform, +} + +/// Platform identity kept outside the package version. +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +pub struct NativePlatform { + pub os: String, + pub arch: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub libc: Option, +} + +impl NativePlatform { + /// Stable human-readable selector used in diagnostics and deterministic + /// ordering. It is not a package name and does not affect version + /// precedence. + pub fn selector(&self) -> String { + match &self.libc { + Some(libc) => format!("{}-{}-{libc}", self.os, self.arch), + None => format!("{}-{}", self.os, self.arch), + } + } + + fn validate(&self, field: &str) -> Result<(), NativeRegistryError> { + validate_lower_token(&format!("{field}.os"), &self.os)?; + validate_lower_token(&format!("{field}.arch"), &self.arch)?; + if let Some(libc) = &self.libc { + validate_lower_token(&format!("{field}.libc"), libc)?; + } + Ok(()) + } +} + +/// Strict SemVer source identity in the Zed registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ZedNativePackageIdentity { + pub org: String, + pub name: String, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +impl ZedNativePackageIdentity { + fn validate(&self) -> Result<(), NativeRegistryError> { + validate_identity_component("source.org", &self.org)?; + validate_identity_component("source.name", &self.name)?; + if let Some(target) = &self.target { + validate_identity_component("source.target", target)?; + } + validate_native_version("source.version", &self.version).map(|_| ()) + } +} + +/// Public identity selected in npm or a Cargo-compatible registry. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct NativePackageIdentity { + pub name: String, + pub version: String, +} + +impl NativePackageIdentity { + fn validate(&self, registry: NativeRegistry, field: &str) -> Result<(), NativeRegistryError> { + validate_native_package_name(registry, &self.name)?; + validate_native_version(&format!("{field}.version"), &self.version).map(|_| ()) + } +} + +/// Exact immutable bytes published under one native package identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeArtifact { + /// Lowercase hexadecimal SHA-256 of the exact uploaded archive bytes. + pub sha256: String, + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, +} + +impl NativeArtifact { + fn validate(&self, field: &str) -> Result<(), NativeRegistryError> { + if self.sha256.len() != 64 + || !self + .sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(NativeRegistryError::InvalidSha256 { + field: format!("{field}.sha256"), + value: self.sha256.clone(), + }); + } + if self.size == 0 { + return Err(NativeRegistryError::EmptyArtifact { + field: field.to_string(), + }); + } + Ok(()) + } +} + +/// One platform-to-package edge emitted by a generic wrapper package. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct NativePlatformPackage { + pub platform: NativePlatform, + pub package: String, +} + +impl NativePlatformPackage { + fn validate(&self, registry: NativeRegistry, field: &str) -> Result<(), NativeRegistryError> { + self.platform.validate(&format!("{field}.platform"))?; + validate_native_package_name(registry, &self.package) + } +} + +/// One uploaded package within a publication family. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativePublication { + pub package: NativePackageIdentity, + pub kind: NativePublicationKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform: Option, + /// Only a `meta` publication may contain selector edges. Each edge must + /// reference a `platform` publication in the same adapter record. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub platform_packages: Vec, + pub artifact: NativeArtifact, +} + +impl NativePublication { + fn normalized(&self) -> Self { + let mut publication = self.clone(); + publication.platform_packages.sort(); + publication + } + + fn validate( + &self, + registry: NativeRegistry, + source_version: &str, + index: usize, + ) -> Result<(), NativeRegistryError> { + let field = format!("publications[{index}]"); + self.package + .validate(registry, &format!("{field}.package"))?; + if self.package.version != source_version { + return Err(NativeRegistryError::VersionDrift { + package: self.package.name.clone(), + source_version: source_version.to_string(), + publication_version: self.package.version.clone(), + }); + } + + match self.kind { + NativePublicationKind::Platform => { + let platform = self.platform.as_ref().ok_or_else(|| { + NativeRegistryError::PlatformRequired { + package: self.package.name.clone(), + } + })?; + platform.validate(&format!("{field}.platform"))?; + if !self.platform_packages.is_empty() { + return Err(NativeRegistryError::PlatformPackagesNotAllowed { + package: self.package.name.clone(), + kind: self.kind, + }); + } + } + NativePublicationKind::Portable | NativePublicationKind::Meta => { + if self.platform.is_some() { + return Err(NativeRegistryError::UnexpectedPlatform { + package: self.package.name.clone(), + kind: self.kind, + }); + } + if self.kind == NativePublicationKind::Portable + && !self.platform_packages.is_empty() + { + return Err(NativeRegistryError::PlatformPackagesNotAllowed { + package: self.package.name.clone(), + kind: self.kind, + }); + } + } + } + + let mut selected_platforms = BTreeSet::new(); + for (selection_index, selection) in self.platform_packages.iter().enumerate() { + selection.validate( + registry, + &format!("{field}.platform-packages[{selection_index}]"), + )?; + if !selected_platforms.insert(selection.platform.clone()) { + return Err(NativeRegistryError::DuplicateMetaPlatform { + package: self.package.name.clone(), + platform: selection.platform.selector(), + }); + } + } + self.artifact.validate(&format!("{field}.artifact")) + } +} + +/// Final immutable mapping from one Zed source target to a family of native +/// registry packages. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeRegistryAdapterRecord { + pub schema: String, + pub registry: NativeRegistry, + pub source: ZedNativePackageIdentity, + pub publications: Vec, +} + +impl NativeRegistryAdapterRecord { + pub const SCHEMA_V1: &'static str = NATIVE_REGISTRY_ADAPTER_SCHEMA_V1; + + /// Presentation-independent ordering for hashing, signing, lock + /// provenance, and generated client fixtures. + pub fn normalized(&self) -> Self { + let mut record = self.clone(); + record.publications = record + .publications + .iter() + .map(NativePublication::normalized) + .collect(); + record.publications.sort_by(|left, right| { + ( + &left.package, + left.kind, + &left.platform, + &left.platform_packages, + &left.artifact.sha256, + ) + .cmp(&( + &right.package, + right.kind, + &right.platform, + &right.platform_packages, + &right.artifact.sha256, + )) + }); + record + } + + /// Canonical compact JSON bytes. Validation runs first so invalid identity + /// cannot be made acceptable merely by normalization. + pub fn canonical_json_bytes(&self) -> Result, NativeRegistryError> { + self.validate()?; + serde_json::to_vec(&self.normalized()) + .map_err(|error| NativeRegistryError::Serialization(error.to_string())) + } + + pub fn validate(&self) -> Result<(), NativeRegistryError> { + if self.schema != Self::SCHEMA_V1 { + return Err(NativeRegistryError::UnsupportedSchema { + found: self.schema.clone(), + supported: Self::SCHEMA_V1.to_string(), + }); + } + self.source.validate()?; + if self.publications.is_empty() { + return Err(NativeRegistryError::NoPublications); + } + + let mut package_identities = BTreeSet::new(); + let mut platform_publications = BTreeMap::new(); + let mut meta_count = 0usize; + let mut portable_count = 0usize; + + for (index, publication) in self.publications.iter().enumerate() { + publication.validate(self.registry, &self.source.version, index)?; + let identity = ( + publication.package.name.clone(), + semver_precedence_identity(&publication.package.version)?, + ); + if !package_identities.insert(identity.clone()) { + return Err(NativeRegistryError::DuplicatePackageVersion { + package: identity.0, + version: identity.1, + }); + } + + match publication.kind { + NativePublicationKind::Meta => { + meta_count += 1; + if meta_count > 1 { + return Err(NativeRegistryError::MultipleMetaPackages); + } + } + NativePublicationKind::Platform => { + let platform = publication + .platform + .as_ref() + .expect("platform publication validated above") + .clone(); + if let Some(existing) = platform_publications + .insert(platform.clone(), publication.package.name.clone()) + { + return Err(NativeRegistryError::DuplicatePlatformPublication { + platform: platform.selector(), + first: existing, + second: publication.package.name.clone(), + }); + } + } + NativePublicationKind::Portable => { + portable_count += 1; + if portable_count > 1 { + return Err(NativeRegistryError::MultiplePortablePackages); + } + } + } + } + + for publication in self + .publications + .iter() + .filter(|publication| publication.kind == NativePublicationKind::Meta) + { + if publication.platform_packages.is_empty() { + return Err(NativeRegistryError::MetaPackageRequiresPlatformSelections { + package: publication.package.name.clone(), + }); + } + + let mut selected_platforms = BTreeSet::new(); + for selection in &publication.platform_packages { + selected_platforms.insert(selection.platform.clone()); + match platform_publications.get(&selection.platform) { + Some(package) if package == &selection.package => {} + Some(package) => { + return Err(NativeRegistryError::PlatformPackageMismatch { + meta_package: publication.package.name.clone(), + platform: selection.platform.selector(), + expected: package.clone(), + selected: selection.package.clone(), + }); + } + None => { + return Err(NativeRegistryError::MissingPlatformPublication { + meta_package: publication.package.name.clone(), + platform: selection.platform.selector(), + selected: selection.package.clone(), + }); + } + } + } + + for (platform, package) in &platform_publications { + if !selected_platforms.contains(platform) { + return Err(NativeRegistryError::UnselectedPlatformPublication { + meta_package: publication.package.name.clone(), + platform: platform.selector(), + package: package.clone(), + }); + } + } + } + + Ok(()) + } +} + +/// SemVer precedence identity with build metadata removed. +/// +/// Native adapters can use this before planning a publication set to detect +/// collisions such as `1.0.0+linux` and `1.0.0+darwin`. +pub fn semver_precedence_identity(version: &str) -> Result { + let mut version = + Version::parse(version).map_err(|error| NativeRegistryError::InvalidSemver { + field: "version".to_string(), + version: version.to_string(), + detail: error.to_string(), + })?; + version.build = BuildMetadata::EMPTY; + Ok(version.to_string()) +} + +/// Whether two valid SemVer strings identify the same native-registry version +/// after build metadata is ignored. +pub fn native_versions_collide(left: &str, right: &str) -> Result { + Ok(semver_precedence_identity(left)? == semver_precedence_identity(right)?) +} + +fn validate_native_version(field: &str, version: &str) -> Result { + let parsed = Version::parse(version).map_err(|error| NativeRegistryError::InvalidSemver { + field: field.to_string(), + version: version.to_string(), + detail: error.to_string(), + })?; + if parsed.build != BuildMetadata::EMPTY { + return Err(NativeRegistryError::BuildMetadataNotAllowed { + field: field.to_string(), + version: version.to_string(), + }); + } + Ok(parsed) +} + +fn validate_native_package_name( + registry: NativeRegistry, + name: &str, +) -> Result<(), NativeRegistryError> { + match registry { + NativeRegistry::Cargo => validate_cargo_name(name), + NativeRegistry::Npm => validate_npm_name(name), + } +} + +fn validate_cargo_name(name: &str) -> Result<(), NativeRegistryError> { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Cargo, + name: name.to_string(), + detail: "expected 1-64 ASCII alphanumeric, `-`, or `_` characters".to_string(), + }); + } + Ok(()) +} + +fn validate_npm_name(name: &str) -> Result<(), NativeRegistryError> { + if name.is_empty() || name.len() > 214 || name.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "expected a lowercase npm name no longer than 214 bytes".to_string(), + }); + } + + let components: Vec<&str> = if let Some(scoped) = name.strip_prefix('@') { + let mut parts = scoped.split('/'); + let scope = parts.next().unwrap_or_default(); + let package = parts.next().unwrap_or_default(); + if scope.is_empty() || package.is_empty() || parts.next().is_some() { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "scoped names must use exactly `@scope/package`".to_string(), + }); + } + vec![scope, package] + } else { + if name.contains('/') { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "unscoped names may not contain `/`".to_string(), + }); + } + vec![name] + }; + + for component in components { + let mut bytes = component.bytes(); + let first = bytes + .next() + .ok_or_else(|| NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must not be empty".to_string(), + })?; + if !first.is_ascii_lowercase() && !first.is_ascii_digit() { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must start with a lowercase letter or digit".to_string(), + }); + } + if !component.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') + }) { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components may contain lowercase letters, digits, `-`, `_`, or `.`" + .to_string(), + }); + } + } + Ok(()) +} + +fn validate_identity_component(field: &str, value: &str) -> Result<(), NativeRegistryError> { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(NativeRegistryError::InvalidIdentityComponent { + field: field.to_string(), + value: value.to_string(), + }); + } + Ok(()) +} + +fn validate_lower_token(field: &str, value: &str) -> Result<(), NativeRegistryError> { + if value.is_empty() + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') + }) + { + return Err(NativeRegistryError::InvalidPlatformToken { + field: field.to_string(), + value: value.to_string(), + }); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum NativeRegistryError { + #[error("unsupported native-registry adapter schema `{found}`; expected `{supported}`")] + UnsupportedSchema { found: String, supported: String }, + #[error("native-registry adapter record must contain at least one publication")] + NoPublications, + #[error("invalid native package name `{name}` for {registry:?}: {detail}")] + InvalidPackageName { + registry: NativeRegistry, + name: String, + detail: String, + }, + #[error("invalid strict SemVer `{version}` at `{field}`: {detail}")] + InvalidSemver { + field: String, + version: String, + detail: String, + }, + #[error( + "SemVer build metadata is not allowed at `{field}` (`{version}`); encode platform identity outside the version" + )] + BuildMetadataNotAllowed { field: String, version: String }, + #[error( + "native publication `{package}` uses version `{publication_version}`, but the Zed source uses `{source_version}`" + )] + VersionDrift { + package: String, + source_version: String, + publication_version: String, + }, + #[error("invalid Zed package identity component `{value}` at `{field}`")] + InvalidIdentityComponent { field: String, value: String }, + #[error("invalid lowercase platform token `{value}` at `{field}`")] + InvalidPlatformToken { field: String, value: String }, + #[error("invalid lowercase SHA-256 `{value}` at `{field}`")] + InvalidSha256 { field: String, value: String }, + #[error("artifact at `{field}` must contain at least one byte")] + EmptyArtifact { field: String }, + #[error("platform publication `{package}` requires an explicit platform selector")] + PlatformRequired { package: String }, + #[error("{kind:?} publication `{package}` may not contain a platform selector")] + UnexpectedPlatform { + package: String, + kind: NativePublicationKind, + }, + #[error("{kind:?} publication `{package}` may not select platform packages")] + PlatformPackagesNotAllowed { + package: String, + kind: NativePublicationKind, + }, + #[error("meta publication `{package}` selects platform `{platform}` more than once")] + DuplicateMetaPlatform { package: String, platform: String }, + #[error("duplicate native package identity `{package}@{version}`")] + DuplicatePackageVersion { package: String, version: String }, + #[error("one adapter record may contain at most one portable package")] + MultiplePortablePackages, + #[error("one adapter record may contain at most one meta package")] + MultipleMetaPackages, + #[error("meta publication `{package}` must select at least one platform package")] + MetaPackageRequiresPlatformSelections { package: String }, + #[error("platform `{platform}` is published twice by `{first}` and `{second}`")] + DuplicatePlatformPublication { + platform: String, + first: String, + second: String, + }, + #[error( + "meta package `{meta_package}` selects `{selected}` for `{platform}`, but the record publishes `{expected}`" + )] + PlatformPackageMismatch { + meta_package: String, + platform: String, + expected: String, + selected: String, + }, + #[error( + "meta package `{meta_package}` selects missing platform package `{selected}` for `{platform}`" + )] + MissingPlatformPublication { + meta_package: String, + platform: String, + selected: String, + }, + #[error( + "meta package `{meta_package}` does not select published platform package `{package}` for `{platform}`" + )] + UnselectedPlatformPublication { + meta_package: String, + platform: String, + package: String, + }, + #[error("failed to serialize native-registry adapter record: {0}")] + Serialization(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn platform(os: &str, arch: &str, libc: Option<&str>) -> NativePlatform { + NativePlatform { + os: os.to_string(), + arch: arch.to_string(), + libc: libc.map(str::to_string), + } + } + + fn artifact(byte: char) -> NativeArtifact { + NativeArtifact { + sha256: std::iter::repeat_n(byte, 64).collect(), + size: 128, + format: ArtifactFormat::TarGz, + } + } + + fn publication( + name: &str, + kind: NativePublicationKind, + platform: Option, + byte: char, + ) -> NativePublication { + NativePublication { + package: NativePackageIdentity { + name: name.to_string(), + version: "1.2.3".to_string(), + }, + kind, + platform, + platform_packages: Vec::new(), + artifact: artifact(byte), + } + } + + fn record() -> NativeRegistryAdapterRecord { + let linux = platform("linux", "arm64", Some("musl")); + let darwin = platform("darwin", "arm64", None); + let mut meta = publication("@fiducia/core", NativePublicationKind::Meta, None, 'a'); + meta.platform_packages = vec![ + NativePlatformPackage { + platform: linux.clone(), + package: "@fiducia/core-linux-arm64-musl".to_string(), + }, + NativePlatformPackage { + platform: darwin.clone(), + package: "@fiducia/core-darwin-arm64".to_string(), + }, + ]; + NativeRegistryAdapterRecord { + schema: NATIVE_REGISTRY_ADAPTER_SCHEMA_V1.to_string(), + registry: NativeRegistry::Npm, + source: ZedNativePackageIdentity { + org: "fiducia".to_string(), + name: "core".to_string(), + version: "1.2.3".to_string(), + target: Some("node".to_string()), + }, + publications: vec![ + publication( + "@fiducia/core-linux-arm64-musl", + NativePublicationKind::Platform, + Some(linux), + 'b', + ), + meta, + publication( + "@fiducia/core-darwin-arm64", + NativePublicationKind::Platform, + Some(darwin), + 'c', + ), + ], + } + } + + #[test] + fn valid_multi_platform_record_is_canonical() { + let mut reversed = record(); + reversed.publications.reverse(); + for publication in &mut reversed.publications { + publication.platform_packages.reverse(); + } + + assert!(record().validate().is_ok()); + assert_eq!( + record().canonical_json_bytes().unwrap(), + reversed.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn build_metadata_collides_and_is_rejected_for_publication() { + assert!(native_versions_collide("1.2.3+linux", "1.2.3+darwin").unwrap()); + assert!(!native_versions_collide("1.2.3-rc.1", "1.2.3").unwrap()); + + let mut record = record(); + record.source.version = "1.2.3+linux".to_string(); + for publication in &mut record.publications { + publication.package.version = "1.2.3+linux".to_string(); + } + assert!(matches!( + record.validate(), + Err(NativeRegistryError::BuildMetadataNotAllowed { .. }) + )); + } + + #[test] + fn platform_identity_cannot_be_smuggled_into_a_portable_publication() { + let mut record = record(); + let meta = record + .publications + .iter_mut() + .find(|publication| publication.kind == NativePublicationKind::Meta) + .unwrap(); + meta.platform = Some(platform("linux", "x64", Some("gnu"))); + assert!(matches!( + record.validate(), + Err(NativeRegistryError::UnexpectedPlatform { .. }) + )); + } + + #[test] + fn meta_edges_must_reference_the_exact_platform_publication() { + let mut record = record(); + let meta = record + .publications + .iter_mut() + .find(|publication| publication.kind == NativePublicationKind::Meta) + .unwrap(); + meta.platform_packages[0].package = "@fiducia/not-published".to_string(); + assert!(matches!( + record.validate(), + Err(NativeRegistryError::PlatformPackageMismatch { .. }) + )); + } + + #[test] + fn duplicate_platforms_and_precedence_identities_fail_closed() { + let mut duplicate_platform = record(); + let first_platform = duplicate_platform + .publications + .iter() + .find(|publication| publication.kind == NativePublicationKind::Platform) + .unwrap() + .platform + .clone(); + let mut extra = publication( + "@fiducia/core-another", + NativePublicationKind::Platform, + first_platform, + 'd', + ); + extra.package.version = "1.2.3".to_string(); + duplicate_platform.publications.push(extra); + assert!(matches!( + duplicate_platform.validate(), + Err(NativeRegistryError::DuplicatePlatformPublication { .. }) + )); + + let mut duplicate_package = record(); + let duplicate = duplicate_package.publications[0].clone(); + duplicate_package.publications.push(duplicate); + assert!(matches!( + duplicate_package.validate(), + Err(NativeRegistryError::DuplicatePackageVersion { .. }) + )); + } + + #[test] + fn publication_family_cardinality_and_meta_coverage_fail_closed() { + let mut multiple_portable = record(); + multiple_portable.publications.extend([ + publication( + "@fiducia/core-portable", + NativePublicationKind::Portable, + None, + 'd', + ), + publication( + "@fiducia/core-portable-extra", + NativePublicationKind::Portable, + None, + 'e', + ), + ]); + assert!(matches!( + multiple_portable.validate(), + Err(NativeRegistryError::MultiplePortablePackages) + )); + + let mut empty_meta = record(); + empty_meta + .publications + .iter_mut() + .find(|publication| publication.kind == NativePublicationKind::Meta) + .unwrap() + .platform_packages + .clear(); + assert!(matches!( + empty_meta.validate(), + Err(NativeRegistryError::MetaPackageRequiresPlatformSelections { .. }) + )); + + let mut incomplete_meta = record(); + incomplete_meta + .publications + .iter_mut() + .find(|publication| publication.kind == NativePublicationKind::Meta) + .unwrap() + .platform_packages + .pop(); + assert!(matches!( + incomplete_meta.validate(), + Err(NativeRegistryError::UnselectedPlatformPublication { .. }) + )); + + let mut platform_only = record(); + platform_only + .publications + .retain(|publication| publication.kind == NativePublicationKind::Platform); + assert!(platform_only.validate().is_ok()); + } + + #[test] + fn cargo_and_npm_names_use_conservative_portable_subsets() { + assert!(validate_native_package_name(NativeRegistry::Cargo, "fiducia_core-sys").is_ok()); + assert!(validate_native_package_name(NativeRegistry::Cargo, "bad/name").is_err()); + assert!( + validate_native_package_name(NativeRegistry::Npm, "@fiducia/core-linux-x64").is_ok() + ); + assert!(validate_native_package_name(NativeRegistry::Npm, "@Fiducia/core").is_err()); + assert!(validate_native_package_name(NativeRegistry::Npm, "../core").is_err()); + } + + #[test] + fn artifact_and_version_drift_are_rejected() { + let mut invalid_artifact = record(); + invalid_artifact.publications[0].artifact.sha256 = "A".repeat(64); + assert!(matches!( + invalid_artifact.validate(), + Err(NativeRegistryError::InvalidSha256 { .. }) + )); + + let mut version_drift = record(); + version_drift.publications[0].package.version = "1.2.4".to_string(); + assert!(matches!( + version_drift.validate(), + Err(NativeRegistryError::VersionDrift { .. }) + )); + } +} diff --git a/src/rust/nix.rs b/src/rust/nix.rs new file mode 100644 index 0000000..6556c9d --- /dev/null +++ b/src/rust/nix.rs @@ -0,0 +1,1003 @@ +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::artifact::ArtifactFormat; +use crate::manifest::{is_sha256_hex, is_slug, is_target_name}; + +/// Major-versioned identifier for the first immutable Nix ↔ Zed adapter +/// contract. Unknown major versions must fail closed. +pub const NIX_ADAPTER_SCHEMA_V1: &str = "zed.nix-adapter/v1"; + +/// Store-object JSON versions accepted by the v1 contract. The CLI still +/// records the concrete Nix version because these new-CLI formats are +/// versioned independently of this crate. +pub const SUPPORTED_STORE_INFO_JSON_VERSIONS: &[u32] = &[1, 2, 3]; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum NixExportMode { + /// Export the exact immutable Zed artifact. Source-builder translation is + /// intentionally not inferred from native manifests in contract v1. + #[default] + Artifact, +} + +/// Author intent for exporting a package or target to Nix. +/// +/// This structure contains no realized store paths, hashes, commands, +/// credentials, cache keys, or service deployment policy. Those belong in a +/// versioned adapter record after planning/realization. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct NixExportSection { + pub mode: NixExportMode, + /// Optional Nix package attribute. Omit it to use the Zed package name. + #[serde(skip_serializing_if = "Option::is_none")] + pub attribute: Option, + /// Explicit Nix systems this package claims to support. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub systems: Vec, + /// Explicit derivation outputs. Contract v1 never silently selects the + /// first output of a multi-output derivation. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, +} + +impl NixExportSection { + pub fn resolved_attribute(&self, default_attribute: &str) -> String { + self.attribute + .clone() + .unwrap_or_else(|| default_attribute.to_string()) + } + + pub fn validate(&self, default_attribute: &str) -> Result<(), NixInteropError> { + let attribute = self.resolved_attribute(default_attribute); + if !is_nix_identifier(&attribute) { + return Err(NixInteropError::InvalidExportIntent(format!( + "attribute `{attribute}` must be a Nix identifier" + ))); + } + if attribute == "default" { + return Err(NixInteropError::InvalidExportIntent( + "attribute `default` is reserved for the generated flake alias; set an explicit non-default attribute" + .to_string(), + )); + } + if self.systems.is_empty() { + return Err(NixInteropError::InvalidExportIntent( + "at least one explicit Nix system is required".to_string(), + )); + } + if self.outputs.is_empty() { + return Err(NixInteropError::InvalidExportIntent( + "at least one explicit Nix output is required (usually `out`)".to_string(), + )); + } + ensure_unique(&self.systems, "Nix systems")?; + ensure_unique(&self.outputs, "Nix outputs")?; + for system in &self.systems { + if !is_nix_system(system) { + return Err(NixInteropError::InvalidExportIntent(format!( + "system `{system}` must be a lowercase Nix system such as `x86_64-linux`" + ))); + } + } + for output in &self.outputs { + if !is_nix_identifier(output) { + return Err(NixInteropError::InvalidExportIntent(format!( + "output `{output}` must be a Nix identifier" + ))); + } + } + Ok(()) + } + + fn normalize(&mut self) { + self.systems.sort(); + self.outputs.sort(); + } +} + +/// Public Zed identity chosen for either translation direction. A Nix +/// attribute is a selector and never silently claims a Zed organization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixPackageIdentity { + pub org: String, + pub name: String, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +impl NixPackageIdentity { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_slug(&self.org) { + return Err(NixInteropError::InvalidPackageIdentity(format!( + "invalid org slug `{}`", + self.org + ))); + } + if !is_slug(&self.name) { + return Err(NixInteropError::InvalidPackageIdentity(format!( + "invalid package name `{}`", + self.name + ))); + } + if self.version.is_empty() + || self.version.trim() != self.version + || self.version.chars().any(char::is_whitespace) + { + return Err(NixInteropError::InvalidPackageIdentity( + "version must be non-empty and contain no whitespace".to_string(), + )); + } + if let Some(target) = &self.target + && !is_target_name(target) + { + return Err(NixInteropError::InvalidPackageIdentity(format!( + "invalid target `{target}`" + ))); + } + Ok(()) + } +} + +/// Immutable Zed artifact identity used by both a Zed-origin source and the +/// translated artifact produced by Nix → Zed sealing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixInteropArtifact { + #[serde(default)] + pub format: ArtifactFormat, + /// Lowercase hexadecimal SHA-256 of the exact archive bytes. + pub sha256: String, + pub size: u64, +} + +impl NixInteropArtifact { + pub fn validate(&self, field: &str) -> Result<(), NixInteropError> { + validate_sha256_hex(field, &self.sha256)?; + if self.size == 0 { + return Err(NixInteropError::InvalidArtifact(format!( + "{field} size must be greater than zero" + ))); + } + Ok(()) + } +} + +/// Immutable source evidence when Zed is the dependency-resolution authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ZedArtifactOrigin { + /// Registry base URL used to resolve the exact artifact. + pub registry: String, + pub artifact: NixInteropArtifact, + pub vcs_tag: String, + pub vcs_commit: String, + /// Hash of the exact `.zpkg.lock` bytes, omitted only for a dependency-free + /// package whose export plan proves no lock is required. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lock_sha256: Option, +} + +impl ZedArtifactOrigin { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_registry_url(&self.registry) { + return Err(NixInteropError::InvalidZedOrigin( + "registry must be an http(s) or file URL without whitespace".to_string(), + )); + } + self.artifact.validate("Zed source artifact")?; + if !is_ref_token(&self.vcs_tag) { + return Err(NixInteropError::InvalidZedOrigin( + "VCS tag must be non-empty and contain no whitespace".to_string(), + )); + } + if self.vcs_commit.len() < 7 || !is_ref_token(&self.vcs_commit) { + return Err(NixInteropError::InvalidZedOrigin( + "VCS commit must be an immutable non-whitespace identifier".to_string(), + )); + } + if let Some(lock_sha256) = &self.lock_sha256 { + validate_sha256_hex("Zed lock", lock_sha256)?; + } + Ok(()) + } +} + +/// One referenced Nix store object. Portable Nix → Zed imports reject any +/// runtime references in contract v1; Zed → Nix output attestations may still +/// retain them as evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixStoreReference { + pub store_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nar_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nar_size: Option, +} + +impl NixStoreReference { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_nix_store_path(&self.store_path) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid referenced store path `{}`", + self.store_path + ))); + } + if let Some(nar_hash) = &self.nar_hash + && !is_sha256_sri(nar_hash) + { + return Err(NixInteropError::InvalidNixOutput(format!( + "reference `{}` has an invalid SHA-256 SRI NAR hash", + self.store_path + ))); + } + if self.nar_size == Some(0) { + return Err(NixInteropError::InvalidNixOutput(format!( + "reference `{}` has zero NAR size", + self.store_path + ))); + } + Ok(()) + } +} + +/// Realization evidence for exactly one Nix system/output pair. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixRealizedOutput { + pub system: String, + pub output: String, + pub derivation_json_sha256: String, + /// Diagnostic path only; `nar_hash` is the portable output identity. + pub store_path: String, + pub nar_hash: String, + pub nar_size: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub references: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub signatures: Vec, + pub nix_version: String, + pub store_info_json_version: u32, +} + +impl NixRealizedOutput { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_nix_system(&self.system) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid Nix system `{}`", + self.system + ))); + } + if !is_nix_identifier(&self.output) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid Nix output `{}`", + self.output + ))); + } + validate_sha256_hex("derivation JSON", &self.derivation_json_sha256)?; + if !is_nix_store_path(&self.store_path) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid store path `{}`", + self.store_path + ))); + } + if !is_sha256_sri(&self.nar_hash) { + return Err(NixInteropError::InvalidNixOutput( + "NAR hash must be a SHA-256 SRI value".to_string(), + )); + } + if self.nar_size == 0 { + return Err(NixInteropError::InvalidNixOutput( + "NAR size must be greater than zero".to_string(), + )); + } + let mut reference_paths = BTreeSet::new(); + for reference in &self.references { + reference.validate()?; + if !reference_paths.insert(reference.store_path.as_str()) { + return Err(NixInteropError::InvalidNixOutput(format!( + "store reference `{}` appears more than once", + reference.store_path + ))); + } + } + ensure_unique(&self.signatures, "Nix signatures")?; + if self + .signatures + .iter() + .any(|signature| signature.is_empty() || signature.chars().any(char::is_whitespace)) + { + return Err(NixInteropError::InvalidNixOutput( + "Nix signatures must be non-empty tokens without whitespace".to_string(), + )); + } + if self.nix_version.trim().is_empty() { + return Err(NixInteropError::InvalidNixOutput( + "Nix version must be recorded".to_string(), + )); + } + if !SUPPORTED_STORE_INFO_JSON_VERSIONS.contains(&self.store_info_json_version) { + return Err(NixInteropError::InvalidNixOutput(format!( + "unsupported store-info JSON version {}; supported versions are 1, 2, and 3", + self.store_info_json_version + ))); + } + Ok(()) + } + + fn normalize(&mut self) { + self.references + .sort_by(|a, b| a.store_path.cmp(&b.store_path)); + self.signatures.sort(); + } +} + +/// Immutable Nix source selector plus the exact realized output selected for a +/// Nix → Zed translation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixOutputOrigin { + pub locked_ref: String, + pub flake_lock_sha256: String, + /// Standard flake attribute path, e.g. `packages.x86_64-linux.tool`. + pub attribute: String, + pub realized: NixRealizedOutput, +} + +impl NixOutputOrigin { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_immutable_nix_ref(&self.locked_ref) { + return Err(NixInteropError::InvalidNixOrigin( + "locked ref must contain immutable revision or NAR-hash evidence and no whitespace" + .to_string(), + )); + } + validate_sha256_hex("flake.lock", &self.flake_lock_sha256)?; + if !is_nix_attribute_path(&self.attribute) { + return Err(NixInteropError::InvalidNixOrigin(format!( + "invalid standard flake attribute path `{}`", + self.attribute + ))); + } + self.realized.validate() + } + + fn normalize(&mut self) { + self.realized.normalize(); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum NixPolicyProfile { + StrictV1, + Development, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum NixBuilderNetwork { + Disabled, + PreparationOnly, + Allowed, +} + +/// Evidence that the translation was planned/realized under a named policy. +/// This records policy state; it does not grant credentials or execute Nix. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixPolicyEvidence { + pub profile: NixPolicyProfile, + pub pure_evaluation: bool, + pub import_from_derivation: bool, + pub sandbox_required: bool, + pub builder_network: NixBuilderNetwork, + pub dirty_source: bool, + pub publishable: bool, +} + +impl NixPolicyEvidence { + pub fn validate(&self) -> Result<(), NixInteropError> { + match self.profile { + NixPolicyProfile::StrictV1 => { + if !self.pure_evaluation + || self.import_from_derivation + || !self.sandbox_required + || self.builder_network != NixBuilderNetwork::Disabled + || self.dirty_source + || !self.publishable + { + return Err(NixInteropError::InvalidPolicy( + "strict-v1 requires pure evaluation, IFD disabled, sandbox required, builder network disabled, clean source, and publishable output" + .to_string(), + )); + } + } + NixPolicyProfile::Development => { + if self.publishable { + return Err(NixInteropError::InvalidPolicy( + "development policy records are never publishable".to_string(), + )); + } + } + } + Ok(()) + } +} + +/// Final, immutable provenance record for one completed translation. +/// +/// `direction` is internally tagged in JSON so consumers cannot deserialize a +/// direction whose required origin/result fields are absent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "direction", rename_all = "kebab-case")] +pub enum NixAdapterRecord { + ZedToNix { + schema: String, + package: NixPackageIdentity, + source: ZedArtifactOrigin, + intent: NixExportSection, + /// Hash of a canonical inventory of the generated standalone flake + /// bundle, not a filesystem-dependent archive of the output directory. + flake_bundle_sha256: String, + outputs: Vec, + policy: NixPolicyEvidence, + }, + NixToZed { + schema: String, + package: NixPackageIdentity, + source: NixOutputOrigin, + artifact: NixInteropArtifact, + policy: NixPolicyEvidence, + }, +} + +impl NixAdapterRecord { + pub fn zed_to_nix( + package: NixPackageIdentity, + source: ZedArtifactOrigin, + intent: NixExportSection, + flake_bundle_sha256: String, + outputs: Vec, + policy: NixPolicyEvidence, + ) -> Self { + Self::ZedToNix { + schema: NIX_ADAPTER_SCHEMA_V1.to_string(), + package, + source, + intent, + flake_bundle_sha256, + outputs, + policy, + } + } + + pub fn nix_to_zed( + package: NixPackageIdentity, + source: NixOutputOrigin, + artifact: NixInteropArtifact, + policy: NixPolicyEvidence, + ) -> Self { + Self::NixToZed { + schema: NIX_ADAPTER_SCHEMA_V1.to_string(), + package, + source, + artifact, + policy, + } + } + + pub fn validate(&self) -> Result<(), NixInteropError> { + match self { + Self::ZedToNix { + schema, + package, + source, + intent, + flake_bundle_sha256, + outputs, + policy, + } => { + validate_schema(schema)?; + package.validate()?; + source.validate()?; + intent.validate(&package.name)?; + validate_sha256_hex("flake bundle", flake_bundle_sha256)?; + policy.validate()?; + if outputs.is_empty() { + return Err(NixInteropError::InvalidAdapter( + "a final Zed → Nix adapter record requires at least one realized output" + .to_string(), + )); + } + let declared_systems: BTreeSet<&str> = + intent.systems.iter().map(String::as_str).collect(); + let declared_outputs: BTreeSet<&str> = + intent.outputs.iter().map(String::as_str).collect(); + let mut seen = BTreeSet::new(); + let mut realized_systems = BTreeSet::new(); + for output in outputs { + output.validate()?; + if !declared_systems.contains(output.system.as_str()) { + return Err(NixInteropError::InvalidAdapter(format!( + "realized system `{}` was not declared by the export intent", + output.system + ))); + } + if !declared_outputs.contains(output.output.as_str()) { + return Err(NixInteropError::InvalidAdapter(format!( + "realized output `{}` was not declared by the export intent", + output.output + ))); + } + if !seen.insert((output.system.as_str(), output.output.as_str())) { + return Err(NixInteropError::InvalidAdapter(format!( + "system/output pair `{}/{}` appears more than once", + output.system, output.output + ))); + } + realized_systems.insert(output.system.as_str()); + } + for system in declared_systems { + if !realized_systems.contains(system) { + return Err(NixInteropError::InvalidAdapter(format!( + "declared system `{system}` has no realized output evidence" + ))); + } + } + Ok(()) + } + Self::NixToZed { + schema, + package, + source, + artifact, + policy, + } => { + validate_schema(schema)?; + package.validate()?; + source.validate()?; + artifact.validate("translated Zed artifact")?; + policy.validate()?; + if !source.realized.references.is_empty() { + return Err(NixInteropError::InvalidAdapter( + "contract v1 Nix → Zed imports must be closure-free; runtime store references are not portable" + .to_string(), + )); + } + Ok(()) + } + } + } + + /// Deterministic compact JSON bytes for hashing/signing. Validation occurs + /// before normalization, so duplicate unordered values cannot be silently + /// collapsed. Arrays whose order is not semantic are then sorted, and all + /// object keys are emitted lexicographically. + pub fn canonical_json_bytes(&self) -> Result, NixInteropError> { + self.validate()?; + let mut normalized = self.clone(); + normalized.normalize(); + let value = serde_json::to_value(normalized) + .map_err(|error| NixInteropError::Json(error.to_string()))?; + serde_json::to_vec(&canonicalize_json(value)) + .map_err(|error| NixInteropError::Json(error.to_string())) + } + + pub fn canonical_json_string(&self) -> Result { + String::from_utf8(self.canonical_json_bytes()?) + .map_err(|error| NixInteropError::Json(error.to_string())) + } + + fn normalize(&mut self) { + match self { + Self::ZedToNix { + intent, outputs, .. + } => { + intent.normalize(); + for output in outputs.iter_mut() { + output.normalize(); + } + outputs.sort_by(|a, b| { + (&a.system, &a.output, &a.store_path).cmp(&( + &b.system, + &b.output, + &b.store_path, + )) + }); + } + Self::NixToZed { source, .. } => source.normalize(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum NixInteropError { + #[error("unsupported Nix adapter schema `{0}`")] + UnsupportedSchema(String), + #[error("invalid Nix export intent: {0}")] + InvalidExportIntent(String), + #[error("invalid Nix interop package identity: {0}")] + InvalidPackageIdentity(String), + #[error("invalid Nix interop artifact: {0}")] + InvalidArtifact(String), + #[error("invalid Zed origin: {0}")] + InvalidZedOrigin(String), + #[error("invalid Nix origin: {0}")] + InvalidNixOrigin(String), + #[error("invalid Nix output evidence: {0}")] + InvalidNixOutput(String), + #[error("invalid Nix interop policy: {0}")] + InvalidPolicy(String), + #[error("invalid Nix adapter record: {0}")] + InvalidAdapter(String), + #[error("Nix adapter JSON error: {0}")] + Json(String), +} + +pub fn is_nix_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first.is_ascii_alphabetic() || first == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '\'')) +} + +pub fn is_nix_attribute_path(value: &str) -> bool { + !value.is_empty() && value.split('.').all(is_nix_identifier) +} + +pub fn is_nix_system(value: &str) -> bool { + !value.is_empty() + && value.contains('-') + && !value.starts_with('-') + && !value.ends_with('-') + && value + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-')) +} + +pub fn is_nix_store_path(value: &str) -> bool { + let Some(rest) = value.strip_prefix("/nix/store/") else { + return false; + }; + if rest.len() < 34 || rest.as_bytes().get(32) != Some(&b'-') { + return false; + } + const NIX_BASE32: &str = "0123456789abcdfghijklmnpqrsvwxyz"; + rest[..32].chars().all(|c| NIX_BASE32.contains(c)) + && rest[33..] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.' | '_' | '?')) +} + +pub fn is_sha256_sri(value: &str) -> bool { + let Some(payload) = value.strip_prefix("sha256-") else { + return false; + }; + payload.len() == 44 + && payload.ends_with('=') + && payload[..43] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/')) +} + +pub fn is_immutable_nix_ref(value: &str) -> bool { + if value.is_empty() + || value.trim() != value + || value.chars().any(char::is_whitespace) + || value.contains("<") + || value.contains('>') + { + return false; + } + if value.starts_with("/nix/store/") || value.starts_with("path:/nix/store/") { + return true; + } + if value.contains("narHash=sha256-") { + return true; + } + if let Some(rev) = query_value(value, "rev") + && is_hex_revision(rev) + { + return true; + } + value + .split(|c: char| !c.is_ascii_hexdigit()) + .any(is_hex_revision) +} + +fn is_hex_revision(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn query_value<'a>(value: &'a str, name: &str) -> Option<&'a str> { + value + .split(['?', '&']) + .find_map(|part| part.strip_prefix(&format!("{name}="))) +} + +fn is_registry_url(value: &str) -> bool { + value.trim() == value + && !value.chars().any(char::is_whitespace) + && ["https://", "http://", "file://"] + .iter() + .any(|prefix| value.starts_with(prefix)) +} + +fn is_ref_token(value: &str) -> bool { + !value.is_empty() && value.trim() == value && !value.chars().any(char::is_whitespace) +} + +fn validate_schema(schema: &str) -> Result<(), NixInteropError> { + if schema != NIX_ADAPTER_SCHEMA_V1 { + return Err(NixInteropError::UnsupportedSchema(schema.to_string())); + } + Ok(()) +} + +fn validate_sha256_hex(field: &str, value: &str) -> Result<(), NixInteropError> { + if !is_sha256_hex(value) { + return Err(NixInteropError::InvalidArtifact(format!( + "{field} SHA-256 must be 64 lowercase hexadecimal characters" + ))); + } + Ok(()) +} + +fn ensure_unique(values: &[String], field: &str) -> Result<(), NixInteropError> { + let mut seen = BTreeSet::new(); + for value in values { + if !seen.insert(value.as_str()) { + return Err(NixInteropError::InvalidAdapter(format!( + "{field} contains duplicate `{value}`" + ))); + } + } + Ok(()) +} + +fn canonicalize_json(value: Value) -> Value { + match value { + Value::Object(map) => { + let mut entries: Vec<_> = map.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut sorted = serde_json::Map::new(); + for (key, value) in entries { + sorted.insert(key, canonicalize_json(value)); + } + Value::Object(sorted) + } + Value::Array(values) => Value::Array(values.into_iter().map(canonicalize_json).collect()), + scalar => scalar, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEX_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const HEX_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const NAR_A: &str = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + const STORE_A: &str = "/nix/store/00000000000000000000000000000000-tool-1.2.3"; + + fn strict_policy() -> NixPolicyEvidence { + NixPolicyEvidence { + profile: NixPolicyProfile::StrictV1, + pure_evaluation: true, + import_from_derivation: false, + sandbox_required: true, + builder_network: NixBuilderNetwork::Disabled, + dirty_source: false, + publishable: true, + } + } + + fn output(system: &str, output: &str) -> NixRealizedOutput { + NixRealizedOutput { + system: system.to_string(), + output: output.to_string(), + derivation_json_sha256: HEX_B.to_string(), + store_path: STORE_A.to_string(), + nar_hash: NAR_A.to_string(), + nar_size: 128, + references: Vec::new(), + signatures: vec!["cache.example-1:signature".to_string()], + nix_version: "2.35.2".to_string(), + store_info_json_version: 3, + } + } + + fn package() -> NixPackageIdentity { + NixPackageIdentity { + org: "acme".to_string(), + name: "tool".to_string(), + version: "1.2.3".to_string(), + target: None, + } + } + + #[test] + fn export_intent_requires_explicit_systems_and_outputs() { + let empty = NixExportSection::default(); + assert!(empty.validate("tool").is_err()); + + let valid = NixExportSection { + mode: NixExportMode::Artifact, + attribute: None, + systems: vec!["x86_64-linux".to_string()], + outputs: vec!["out".to_string()], + }; + valid.validate("tool").unwrap(); + + let mut reserved = valid.clone(); + reserved.attribute = Some("default".to_string()); + assert!(reserved.validate("tool").is_err()); + } + + #[test] + fn strict_policy_cannot_silently_downgrade() { + strict_policy().validate().unwrap(); + let mut impure = strict_policy(); + impure.pure_evaluation = false; + assert!(impure.validate().is_err()); + + let development = NixPolicyEvidence { + profile: NixPolicyProfile::Development, + publishable: false, + ..strict_policy() + }; + development.validate().unwrap(); + } + + #[test] + fn nix_to_zed_v1_rejects_runtime_store_references() { + let mut realized = output("x86_64-linux", "out"); + realized.references.push(NixStoreReference { + store_path: "/nix/store/11111111111111111111111111111111-glibc".to_string(), + nar_hash: Some(NAR_A.to_string()), + nar_size: Some(256), + }); + let record = NixAdapterRecord::nix_to_zed( + package(), + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized, + }, + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 512, + }, + strict_policy(), + ); + assert!(matches!( + record.validate(), + Err(NixInteropError::InvalidAdapter(_)) + )); + } + + #[test] + fn canonical_json_normalizes_non_semantic_array_order() { + let intent_a = NixExportSection { + mode: NixExportMode::Artifact, + attribute: Some("tool".to_string()), + systems: vec!["x86_64-linux".to_string(), "aarch64-linux".to_string()], + outputs: vec!["out".to_string()], + }; + let intent_b = NixExportSection { + systems: intent_a.systems.iter().cloned().rev().collect(), + ..intent_a.clone() + }; + let source = ZedArtifactOrigin { + registry: "https://zpkg.example".to_string(), + artifact: NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_A.to_string(), + size: 256, + }, + vcs_tag: "v1.2.3".to_string(), + vcs_commit: HEX_B[..40].to_string(), + lock_sha256: Some(HEX_B.to_string()), + }; + let outputs_a = vec![ + output("x86_64-linux", "out"), + output("aarch64-linux", "out"), + ]; + let outputs_b = outputs_a.iter().cloned().rev().collect(); + let first = NixAdapterRecord::zed_to_nix( + package(), + source.clone(), + intent_a, + HEX_A.to_string(), + outputs_a, + strict_policy(), + ); + let second = NixAdapterRecord::zed_to_nix( + package(), + source, + intent_b, + HEX_A.to_string(), + outputs_b, + strict_policy(), + ); + assert_eq!( + first.canonical_json_bytes().unwrap(), + second.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn canonical_json_has_a_stable_compact_golden_vector() { + let record = NixAdapterRecord::nix_to_zed( + package(), + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized: output("x86_64-linux", "out"), + }, + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 512, + }, + strict_policy(), + ); + let canonical = record.canonical_json_string().unwrap(); + assert!(!canonical.contains('\n')); + assert_eq!( + serde_json::from_str::(&canonical).unwrap(), + serde_json::to_value(record).unwrap() + ); + assert!(canonical.starts_with("{\"artifact\":")); + assert!( + canonical.ends_with("}") && canonical.contains("\"schema\":\"zed.nix-adapter/v1\"") + ); + } + + #[test] + fn unknown_schema_and_unknown_store_info_version_fail_closed() { + let mut record = NixAdapterRecord::nix_to_zed( + package(), + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized: output("x86_64-linux", "out"), + }, + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 512, + }, + strict_policy(), + ); + if let NixAdapterRecord::NixToZed { schema, source, .. } = &mut record { + *schema = "zed.nix-adapter/v2".to_string(); + source.realized.store_info_json_version = 99; + } + assert!(matches!( + record.validate(), + Err(NixInteropError::UnsupportedSchema(_)) + )); + } + + #[test] + fn immutable_ref_and_hash_helpers_are_strict() { + assert!(is_immutable_nix_ref(&format!("github:acme/tool/{HEX_A}"))); + assert!(is_immutable_nix_ref( + "git+https://example.invalid/repo?rev=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )); + assert!(!is_immutable_nix_ref("github:acme/tool/main")); + assert!(is_sha256_sri(NAR_A)); + assert!(!is_sha256_sri("sha256-not-a-hash")); + assert!(is_nix_store_path(STORE_A)); + assert!(!is_nix_store_path("/tmp/tool")); + } +} diff --git a/src/rust/nix_plan.rs b/src/rust/nix_plan.rs new file mode 100644 index 0000000..27da13f --- /dev/null +++ b/src/rust/nix_plan.rs @@ -0,0 +1,462 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::manifest::is_sha256_hex; +use crate::nix::{ + NixExportMode, NixExportSection, NixInteropArtifact, NixPackageIdentity, NixPolicyEvidence, + NixPolicyProfile, +}; + +/// Major-versioned identifier for a read-only Zed → Nix export plan. +/// +/// The plan is execution-independent. It binds author intent and immutable Zed +/// inputs before any flake is generated or Nix process is started. +pub const NIX_EXPORT_PLAN_SCHEMA_V1: &str = "zed.nix-export-plan/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum NixExportPackageClass { + /// Immutable package data with no executable entry point. + Data, + /// Executables already present in the immutable Zed artifact. Contract v1 + /// never infers or executes a source build to create them. + PrebuiltBin, +} + +/// Fully resolved author intent. Unlike manifest intent, the package attribute +/// is no longer optional and non-semantic arrays are canonicalized. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ResolvedNixExportIntent { + pub mode: NixExportMode, + pub attribute: String, + pub systems: Vec, + pub outputs: Vec, +} + +impl ResolvedNixExportIntent { + pub fn normalize(&mut self) { + self.systems.sort(); + self.outputs.sort(); + } + + pub fn validate(&self, package_name: &str) -> Result<(), NixExportPlanError> { + let intent = NixExportSection { + mode: self.mode, + attribute: Some(self.attribute.clone()), + systems: self.systems.clone(), + outputs: self.outputs.clone(), + }; + intent + .validate(package_name) + .map_err(|error| NixExportPlanError::InvalidIntent(error.to_string()))?; + ensure_sorted_unique(&self.systems, "Nix systems")?; + ensure_sorted_unique(&self.outputs, "Nix outputs")?; + if self.mode != NixExportMode::Artifact { + return Err(NixExportPlanError::InvalidIntent( + "export plan v1 supports only artifact mode".to_string(), + )); + } + Ok(()) + } +} + +/// Exact immutable Zed source selected by planning. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PlannedZedExportArtifact { + /// Safe artifact basename, e.g. `acme-tool-1.2.3.tar.gz`. + pub file_name: String, + pub artifact: NixInteropArtifact, + /// SHA-256 of exact `.zpkg.toml` bytes, including comments and formatting. + pub manifest_sha256: String, + /// SHA-256 of exact `.zpkg.lock` bytes. + pub lock_sha256: String, +} + +impl PlannedZedExportArtifact { + pub fn validate(&self, package: &NixPackageIdentity) -> Result<(), NixExportPlanError> { + self.artifact + .validate("planned Zed artifact") + .map_err(|error| NixExportPlanError::InvalidArtifact(error.to_string()))?; + validate_sha256("manifest", &self.manifest_sha256)?; + validate_sha256("lock", &self.lock_sha256)?; + + let expected = format!( + "{}-{}-{}.{}", + package.org, + package.name, + package.version, + self.artifact.format.extension() + ); + if self.file_name != expected || !is_safe_basename(&self.file_name) { + return Err(NixExportPlanError::InvalidArtifact(format!( + "artifact filename must be the canonical safe basename `{expected}`" + ))); + } + Ok(()) + } +} + +/// Reserved typed dependency edge for later plan revisions. +/// +/// Strict v1 plans must keep `dependencies` empty. Keeping the typed field in +/// the wire contract prevents a later implementation from smuggling an opaque +/// native package-manager graph into otherwise valid-looking plan JSON. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PlannedNixExportDependency { + pub org: String, + pub name: String, + pub version: String, + pub sha256: String, +} + +impl PlannedNixExportDependency { + fn validate(&self) -> Result<(), NixExportPlanError> { + NixPackageIdentity { + org: self.org.clone(), + name: self.name.clone(), + version: self.version.clone(), + target: None, + } + .validate() + .map_err(|error| NixExportPlanError::InvalidDependency(error.to_string()))?; + validate_sha256("dependency artifact", &self.sha256) + .map_err(|error| NixExportPlanError::InvalidDependency(error.to_string())) + } +} + +/// Canonical, credential-free plan for one Zed → Nix package export. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NixExportPlan { + pub schema: String, + pub package: NixPackageIdentity, + pub package_class: NixExportPackageClass, + pub intent: ResolvedNixExportIntent, + pub source: PlannedZedExportArtifact, + /// Command name → artifact-relative executable path. + #[serde(default)] + pub bins: BTreeMap, + /// Contract v1 requires this list to be empty. + #[serde(default)] + pub dependencies: Vec, + pub policy: NixPolicyEvidence, +} + +impl NixExportPlan { + pub fn new( + package: NixPackageIdentity, + package_class: NixExportPackageClass, + intent: ResolvedNixExportIntent, + source: PlannedZedExportArtifact, + bins: BTreeMap, + policy: NixPolicyEvidence, + ) -> Self { + Self { + schema: NIX_EXPORT_PLAN_SCHEMA_V1.to_string(), + package, + package_class, + intent, + source, + bins, + dependencies: Vec::new(), + policy, + } + } + + pub fn normalize(&mut self) { + self.intent.normalize(); + self.dependencies.sort(); + } + + pub fn validate(&self) -> Result<(), NixExportPlanError> { + if self.schema != NIX_EXPORT_PLAN_SCHEMA_V1 { + return Err(NixExportPlanError::UnsupportedSchema(self.schema.clone())); + } + self.package + .validate() + .map_err(|error| NixExportPlanError::InvalidPackage(error.to_string()))?; + self.intent.validate(&self.package.name)?; + self.source.validate(&self.package)?; + self.policy + .validate() + .map_err(|error| NixExportPlanError::InvalidPolicy(error.to_string()))?; + if self.policy.profile != NixPolicyProfile::StrictV1 { + return Err(NixExportPlanError::InvalidPolicy( + "publishable export plans require strict-v1 policy".to_string(), + )); + } + + match self.package_class { + NixExportPackageClass::Data if !self.bins.is_empty() => { + return Err(NixExportPlanError::InvalidBins( + "data packages must not declare executable bins".to_string(), + )); + } + NixExportPackageClass::PrebuiltBin if self.bins.is_empty() => { + return Err(NixExportPlanError::InvalidBins( + "prebuilt-bin packages must declare at least one executable".to_string(), + )); + } + _ => {} + } + + for (name, path) in &self.bins { + if !is_bin_name(name) { + return Err(NixExportPlanError::InvalidBins(format!( + "invalid executable name `{name}`" + ))); + } + if !is_safe_relative_path(path) { + return Err(NixExportPlanError::InvalidBins(format!( + "executable `{name}` has unsafe artifact-relative path `{path}`" + ))); + } + } + + for dependency in &self.dependencies { + dependency.validate()?; + } + ensure_sorted_unique(&self.dependencies, "planned dependencies")?; + if !self.dependencies.is_empty() { + return Err(NixExportPlanError::InvalidDependency( + "export plan v1 accepts dependency-free packages only".to_string(), + )); + } + Ok(()) + } + + /// Stable compact JSON suitable for hashing, review, and later export. + /// + /// Callers may construct intent arrays in any order; this method clones and + /// normalizes the plan before validating and serializing it. + pub fn canonical_json_bytes(&self) -> Result, NixExportPlanError> { + let mut canonical = self.clone(); + canonical.normalize(); + canonical.validate()?; + serde_json::to_vec(&canonical).map_err(|error| NixExportPlanError::Json(error.to_string())) + } + + pub fn canonical_json_string(&self) -> Result { + String::from_utf8(self.canonical_json_bytes()?) + .map_err(|error| NixExportPlanError::Json(error.to_string())) + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum NixExportPlanError { + #[error("unsupported Nix export plan schema `{0}`")] + UnsupportedSchema(String), + #[error("invalid Nix export plan package: {0}")] + InvalidPackage(String), + #[error("invalid Nix export intent: {0}")] + InvalidIntent(String), + #[error("invalid planned Zed artifact: {0}")] + InvalidArtifact(String), + #[error("invalid prebuilt executable inventory: {0}")] + InvalidBins(String), + #[error("invalid planned dependency graph: {0}")] + InvalidDependency(String), + #[error("invalid Nix export policy: {0}")] + InvalidPolicy(String), + #[error("Nix export plan JSON error: {0}")] + Json(String), +} + +fn validate_sha256(field: &str, value: &str) -> Result<(), NixExportPlanError> { + if !is_sha256_hex(value) { + return Err(NixExportPlanError::InvalidArtifact(format!( + "{field} SHA-256 must be 64 lowercase hexadecimal characters" + ))); + } + Ok(()) +} + +fn is_safe_basename(value: &str) -> bool { + let path = Path::new(value); + path.file_name() == Some(path.as_os_str()) + && !value.is_empty() + && !value.starts_with('.') + && !value.chars().any(char::is_whitespace) +} + +fn is_safe_relative_path(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('/') + && !value.ends_with('/') + && !value.contains('\\') + && value.split('/').all(|part| { + !part.is_empty() && part != "." && part != ".." && !part.chars().any(char::is_control) + }) +} + +fn is_bin_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && !matches!(value.chars().next(), Some('-' | '.')) + && !matches!(value.chars().last(), Some('-' | '.')) + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) +} + +fn ensure_sorted_unique(values: &[T], field: &str) -> Result<(), NixExportPlanError> +where + T: Ord + std::fmt::Debug, +{ + let mut seen = BTreeSet::new(); + let mut previous: Option<&T> = None; + for value in values { + if !seen.insert(value) { + return Err(NixExportPlanError::InvalidIntent(format!( + "{field} contains duplicate {value:?}" + ))); + } + if previous.is_some_and(|prior| prior > value) { + return Err(NixExportPlanError::InvalidIntent(format!( + "{field} must be sorted canonically" + ))); + } + previous = Some(value); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::artifact::ArtifactFormat; + use crate::nix::{NixBuilderNetwork, NixPolicyEvidence}; + + fn digest(character: char) -> String { + std::iter::repeat_n(character, 64).collect() + } + + fn policy() -> NixPolicyEvidence { + NixPolicyEvidence { + profile: NixPolicyProfile::StrictV1, + pure_evaluation: true, + import_from_derivation: false, + sandbox_required: true, + builder_network: NixBuilderNetwork::Disabled, + dirty_source: false, + publishable: true, + } + } + + fn data_plan() -> NixExportPlan { + NixExportPlan::new( + NixPackageIdentity { + org: "acme".to_string(), + name: "dataset".to_string(), + version: "1.2.3".to_string(), + target: None, + }, + NixExportPackageClass::Data, + ResolvedNixExportIntent { + mode: NixExportMode::Artifact, + attribute: "dataset".to_string(), + systems: vec!["x86_64-linux".to_string(), "aarch64-linux".to_string()], + outputs: vec!["out".to_string()], + }, + PlannedZedExportArtifact { + file_name: "acme-dataset-1.2.3.tar.gz".to_string(), + artifact: NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: digest('a'), + size: 123, + }, + manifest_sha256: digest('b'), + lock_sha256: digest('c'), + }, + BTreeMap::new(), + policy(), + ) + } + + #[test] + fn canonical_json_sorts_non_semantic_intent_arrays() { + let plan = data_plan(); + let encoded = plan.canonical_json_string().unwrap(); + let decoded: serde_json::Value = serde_json::from_str(&encoded).unwrap(); + assert_eq!( + decoded["intent"]["systems"], + serde_json::json!(["aarch64-linux", "x86_64-linux"]) + ); + assert_eq!(decoded["schema"], NIX_EXPORT_PLAN_SCHEMA_V1); + assert_eq!(decoded["bins"], serde_json::json!({})); + assert_eq!(decoded["dependencies"], serde_json::json!([])); + assert!(!encoded.contains("registry")); + assert!(!encoded.contains("token")); + assert!(!encoded.contains("/tmp/")); + } + + #[test] + fn canonical_json_is_stable_after_round_trip() { + let encoded = data_plan().canonical_json_string().unwrap(); + let decoded: NixExportPlan = serde_json::from_str(&encoded).unwrap(); + assert_eq!(encoded, decoded.canonical_json_string().unwrap()); + } + + #[test] + fn prebuilt_bins_are_typed_and_path_safe() { + let mut plan = data_plan(); + plan.package_class = NixExportPackageClass::PrebuiltBin; + plan.bins + .insert("dataset-tool".to_string(), "bin/dataset-tool".to_string()); + plan.canonical_json_bytes().unwrap(); + + plan.bins + .insert("escape".to_string(), "../outside".to_string()); + assert!(matches!( + plan.canonical_json_bytes().unwrap_err(), + NixExportPlanError::InvalidBins(_) + )); + } + + #[test] + fn artifact_filename_and_exact_input_digests_fail_closed() { + let mut plan = data_plan(); + plan.source.file_name = "other.tar.gz".to_string(); + assert!(matches!( + plan.canonical_json_bytes().unwrap_err(), + NixExportPlanError::InvalidArtifact(_) + )); + + let mut plan = data_plan(); + plan.source.lock_sha256 = "not-a-digest".to_string(); + assert!(matches!( + plan.canonical_json_bytes().unwrap_err(), + NixExportPlanError::InvalidArtifact(_) + )); + } + + #[test] + fn strict_v1_rejects_dependency_edges() { + let mut plan = data_plan(); + plan.dependencies.push(PlannedNixExportDependency { + org: "acme".to_string(), + name: "other".to_string(), + version: "1.0.0".to_string(), + sha256: digest('d'), + }); + assert!(matches!( + plan.canonical_json_bytes().unwrap_err(), + NixExportPlanError::InvalidDependency(_) + )); + } + + #[test] + fn unknown_fields_are_rejected() { + let encoded = data_plan().canonical_json_string().unwrap(); + let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap(); + value["registry"] = serde_json::json!("https://secret.invalid"); + assert!(serde_json::from_value::(value).is_err()); + } +} diff --git a/src/rust/oci.rs b/src/rust/oci.rs new file mode 100644 index 0000000..f02673a --- /dev/null +++ b/src/rust/oci.rs @@ -0,0 +1,886 @@ +//! Shared, service-independent contracts for distributing Zed packages as +//! OCI artifacts. +//! +//! The contract deliberately stops at immutable identity, descriptor, media +//! type, and provenance boundaries. Registry authentication, HTTP transport, +//! retries, and ORAS process execution belong to callers such as `zed-cli`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::str::FromStr; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Major-versioned identifier for the first finalized Zed ↔ OCI adapter +/// record. Unknown major versions must fail closed. +pub const OCI_ADAPTER_SCHEMA_V1: &str = "zed.oci-adapter/v1"; + +/// OCI image-manifest media type used by OCI 1.1 artifact manifests. +pub const OCI_IMAGE_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json"; +/// Zed package metadata stored as the OCI manifest's config descriptor. +pub const ZED_OCI_CONFIG_MEDIA_TYPE_V1: &str = "application/vnd.zed.package.config.v1+json"; +/// A deterministic Zed `tar.gz` package artifact. +pub const ZED_OCI_PACKAGE_TAR_GZ_MEDIA_TYPE_V1: &str = "application/vnd.zed.package.v1.tar+gzip"; +/// A deterministic Zed ZIP package artifact. +pub const ZED_OCI_PACKAGE_ZIP_MEDIA_TYPE_V1: &str = "application/vnd.zed.package.v1+zip"; +/// The exact `.zpkg.toml` bytes associated with a package artifact. +pub const ZED_OCI_MANIFEST_MEDIA_TYPE_V1: &str = "application/vnd.zed.package.manifest.v1+toml"; +/// The exact `.zpkg.lock` bytes associated with a package artifact. +pub const ZED_OCI_LOCK_MEDIA_TYPE_V1: &str = "application/vnd.zed.package.lock.v1+toml"; +/// A platform-specific or portable executable/library payload. +pub const ZED_OCI_BINARY_MEDIA_TYPE_V1: &str = "application/vnd.zed.package.binary.v1"; +/// SPDX JSON SBOM media type. +pub const SPDX_JSON_MEDIA_TYPE: &str = "application/spdx+json"; +/// CycloneDX JSON SBOM media type. +pub const CYCLONEDX_JSON_MEDIA_TYPE: &str = "application/vnd.cyclonedx+json"; +/// in-toto statement/provenance media type. +pub const IN_TOTO_JSON_MEDIA_TYPE: &str = "application/vnd.in-toto+json"; + +/// One canonical OCI content digest. +/// +/// Contract v1 intentionally accepts only lowercase SHA-256 digests. OCI can +/// carry other digest algorithms, but Zed's existing artifact and lock model +/// is SHA-256 based; accepting an algorithm the rest of the system cannot +/// verify would create a false portability guarantee. +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(transparent)] +pub struct OciDigest(String); + +impl OciDigest { + pub fn parse(value: impl Into) -> Result { + let digest = Self(value.into()); + digest.validate()?; + Ok(digest) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn encoded(&self) -> Option<&str> { + self.0.strip_prefix("sha256:") + } + + pub fn validate(&self) -> Result<(), OciInteropError> { + let Some(encoded) = self.encoded() else { + return Err(OciInteropError::InvalidDigest( + "contract v1 requires a `sha256:` digest".to_string(), + )); + }; + if encoded.len() != 64 + || !encoded + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(OciInteropError::InvalidDigest( + "SHA-256 digest payload must be 64 lowercase hexadecimal characters".to_string(), + )); + } + Ok(()) + } +} + +impl fmt::Display for OciDigest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl FromStr for OciDigest { + type Err = OciInteropError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +/// A normalized OCI registry reference. +/// +/// The textual form is `oci://registry/repository[:tag][@sha256:digest]`. +/// At least one tag or digest is required. A final adapter record additionally +/// requires the digest because a tag alone is mutable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct OciReference { + pub registry: String, + pub repository: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tag: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub digest: Option, +} + +impl OciReference { + pub fn parse(value: &str) -> Result { + if value.trim() != value || value.chars().any(char::is_whitespace) { + return Err(OciInteropError::InvalidReference( + "OCI reference must not contain leading, trailing, or embedded whitespace" + .to_string(), + )); + } + let Some(rest) = value.strip_prefix("oci://") else { + return Err(OciInteropError::InvalidReference( + "OCI reference must start with `oci://`".to_string(), + )); + }; + if rest.contains(['?', '#']) { + return Err(OciInteropError::InvalidReference( + "OCI references must not contain query strings or fragments".to_string(), + )); + } + + let (name, digest) = match rest.rsplit_once('@') { + Some((name, encoded)) => { + if name.contains('@') { + return Err(OciInteropError::InvalidReference( + "OCI references must not contain embedded credentials or multiple `@` separators" + .to_string(), + )); + } + (name, Some(OciDigest::parse(encoded)?)) + } + None => (rest, None), + }; + + let Some((registry, repository_and_tag)) = name.split_once('/') else { + return Err(OciInteropError::InvalidReference( + "OCI reference must include both a registry and repository path".to_string(), + )); + }; + let (repository, tag) = match repository_and_tag.rsplit_once(':') { + Some((repository, tag)) => (repository, Some(tag.to_string())), + None => (repository_and_tag, None), + }; + + let reference = Self { + registry: registry.to_string(), + repository: repository.to_string(), + tag, + digest, + }; + reference.validate()?; + Ok(reference) + } + + pub fn is_immutable(&self) -> bool { + self.digest.is_some() + } + + pub fn require_digest(&self) -> Result<&OciDigest, OciInteropError> { + self.digest.as_ref().ok_or_else(|| { + OciInteropError::InvalidReference( + "a finalized OCI reference requires an immutable digest".to_string(), + ) + }) + } + + pub fn validate(&self) -> Result<(), OciInteropError> { + validate_registry(&self.registry)?; + validate_repository(&self.repository)?; + if let Some(tag) = &self.tag { + validate_tag(tag)?; + } + if let Some(digest) = &self.digest { + digest.validate()?; + } + if self.tag.is_none() && self.digest.is_none() { + return Err(OciInteropError::InvalidReference( + "OCI reference must include a tag, a digest, or both".to_string(), + )); + } + Ok(()) + } +} + +impl fmt::Display for OciReference { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "oci://{}/{}", self.registry, self.repository)?; + if let Some(tag) = &self.tag { + write!(f, ":{tag}")?; + } + if let Some(digest) = &self.digest { + write!(f, "@{digest}")?; + } + Ok(()) + } +} + +impl FromStr for OciReference { + type Err = OciInteropError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +/// Public Zed identity associated with an OCI artifact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct OciPackageIdentity { + pub org: String, + pub name: String, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +impl OciPackageIdentity { + pub fn validate(&self) -> Result<(), OciInteropError> { + if !is_slug(&self.org) { + return Err(OciInteropError::InvalidPackageIdentity(format!( + "invalid org slug `{}`", + self.org + ))); + } + if !is_slug(&self.name) { + return Err(OciInteropError::InvalidPackageIdentity(format!( + "invalid package name `{}`", + self.name + ))); + } + if self.version.is_empty() + || self.version.trim() != self.version + || self.version.chars().any(char::is_whitespace) + { + return Err(OciInteropError::InvalidPackageIdentity( + "version must be non-empty and contain no whitespace".to_string(), + )); + } + if let Some(target) = &self.target + && !is_slug(target) + { + return Err(OciInteropError::InvalidPackageIdentity(format!( + "invalid target `{target}`" + ))); + } + Ok(()) + } +} + +/// OCI platform selector for multi-platform package payloads. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct OciPlatform { + pub os: String, + pub architecture: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant: Option, +} + +impl OciPlatform { + pub fn validate(&self) -> Result<(), OciInteropError> { + for (field, value) in [ + ("os", self.os.as_str()), + ("architecture", self.architecture.as_str()), + ] { + if !is_platform_token(value) { + return Err(OciInteropError::InvalidPlatform(format!( + "{field} `{value}` must be a lowercase platform token" + ))); + } + } + if let Some(variant) = &self.variant + && !is_platform_token(variant) + { + return Err(OciInteropError::InvalidPlatform(format!( + "variant `{variant}` must be a lowercase platform token" + ))); + } + Ok(()) + } +} + +/// One immutable OCI descriptor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct OciDescriptor { + #[serde(rename = "mediaType")] + pub media_type: String, + pub digest: OciDigest, + pub size: u64, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub annotations: BTreeMap, +} + +impl OciDescriptor { + pub fn validate(&self, field: &str) -> Result<(), OciInteropError> { + if !is_media_type(&self.media_type) { + return Err(OciInteropError::InvalidDescriptor(format!( + "{field} media type `{}` is invalid or non-canonical", + self.media_type + ))); + } + self.digest.validate()?; + if self.size == 0 { + return Err(OciInteropError::InvalidDescriptor(format!( + "{field} size must be greater than zero" + ))); + } + validate_annotations(&self.annotations, field) + } +} + +/// Semantic role of a layer within a Zed OCI artifact. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum OciLayerKind { + PackageTarGz, + PackageZip, + Manifest, + Lockfile, + Binary, + SpdxSbom, + CycloneDxSbom, + Provenance, +} + +impl OciLayerKind { + pub fn media_type(self) -> &'static str { + match self { + Self::PackageTarGz => ZED_OCI_PACKAGE_TAR_GZ_MEDIA_TYPE_V1, + Self::PackageZip => ZED_OCI_PACKAGE_ZIP_MEDIA_TYPE_V1, + Self::Manifest => ZED_OCI_MANIFEST_MEDIA_TYPE_V1, + Self::Lockfile => ZED_OCI_LOCK_MEDIA_TYPE_V1, + Self::Binary => ZED_OCI_BINARY_MEDIA_TYPE_V1, + Self::SpdxSbom => SPDX_JSON_MEDIA_TYPE, + Self::CycloneDxSbom => CYCLONEDX_JSON_MEDIA_TYPE, + Self::Provenance => IN_TOTO_JSON_MEDIA_TYPE, + } + } + + pub fn is_primary_package(self) -> bool { + matches!(self, Self::PackageTarGz | Self::PackageZip | Self::Binary) + } + + fn allows_platform(self) -> bool { + self.is_primary_package() + } +} + +/// One typed layer in the finalized artifact record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct OciLayer { + pub kind: OciLayerKind, + pub descriptor: OciDescriptor, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform: Option, +} + +impl OciLayer { + pub fn validate(&self) -> Result<(), OciInteropError> { + self.descriptor.validate("OCI layer")?; + if self.descriptor.media_type != self.kind.media_type() { + return Err(OciInteropError::InvalidLayer(format!( + "layer kind `{:?}` requires media type `{}`, found `{}`", + self.kind, + self.kind.media_type(), + self.descriptor.media_type + ))); + } + if let Some(platform) = &self.platform { + if !self.kind.allows_platform() { + return Err(OciInteropError::InvalidLayer(format!( + "layer kind `{:?}` cannot carry a platform selector", + self.kind + ))); + } + platform.validate()?; + } + Ok(()) + } +} + +/// Final immutable provenance record connecting a Zed package identity to one +/// OCI manifest digest and all of its typed blobs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct OciAdapterRecord { + pub schema: String, + pub package: OciPackageIdentity, + pub reference: OciReference, + pub manifest: OciDescriptor, + pub config: OciDescriptor, + pub layers: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub annotations: BTreeMap, +} + +impl OciAdapterRecord { + pub fn new( + package: OciPackageIdentity, + reference: OciReference, + manifest: OciDescriptor, + config: OciDescriptor, + layers: Vec, + ) -> Self { + Self { + schema: OCI_ADAPTER_SCHEMA_V1.to_string(), + package, + reference, + manifest, + config, + layers, + subject: None, + annotations: BTreeMap::new(), + } + } + + pub fn validate(&self) -> Result<(), OciInteropError> { + if self.schema != OCI_ADAPTER_SCHEMA_V1 { + return Err(OciInteropError::UnsupportedSchema(self.schema.clone())); + } + self.package.validate()?; + self.reference.validate()?; + let reference_digest = self.reference.require_digest()?; + + self.manifest.validate("OCI manifest")?; + if self.manifest.media_type != OCI_IMAGE_MANIFEST_MEDIA_TYPE { + return Err(OciInteropError::InvalidDescriptor(format!( + "OCI manifest requires media type `{OCI_IMAGE_MANIFEST_MEDIA_TYPE}`" + ))); + } + if reference_digest != &self.manifest.digest { + return Err(OciInteropError::InvalidAdapter( + "reference digest must equal the finalized OCI manifest digest".to_string(), + )); + } + + self.config.validate("OCI config")?; + if self.config.media_type != ZED_OCI_CONFIG_MEDIA_TYPE_V1 { + return Err(OciInteropError::InvalidDescriptor(format!( + "OCI config requires media type `{ZED_OCI_CONFIG_MEDIA_TYPE_V1}`" + ))); + } + if let Some(subject) = &self.subject { + subject.validate("OCI subject")?; + } + validate_annotations(&self.annotations, "OCI adapter record")?; + + if self.layers.is_empty() { + return Err(OciInteropError::InvalidAdapter( + "a finalized OCI artifact requires at least one layer".to_string(), + )); + } + + let mut digests = BTreeSet::new(); + let mut positions = BTreeSet::new(); + let mut has_primary_package = false; + for layer in &self.layers { + layer.validate()?; + has_primary_package |= layer.kind.is_primary_package(); + if !digests.insert(layer.descriptor.digest.as_str()) { + return Err(OciInteropError::InvalidAdapter(format!( + "layer digest `{}` appears more than once", + layer.descriptor.digest + ))); + } + if !positions.insert((layer.kind, layer.platform.as_ref())) { + return Err(OciInteropError::InvalidAdapter(format!( + "layer kind `{:?}` has duplicate platform coverage", + layer.kind + ))); + } + } + if !has_primary_package { + return Err(OciInteropError::InvalidAdapter( + "a finalized OCI artifact requires a package archive or binary layer".to_string(), + )); + } + Ok(()) + } + + /// Deterministic compact JSON bytes for hashing, signing, lock provenance, + /// and OCI referrer attachment. + pub fn canonical_json_bytes(&self) -> Result, OciInteropError> { + self.validate()?; + let mut normalized = self.clone(); + normalized.layers.sort_by(|left, right| { + ( + left.kind, + left.platform.as_ref(), + left.descriptor.digest.as_str(), + ) + .cmp(&( + right.kind, + right.platform.as_ref(), + right.descriptor.digest.as_str(), + )) + }); + let value = serde_json::to_value(normalized) + .map_err(|error| OciInteropError::Json(error.to_string()))?; + serde_json::to_vec(&canonicalize_json(value)) + .map_err(|error| OciInteropError::Json(error.to_string())) + } + + pub fn canonical_json_string(&self) -> Result { + String::from_utf8(self.canonical_json_bytes()?) + .map_err(|error| OciInteropError::Json(error.to_string())) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum OciInteropError { + #[error("unsupported OCI adapter schema `{0}`")] + UnsupportedSchema(String), + #[error("invalid OCI reference: {0}")] + InvalidReference(String), + #[error("invalid OCI digest: {0}")] + InvalidDigest(String), + #[error("invalid OCI package identity: {0}")] + InvalidPackageIdentity(String), + #[error("invalid OCI platform: {0}")] + InvalidPlatform(String), + #[error("invalid OCI descriptor: {0}")] + InvalidDescriptor(String), + #[error("invalid OCI layer: {0}")] + InvalidLayer(String), + #[error("invalid OCI adapter record: {0}")] + InvalidAdapter(String), + #[error("OCI adapter JSON error: {0}")] + Json(String), +} + +fn validate_registry(registry: &str) -> Result<(), OciInteropError> { + if registry.is_empty() + || registry.len() > 253 + || registry != registry.to_ascii_lowercase() + || registry.contains(['/', '@', '[', ']']) + || registry.chars().any(char::is_whitespace) + { + return Err(OciInteropError::InvalidReference(format!( + "registry `{registry}` must be a lowercase DNS name or IPv4 address with an optional port" + ))); + } + + let colon_count = registry.bytes().filter(|byte| *byte == b':').count(); + if colon_count > 1 { + return Err(OciInteropError::InvalidReference( + "contract v1 does not accept bracketed or ambiguous IPv6 registry literals".to_string(), + )); + } + let (host, port) = match registry.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (registry, None), + }; + if host.is_empty() + || host.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || !label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + || !label + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || !label + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + }) + { + return Err(OciInteropError::InvalidReference(format!( + "registry host `{host}` is invalid" + ))); + } + if let Some(port) = port + && (port.is_empty() + || !port.bytes().all(|byte| byte.is_ascii_digit()) + || port + .parse::() + .ok() + .filter(|value| *value > 0) + .is_none()) + { + return Err(OciInteropError::InvalidReference(format!( + "registry port `{port}` must be an integer from 1 through 65535" + ))); + } + Ok(()) +} + +fn validate_repository(repository: &str) -> Result<(), OciInteropError> { + if repository.is_empty() + || repository.len() > 255 + || repository != repository.to_ascii_lowercase() + || repository.split('/').any(|component| { + component.is_empty() + || !component.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b'-') + }) + || !component + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || !component + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + }) + { + return Err(OciInteropError::InvalidReference(format!( + "repository `{repository}` must be a lowercase slash-separated OCI repository name" + ))); + } + Ok(()) +} + +fn validate_tag(tag: &str) -> Result<(), OciInteropError> { + let Some(first) = tag.as_bytes().first() else { + return Err(OciInteropError::InvalidReference( + "OCI tag must not be empty".to_string(), + )); + }; + if tag.len() > 128 + || !first.is_ascii_alphanumeric() && *first != b'_' + || !tag + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) + { + return Err(OciInteropError::InvalidReference(format!( + "OCI tag `{tag}` does not match `[A-Za-z0-9_][A-Za-z0-9_.-]{{0,127}}`" + ))); + } + Ok(()) +} + +fn validate_annotations( + annotations: &BTreeMap, + field: &str, +) -> Result<(), OciInteropError> { + for (key, value) in annotations { + if key.is_empty() + || key.len() > 255 + || !key.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'_' | b'-') + }) + { + return Err(OciInteropError::InvalidDescriptor(format!( + "{field} annotation key `{key}` is invalid" + ))); + } + if value.len() > 4096 || value.chars().any(char::is_control) { + return Err(OciInteropError::InvalidDescriptor(format!( + "{field} annotation `{key}` contains control characters or exceeds 4096 bytes" + ))); + } + } + Ok(()) +} + +fn is_media_type(value: &str) -> bool { + if value.is_empty() || value != value.to_ascii_lowercase() || value.contains(';') { + return false; + } + let Some((kind, subtype)) = value.split_once('/') else { + return false; + }; + !kind.is_empty() + && !subtype.is_empty() + && kind.bytes().chain(subtype.bytes()).all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!( + byte, + b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' + ) + }) +} + +fn is_slug(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn is_platform_token(value: &str) -> bool { + !value.is_empty() + && value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-') + }) +} + +fn canonicalize_json(value: Value) -> Value { + match value { + Value::Object(map) => { + let mut entries: Vec<_> = map.into_iter().collect(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut sorted = serde_json::Map::new(); + for (key, value) in entries { + sorted.insert(key, canonicalize_json(value)); + } + Value::Object(sorted) + } + Value::Array(values) => Value::Array(values.into_iter().map(canonicalize_json).collect()), + scalar => scalar, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DIGEST_A: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const DIGEST_B: &str = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const DIGEST_C: &str = + "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const DIGEST_D: &str = + "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + + fn descriptor(media_type: &str, digest: &str, size: u64) -> OciDescriptor { + OciDescriptor { + media_type: media_type.to_string(), + digest: OciDigest::parse(digest).unwrap(), + size, + annotations: BTreeMap::new(), + } + } + + fn package() -> OciPackageIdentity { + OciPackageIdentity { + org: "acme".to_string(), + name: "tool".to_string(), + version: "1.2.3".to_string(), + target: Some("rust".to_string()), + } + } + + fn record(layers: Vec) -> OciAdapterRecord { + OciAdapterRecord::new( + package(), + OciReference::parse(&format!("oci://ghcr.io/acme/tool:1.2.3@{DIGEST_A}")).unwrap(), + descriptor(OCI_IMAGE_MANIFEST_MEDIA_TYPE, DIGEST_A, 512), + descriptor(ZED_OCI_CONFIG_MEDIA_TYPE_V1, DIGEST_B, 128), + layers, + ) + } + + fn package_layer(digest: &str) -> OciLayer { + OciLayer { + kind: OciLayerKind::PackageTarGz, + descriptor: descriptor(ZED_OCI_PACKAGE_TAR_GZ_MEDIA_TYPE_V1, digest, 1024), + platform: None, + } + } + + #[test] + fn parses_and_roundtrips_tagged_digest_reference() { + let reference = OciReference::parse(&format!( + "oci://registry.example:5000/team/tool:1.2.3@{DIGEST_A}" + )) + .unwrap(); + assert_eq!(reference.registry, "registry.example:5000"); + assert_eq!(reference.repository, "team/tool"); + assert_eq!(reference.tag.as_deref(), Some("1.2.3")); + assert!(reference.is_immutable()); + assert_eq!( + reference.to_string(), + format!("oci://registry.example:5000/team/tool:1.2.3@{DIGEST_A}") + ); + } + + #[test] + fn reference_parser_fails_closed_on_ambiguous_or_mutable_input() { + assert!(OciReference::parse("https://ghcr.io/acme/tool:1").is_err()); + assert!(OciReference::parse("oci://ghcr.io/acme/tool").is_err()); + assert!(OciReference::parse("oci://ghcr.io/Acme/tool:1").is_err()); + assert!(OciReference::parse("oci://user@example.com/acme/tool:1").is_err()); + assert!(OciReference::parse("oci://ghcr.io/acme/tool:1?x=y").is_err()); + assert!(OciDigest::parse("sha256:ABC").is_err()); + } + + #[test] + fn finalized_record_requires_matching_manifest_digest() { + let mut valid = record(vec![package_layer(DIGEST_C)]); + valid.validate().unwrap(); + + valid.manifest.digest = OciDigest::parse(DIGEST_D).unwrap(); + assert!(matches!( + valid.validate(), + Err(OciInteropError::InvalidAdapter(_)) + )); + + let mut tag_only = record(vec![package_layer(DIGEST_C)]); + tag_only.reference.digest = None; + assert!(tag_only.validate().is_err()); + } + + #[test] + fn layer_media_type_and_platform_are_typed() { + let mut manifest = OciLayer { + kind: OciLayerKind::Manifest, + descriptor: descriptor(ZED_OCI_MANIFEST_MEDIA_TYPE_V1, DIGEST_C, 64), + platform: Some(OciPlatform { + os: "linux".to_string(), + architecture: "amd64".to_string(), + variant: None, + }), + }; + assert!(manifest.validate().is_err()); + + manifest.platform = None; + manifest.descriptor.media_type = ZED_OCI_LOCK_MEDIA_TYPE_V1.to_string(); + assert!(manifest.validate().is_err()); + } + + #[test] + fn canonical_json_normalizes_layer_order() { + let manifest_layer = OciLayer { + kind: OciLayerKind::Manifest, + descriptor: descriptor(ZED_OCI_MANIFEST_MEDIA_TYPE_V1, DIGEST_D, 64), + platform: None, + }; + let package_layer = package_layer(DIGEST_C); + let first = record(vec![manifest_layer.clone(), package_layer.clone()]); + let second = record(vec![package_layer, manifest_layer]); + assert_eq!( + first.canonical_json_bytes().unwrap(), + second.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn adapter_rejects_unknown_schema_duplicate_layers_and_missing_payload() { + let mut unknown = record(vec![package_layer(DIGEST_C)]); + unknown.schema = "zed.oci-adapter/v2".to_string(); + assert!(matches!( + unknown.validate(), + Err(OciInteropError::UnsupportedSchema(_)) + )); + + let duplicate = record(vec![package_layer(DIGEST_C), package_layer(DIGEST_C)]); + assert!(duplicate.validate().is_err()); + + let metadata_only = record(vec![OciLayer { + kind: OciLayerKind::Manifest, + descriptor: descriptor(ZED_OCI_MANIFEST_MEDIA_TYPE_V1, DIGEST_C, 64), + platform: None, + }]); + assert!(metadata_only.validate().is_err()); + } +} diff --git a/src/rust/paths.rs b/src/rust/paths.rs new file mode 100644 index 0000000..f03a1e7 --- /dev/null +++ b/src/rust/paths.rs @@ -0,0 +1,63 @@ +//! Filesystem layout conventions shared by the CLI, servers, and docs. +//! +//! The global store lives under `$HOME/.zed-pkg` (overridable with +//! `ZED_PKG_HOME`). Projects never copy packages by default: installed +//! versions are extracted once into the content-addressed store and +//! symlinked into the project's `zed_modules/` directory, pnpm-style. +//! In containers (`--install-mode copy`) the symlink step is replaced by a +//! copy so image layers stay self-contained. + +/// Name of the per-user global directory, resolved against `$HOME`. +pub const ZED_HOME_DIR_NAME: &str = ".zed-pkg"; + +/// Directory inside a project where installed packages appear +/// (`zed_modules//`). +pub const MODULES_DIR: &str = "zed_modules"; + +/// Package manifest file name, at the repository root. TOML only. +pub const MANIFEST_FILE: &str = ".zpkg.toml"; + +/// Lockfile name, written next to the manifest. +pub const LOCKFILE_FILE: &str = ".zpkg.lock"; + +/// Optional file with extra publish-exclusion globs, one per line. +pub const IGNORE_FILE: &str = ".zedignore"; + +/// Store schema version segment; bump when the on-disk layout changes. +pub const STORE_VERSION: &str = "v1"; + +/// Directory inside an extracted store entry that holds the package files. +pub const STORE_PKG_DIR: &str = "pkg"; + +/// Root directory inside every artifact archive under which package files +/// are placed (like npm's `package/` prefix). +pub const ARCHIVE_ROOT: &str = "pkg"; + +/// Where `zed pack` writes artifacts, relative to the project root. +pub const PACK_OUT_DIR: &str = ".zed/pack"; + +/// Directory inside `zed_modules/` where package-declared executables are +/// hoisted (`zed_modules/.bin/`), runnable via `zed run `. +pub const BIN_DIR: &str = ".bin"; + +/// Store entry path for an artifact, relative to the zed home directory, +/// e.g. `store/v1/ab/abcdef.../`. +pub fn store_entry_rel(sha256: &str) -> String { + let prefix = sha256.get(..2).unwrap_or("xx"); + format!("store/{STORE_VERSION}/{prefix}/{sha256}") +} + +/// Build-cache entry for an artifact on one platform, relative to the zed +/// home directory, e.g. `builds/v1/macos-aarch64/ab/abcdef.../`. Source +/// extraction (`store/`) is platform-independent; compiled results are not, +/// so they cache separately per (sha256, platform) and the source store +/// stays immutable. +pub fn build_entry_rel(platform: &str, sha256: &str) -> String { + let prefix = sha256.get(..2).unwrap_or("xx"); + format!("builds/{STORE_VERSION}/{platform}/{prefix}/{sha256}") +} + +/// The `os-arch` pair used to key the build cache, e.g. `linux-x86_64`. +pub fn current_platform() -> String { + format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) +} diff --git a/src/rust/registry.rs b/src/rust/registry.rs new file mode 100644 index 0000000..9a62c2b --- /dev/null +++ b/src/rust/registry.rs @@ -0,0 +1,441 @@ +//! REST API contract between clients (CLI, language SDKs, web UI) and +//! `zed-api-server`. Paths are built by the helpers here so every consumer +//! agrees on the URL scheme; DTOs are the JSON bodies. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::artifact::ArtifactFormat; +use crate::manifest::Manifest; +use crate::vcs::Vcs; + +/// Default public registry (production host: zpkg.tech). Override with +/// `--registry` / `ZED_PKG_REGISTRY`; self-hosted deployments point this at +/// their own `zed-api-server`. +pub const DEFAULT_REGISTRY_URL: &str = "https://registry.zpkg.tech"; + +pub const API_V1: &str = "/v1"; + +/// Multipart field carrying the JSON-encoded [`PublishMeta`]. +pub const PUBLISH_META_FIELD: &str = "meta"; +/// Multipart field carrying the artifact archive bytes. +pub const PUBLISH_ARTIFACT_FIELD: &str = "artifact"; + +/// `GET` — package metadata and version list. +pub fn package_path(org: &str, name: &str) -> String { + format!("{API_V1}/packages/{org}/{name}") +} + +/// `GET` — metadata for one published version. +/// `PUT` (multipart, bearer token) — publish this version. +pub fn version_path(org: &str, name: &str, version: &str) -> String { + format!("{API_V1}/packages/{org}/{name}/versions/{version}") +} + +/// `GET` — download (or get redirected to) the artifact with this sha256. +pub fn artifact_path(sha256: &str) -> String { + format!("{API_V1}/artifacts/{sha256}") +} + +/// `GET ?q=` — search packages by name/description. Also accepts repeatable +/// `?tag=` filters (a package must carry all given tags) and `?limit=`. +pub fn search_path() -> String { + format!("{API_V1}/search") +} + +/// `GET` — list all packages, newest first. Accepts `?tag=` (repeatable), +/// `?limit=`, and `?offset=` for pagination. +pub fn packages_list_path() -> String { + format!("{API_V1}/packages") +} + +/// `POST` — RAG / semantic search: nearest packages to a query embedding +/// within one model's space. Body is [`SemanticSearchRequest`]. +pub fn semantic_search_path() -> String { + format!("{API_V1}/search/semantic") +} + +/// `PUT` (bearer token) — upsert a package's embedding for one model. Body is +/// [`EmbeddingUpsertRequest`]. +pub fn embedding_path(org: &str, name: &str) -> String { + format!("{API_V1}/packages/{org}/{name}/embedding") +} + +/// `GET` — serve one file out of a published artifact, unpkg-style +/// (`/v1/files/acme/http-kit/1.2.0/dist/style.css`). Lets the web consume +/// package contents directly from the edge without installing. +pub fn file_path(org: &str, name: &str, version: &str, path: &str) -> String { + format!("{API_V1}/files/{org}/{name}/{version}/{path}") +} + +/// `POST` (bearer token, org-scoped) — mark a published version as yanked +/// (or restore it). Yanked versions stay downloadable for existing +/// lockfiles but are hidden from resolution and search. +pub fn yank_path(org: &str, name: &str, version: &str) -> String { + format!("{API_V1}/packages/{org}/{name}/versions/{version}/yank") +} + +/// `POST` (bearer token) — claim an org namespace. +pub fn orgs_path() -> String { + format!("{API_V1}/orgs") +} + +/// `GET ?limit=&action=&before=` (bearer token, org `owner` or admin) — the +/// org's audit log: who changed published state, what, and when (zed-docs +/// issue #7 governance). `action` filters to one action; `before` pages +/// backwards by taking entries with a lower `seq` than the one given. +pub fn audit_path(org: &str) -> String { + format!("{API_V1}/orgs/{org}/audit") +} + +/// `GET` (bearer token, org `owner` or admin) — walk the org's audit chain and +/// report whether it is intact. See [`audit_chain_preimage`]. +pub fn audit_verify_path(org: &str) -> String { + format!("{API_V1}/orgs/{org}/audit/verify") +} + +/// `GET` — liveness probe. +pub fn healthz_path() -> String { + "/healthz".to_string() +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageSummary { + pub org: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest: Option, + /// Free-form tags for filtering/discovery. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageMetadata { + pub org: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub vcs: Vcs, + pub repo_url: String, + /// How this package's versions should be interpreted (semver by default). + #[serde( + default, + skip_serializing_if = "crate::version::VersionScheme::is_default" + )] + pub version_scheme: crate::version::VersionScheme, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest: Option, + /// Free-form tags for filtering/discovery (multi-tag lookup). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// All published, non-yanked versions, newest first. + pub versions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct VersionMetadata { + pub org: String, + pub name: String, + pub version: String, + pub sha256: String, + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, + pub vcs_tag: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_commit: Option, + /// Absolute or registry-relative URL the artifact can be fetched from. + pub download_url: String, + /// RFC 3339 timestamp. + pub published_at: String, + #[serde(default)] + pub yanked: bool, +} + +/// JSON half of the multipart publish request; the artifact bytes travel in +/// the [`PUBLISH_ARTIFACT_FIELD`] part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PublishMeta { + pub manifest: Manifest, + /// Tag that exists in the source repository for this version. + pub vcs_tag: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_commit: Option, + /// Client-computed sha256 of the uploaded archive; the server recomputes + /// and rejects on mismatch. + pub sha256: String, + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PublishResponse { + pub org: String, + pub name: String, + pub version: String, + pub sha256: String, +} + +/// Body for the yank route. `yanked: false` restores a yanked version. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct YankRequest { + pub yanked: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct YankResponse { + pub org: String, + pub name: String, + pub version: String, + pub yanked: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ClaimOrgRequest { + pub slug: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ClaimOrgResponse { + pub slug: String, + /// False when the caller already owned the org. + pub created: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SearchResponse { + pub query: String, + pub items: Vec, +} + +/// Response for `GET /v1/packages` (list all). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageListResponse { + pub items: Vec, + /// Total packages matching the filter (before limit/offset). + pub total: u64, +} + +/// Body of `POST /v1/search/semantic` (RAG). The caller computes the query +/// embedding with its model; the server ranks stored package embeddings from +/// the SAME model by cosine distance. Vectors up to 2050 dims are accepted and +/// zero-padded server-side. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticSearchRequest { + /// Embedding model id, e.g. `openai/text-embedding-3-small`. Only stored + /// embeddings from this model are searched. + pub model: String, + /// The query embedding (native width; padded to 2050 server-side). + pub embedding: Vec, + #[serde(default = "default_semantic_limit")] + pub limit: u32, + /// Optional tag filter: results must carry all of these tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +fn default_semantic_limit() -> u32 { + 20 +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticHit { + pub org: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Cosine distance (0 = identical direction, 2 = opposite). Lower is nearer. + pub distance: f64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticSearchResponse { + pub items: Vec, +} + +/// Body of `PUT /v1/packages/{org}/{name}/embedding` — upsert a package's +/// embedding for one model (re-embedding replaces in place). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct EmbeddingUpsertRequest { + pub model: String, + /// The embedding (native width; padded to 2050 server-side). + pub embedding: Vec, + /// The text that was embedded (kept for provenance / re-embedding). + pub content: String, +} + +/// A state-changing action recorded in an org's audit log. Reads are never +/// audited — only mutations of published state and of the namespace itself, so +/// the log answers "who changed what" without drowning in traffic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AuditAction { + /// A version was published. + Publish, + /// A version was yanked (hidden from fresh resolution). + Yank, + /// A previously yanked version was restored. + Unyank, + /// The org namespace was claimed. + OrgClaim, +} + +impl AuditAction { + pub fn as_str(&self) -> &'static str { + match self { + AuditAction::Publish => "publish", + AuditAction::Yank => "yank", + AuditAction::Unyank => "unyank", + AuditAction::OrgClaim => "org_claim", + } + } + + /// Parse a stored action string; unknown values are preserved as `None` so + /// a newer server's rows never break an older client's read. + pub fn parse(s: &str) -> Option { + match s { + "publish" => Some(AuditAction::Publish), + "yank" => Some(AuditAction::Yank), + "unyank" => Some(AuditAction::Unyank), + "org_claim" => Some(AuditAction::OrgClaim), + _ => None, + } + } +} + +/// One audit-log record. The actor is identified by the *token* that acted — +/// its name and role, never its secret — which is the identity a registry +/// actually has (zed-docs issue #7 governance). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AuditEntry { + /// RFC 3339 timestamp of the action. + pub at: String, + /// Raw action string; `action_kind` is the parsed form when recognized. + pub action: String, + /// Parsed action, absent when this server build doesn't recognize it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action_kind: Option, + /// What was acted on, e.g. `acme/http-kit@1.2.0` or the org slug. + pub subject: String, + /// Human-readable name of the token that acted. + pub actor_token_name: String, + /// The acting token's role (`owner`/`publisher`/`reader`, or `admin` for + /// unscoped tokens). + pub actor_role: String, + /// Extra context, e.g. the artifact sha256 for a publish. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// Position in the org's append-only chain, starting at 1. Gaps mean + /// entries were deleted. Defaults to 0 when read from a server that + /// predates the chain. + #[serde(default)] + pub seq: u64, + /// `sha256(audit_chain_preimage(..))` for this entry, lowercase hex. Empty + /// from a pre-chain server. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub entry_hash: String, + /// The previous entry's `entry_hash`; `None` for the first entry in an + /// org's chain. Linking each entry to its predecessor is what makes a + /// silent deletion or edit detectable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prev_hash: Option, +} + +/// The exact bytes an audit entry's `entry_hash` is computed over. +/// +/// Defined here, in the shared contract, so the server and *any* client derive +/// byte-identical input and a client can verify the chain itself rather than +/// trusting the server's own verdict — the point of a tamper-evident log is +/// that the party who could tamper is not the only party who can check. +/// +/// Every field is length-prefixed (`:`). A plain separator +/// would be forgeable: a token named `x|publish` could otherwise shift field +/// boundaries and reproduce another entry's digest. Length prefixes make the +/// encoding unambiguous, so distinct entries can never share a preimage. +/// +/// `at` must be the RFC 3339 timestamp exactly as stored/serialized, and +/// `prev_hash` is the empty string for the first entry in a chain. +/// +/// The fields are named rather than positional on purpose: with ten strings in +/// a row, transposing `subject` and `actor_token_name` would compile silently +/// and quietly change every digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditChainInput<'a> { + pub org_id: &'a str, + pub seq: u64, + /// RFC 3339, exactly as stored and serialized. + pub at: &'a str, + pub action: &'a str, + pub subject: &'a str, + pub actor_token_id: Option<&'a str>, + pub actor_token_name: &'a str, + pub actor_role: &'a str, + pub detail: Option<&'a str>, + /// The previous entry's hash; empty for the first entry in a chain. + pub prev_hash: &'a str, +} + +/// Build the canonical preimage for [`AuditChainInput`]. See the module note +/// on why every field is length-prefixed. +pub fn audit_chain_preimage(input: &AuditChainInput<'_>) -> String { + fn field(out: &mut String, value: &str) { + out.push_str(&value.len().to_string()); + out.push(':'); + out.push_str(value); + } + let mut out = String::new(); + field(&mut out, input.org_id); + field(&mut out, &input.seq.to_string()); + field(&mut out, input.at); + field(&mut out, input.action); + field(&mut out, input.subject); + field(&mut out, input.actor_token_id.unwrap_or("")); + field(&mut out, input.actor_token_name); + field(&mut out, input.actor_role); + field(&mut out, input.detail.unwrap_or("")); + field(&mut out, input.prev_hash); + out +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AuditLogResponse { + pub org: String, + /// Most recent first. + pub entries: Vec, +} + +/// The result of walking an org's audit chain end to end. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AuditIntegrityResponse { + pub org: String, + /// True only when every entry's hash recomputes and every link matches. + pub intact: bool, + pub entries_checked: u64, + /// The `seq` where verification first failed, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_bad_seq: Option, + /// Machine-readable failure kind: `hash_mismatch` (an entry was edited), + /// `broken_link` (an entry's `prev_hash` does not match its predecessor), + /// or `sequence_gap` (an entry was deleted). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub problem: Option, + /// The newest entry's hash — an anchor an operator can record externally + /// so that later truncation of the whole tail is also detectable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub head_hash: Option, +} + +/// Error body returned with any non-2xx status. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ApiError { + /// Stable machine-readable code, e.g. `not_found`, `sha256_mismatch`, + /// `tag_not_found`, `unauthorized`, `org_taken`. + pub code: String, + pub message: String, +} diff --git a/src/rust/sync.rs b/src/rust/sync.rs new file mode 100644 index 0000000..fe31d71 --- /dev/null +++ b/src/rust/sync.rs @@ -0,0 +1,104 @@ +//! Shared sync contract types for cross-language consumers. +//! +//! [`zed-sync`](https://github.com/zed-pkg/zed-sync) is the behavioral source +//! of truth (its `protocol/*.schema.json` and `conformance.json` are +//! canonical). This module re-declares the *wire* enums and the change +//! envelope so Rust services and `zed-clients` share one set of types, and so +//! the generated JSON Schemas in `schemas/` stay aligned with zed-sync. Keep +//! these in lockstep with zed-sync's protocol. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Per-write optimism level (mirrors zed-sync `WriteMode`). Enum, not a +/// boolean: a write names its exact behavior. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SyncWriteMode { + LocalOnly, + #[default] + OptimisticQueue, + OptimisticAwaitAck, + ServerFirst, + ServerOnly, +} + +/// How write/flush errors are surfaced (mirrors zed-sync `ErrorPolicy`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SyncErrorPolicy { + ThrowOnly, + #[default] + EmitOnly, + ThrowAndEmit, + Silent, +} + +/// How a dirty-vs-newer-remote conflict resolves (mirrors zed-sync +/// `ConflictResolution`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SyncConflictResolution { + #[default] + ServerWins, + LastWriteWins, +} + +/// Hybrid Logical Clock stamp — the reconciliation key. Total order: +/// `wall_ms`, then `counter`, then `actor`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Hlc { + pub wall_ms: u64, + pub counter: u32, + pub actor: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SyncOp { + Upsert, + Delete, +} + +/// The change envelope both transports decode to (mirrors zed-sync +/// `ChangeEvent`). `version` is the ONLY reconciliation key; `sync_sequence` +/// is the catch-up cursor and never feeds reconciliation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SyncChangeEvent { + pub table: String, + pub op: SyncOp, + pub id: String, + pub version: Hlc, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub row: Option, + pub at_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub write_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sync_sequence: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_values_match_zed_sync_protocol() { + assert_eq!( + serde_json::to_string(&SyncWriteMode::OptimisticQueue).unwrap(), + "\"optimistic_queue\"" + ); + assert_eq!( + serde_json::to_string(&SyncErrorPolicy::EmitOnly).unwrap(), + "\"emit_only\"" + ); + assert_eq!( + serde_json::to_string(&SyncConflictResolution::ServerWins).unwrap(), + "\"server_wins\"" + ); + assert_eq!( + serde_json::to_string(&SyncOp::Upsert).unwrap(), + "\"upsert\"" + ); + } +} diff --git a/src/rust/tests/install_contract.rs b/src/rust/tests/install_contract.rs new file mode 100644 index 0000000..05a1a53 --- /dev/null +++ b/src/rust/tests/install_contract.rs @@ -0,0 +1,238 @@ +use std::collections::BTreeMap; + +use zed_interfaces::manifest::{ + InstallHooksSection, Manifest, ManifestError, NATIVE_PACKAGE_MANAGERS, +}; + +fn manifest(extra: &str) -> String { + format!( + r#" +[package] +org = "acme" +name = "native-tool" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/native-tool" + +{extra} +"# + ) +} + +#[test] +fn native_dependencies_and_hooks_roundtrip_canonically() { + let parsed = Manifest::parse(&manifest( + r#" +[native_dependencies] +apt = ["pkg-config", "libssl-dev"] +brew = ["pkg-config", "openssl@3"] +nix = ["pkg-config", "openssl"] + +[hooks] +pre_install = ["./scripts/pre-install.sh"] +post_install = ["./scripts/post-install.sh"] +"#, + )) + .unwrap(); + + assert_eq!( + parsed.native_dependencies["apt"], + vec!["pkg-config", "libssl-dev"] + ); + assert_eq!( + parsed.hooks, + InstallHooksSection { + pre_install: vec!["./scripts/pre-install.sh".to_string()], + post_install: vec!["./scripts/post-install.sh".to_string()], + } + ); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[native-dependencies]"), "{encoded}"); + assert!(encoded.contains("pre-install ="), "{encoded}"); + assert!(encoded.contains("post-install ="), "{encoded}"); + assert!(!encoded.contains("native_dependencies"), "{encoded}"); + assert!(!encoded.contains("pre_install"), "{encoded}"); + assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); +} + +#[test] +fn target_native_dependencies_and_hooks_merge_in_order() { + let parsed = Manifest::parse(&manifest( + r#" +[native-dependencies] +apt = ["pkg-config", "libssl-dev"] +brew = ["pkg-config"] + +[hooks] +pre-install = ["echo package-pre"] +post-install = ["echo package-post"] + +[targets.rust] +dir = "clients/rust" + +[targets.rust.native-dependencies] +apt = ["clang", "libssl-dev"] +brew = ["llvm"] + +[targets.rust.hooks] +pre-install = ["echo target-pre"] +post-install = ["echo target-post"] +"#, + )) + .unwrap(); + + let native = parsed.effective_native_dependencies(Some("rust")).unwrap(); + assert_eq!(native["apt"], vec!["pkg-config", "libssl-dev", "clang"]); + assert_eq!(native["brew"], vec!["pkg-config", "llvm"]); + + let hooks = parsed.effective_install_hooks(Some("rust")).unwrap(); + assert_eq!( + hooks.pre_install, + vec!["echo package-pre", "echo target-pre"] + ); + assert_eq!( + hooks.post_install, + vec!["echo package-post", "echo target-post"] + ); + + let derived = parsed.manifest_for_target("rust").unwrap(); + assert!(derived.targets.is_empty()); + assert_eq!(derived.native_dependencies, native); + assert_eq!(derived.hooks, hooks); +} + +#[test] +fn an_unselected_target_does_not_leak_native_install_metadata() { + let parsed = Manifest::parse(&manifest( + r#" +[native-dependencies] +apt = ["pkg-config"] + +[targets.rust] +dir = "clients/rust" +[targets.rust.native-dependencies] +apt = ["clang"] + +[targets.node] +dir = "clients/node" +[targets.node.native-dependencies] +apt = ["nodejs"] +"#, + )) + .unwrap(); + + assert_eq!( + parsed.effective_native_dependencies(Some("node")).unwrap()["apt"], + vec!["pkg-config", "nodejs"] + ); + assert_eq!( + parsed.effective_native_dependencies(None).unwrap()["apt"], + vec!["pkg-config"] + ); +} + +#[test] +fn every_documented_native_manager_parses_even_with_no_packages() { + let mut source = manifest(""); + source.push_str("\n[native-dependencies]\n"); + for manager in NATIVE_PACKAGE_MANAGERS { + source.push_str(&format!("{manager} = []\n")); + } + let parsed = Manifest::parse(&source).unwrap(); + assert_eq!( + parsed.native_dependencies.len(), + NATIVE_PACKAGE_MANAGERS.len() + ); +} + +#[test] +fn unsafe_or_ambiguous_native_package_specs_are_rejected() { + for package in ["", "-y", "two words", "line\nbreak", "\u{7f}"] { + let source = manifest(&format!( + "[native-dependencies]\napt = [{}]\n", + toml::Value::String(package.to_string()) + )); + assert!(matches!( + Manifest::parse(&source), + Err(ManifestError::InvalidNativeDependency(_, _)) + )); + } + + let duplicate = manifest( + r#" +[native-dependencies] +apt = ["pkg-config", "pkg-config"] +"#, + ); + assert!(matches!( + Manifest::parse(&duplicate), + Err(ManifestError::InvalidNativeDependency(_, _)) + )); +} + +#[test] +fn unsupported_native_manager_is_rejected_with_the_allowlist() { + let error = Manifest::parse(&manifest( + r#" +[native-dependencies] +curl-pipe-sh = ["anything"] +"#, + )) + .unwrap_err(); + let message = error.to_string(); + assert!(matches!( + error, + ManifestError::InvalidNativeDependency(_, _) + )); + assert!(message.contains("unsupported"), "{message}"); + assert!(message.contains("apt"), "{message}"); + assert!(message.contains("nix"), "{message}"); +} + +#[test] +fn empty_and_oversized_install_hooks_are_rejected() { + for (key, value) in [ + ("pre-install", " ".to_string()), + ("post-install", "x".repeat(32 * 1024 + 1)), + ] { + let source = manifest(&format!( + "[hooks]\n{key} = [{}]\n", + toml::Value::String(value) + )); + assert!(matches!( + Manifest::parse(&source), + Err(ManifestError::InvalidInstallHook(_, _)) + )); + } +} + +#[test] +fn effective_install_metadata_rejects_an_unknown_explicit_target() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.rust] +dir = "clients/rust" +"#, + )) + .unwrap(); + assert!(matches!( + parsed.effective_native_dependencies(Some("python")), + Err(ManifestError::InvalidTarget(_, _)) + )); + assert!(matches!( + parsed.effective_install_hooks(Some("python")), + Err(ManifestError::InvalidTarget(_, _)) + )); +} + +#[test] +fn empty_default_sections_are_omitted() { + let parsed = Manifest::parse(&manifest("")).unwrap(); + assert_eq!(parsed.native_dependencies, BTreeMap::new()); + assert!(parsed.hooks.is_empty()); + let encoded = parsed.to_toml_string().unwrap(); + assert!(!encoded.contains("native-dependencies")); + assert!(!encoded.contains("[hooks]")); +} diff --git a/src/rust/tests/install_contract_edges.rs b/src/rust/tests/install_contract_edges.rs new file mode 100644 index 0000000..dd65966 --- /dev/null +++ b/src/rust/tests/install_contract_edges.rs @@ -0,0 +1,184 @@ +use zed_interfaces::manifest::{Manifest, ManifestError}; + +fn manifest(extra: &str) -> String { + format!( + r#" +[package] +org = "acme" +name = "native-edge" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/native-edge" + +{extra} +"# + ) +} + +#[test] +fn target_language_synonyms_select_the_same_native_metadata() { + let parsed = Manifest::parse(&manifest( + r#" +[native-dependencies] +apt = ["pkg-config"] + +[hooks] +pre-install = ["echo package-pre"] + +[targets.nodejs] +dir = "clients/node" + +[targets.nodejs.native-dependencies] +apt = ["nodejs"] + +[targets.nodejs.hooks] +pre-install = ["echo node-pre"] +post-install = ["echo node-post"] +"#, + )) + .unwrap(); + + let native = parsed.effective_native_dependencies(Some("node")).unwrap(); + assert_eq!(native["apt"], vec!["pkg-config", "nodejs"]); + + let hooks = parsed.effective_install_hooks(Some("node")).unwrap(); + assert_eq!(hooks.pre_install, vec!["echo package-pre", "echo node-pre"]); + assert_eq!(hooks.post_install, vec!["echo node-post"]); +} + +#[test] +fn target_aliases_serialize_to_the_canonical_keys() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.rust] +dir = "clients/rust" + +[targets.rust.native_dependencies] +apt = ["clang"] + +[targets.rust.hooks] +pre_install = ["echo pre"] +post_install = ["echo post"] +"#, + )) + .unwrap(); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[targets.rust.native-dependencies]")); + assert!(encoded.contains("pre-install =")); + assert!(encoded.contains("post-install =")); + assert!(!encoded.contains("native_dependencies")); + assert!(!encoded.contains("pre_install")); + assert!(!encoded.contains("post_install")); +} + +#[test] +fn manifest_target_projection_applies_lifecycle_metadata_exactly_once() { + let parsed = Manifest::parse(&manifest( + r#" +[native-dependencies] +apt = ["pkg-config", "libssl-dev"] + +[hooks] +pre-install = ["echo package-pre"] +post-install = ["echo package-post"] + +[targets.rust] +dir = "clients/rust" + +[targets.rust.native-dependencies] +apt = ["clang", "libssl-dev"] + +[targets.rust.hooks] +pre-install = ["echo target-pre"] +post-install = ["echo target-post"] +"#, + )) + .unwrap(); + + let projected = parsed.manifest_for_target("rust").unwrap(); + assert!(projected.targets.is_empty()); + assert_eq!( + projected.effective_native_dependencies(None).unwrap()["apt"], + vec!["pkg-config", "libssl-dev", "clang"] + ); + assert_eq!( + projected.effective_install_hooks(None).unwrap().pre_install, + vec!["echo package-pre", "echo target-pre"] + ); + assert_eq!( + projected + .effective_install_hooks(None) + .unwrap() + .post_install, + vec!["echo package-post", "echo target-post"] + ); + assert!(projected.manifest_for_target("rust").is_none()); + + let reparsed = Manifest::parse(&projected.to_toml_string().unwrap()).unwrap(); + assert_eq!(reparsed, projected); +} + +#[test] +fn empty_target_manager_routes_preserve_support_without_adding_packages() { + let parsed = Manifest::parse(&manifest( + r#" +[native-dependencies] +apt = ["pkg-config"] + +[targets.rust] +dir = "clients/rust" + +[targets.rust.native-dependencies] +apt = [] +nix = [] +"#, + )) + .unwrap(); + + let native = parsed.effective_native_dependencies(Some("rust")).unwrap(); + assert_eq!(native["apt"], vec!["pkg-config"]); + assert_eq!(native["nix"], Vec::::new()); +} + +#[test] +fn native_and_hook_size_limits_accept_the_exact_boundary() { + let package = "a".repeat(256); + let hook = "x".repeat(32 * 1024); + let source = manifest(&format!( + "[native-dependencies]\napt = [{}]\n\n[hooks]\npre-install = [{}]\n", + toml::Value::String(package.clone()), + toml::Value::String(hook.clone()) + )); + + let parsed = Manifest::parse(&source).unwrap(); + assert_eq!(parsed.native_dependencies["apt"], vec![package]); + assert_eq!(parsed.hooks.pre_install, vec![hook]); +} + +#[test] +fn native_package_size_limit_rejects_the_first_oversized_value() { + let package = "a".repeat(257); + let source = manifest(&format!( + "[native-dependencies]\napt = [{}]\n", + toml::Value::String(package) + )); + + assert!(matches!( + Manifest::parse(&source), + Err(ManifestError::InvalidNativeDependency(_, _)) + )); +} + +#[test] +fn shell_metacharacters_remain_opaque_native_package_arguments() { + let package = "libssl-dev;echo-not-a-shell"; + let parsed = Manifest::parse(&manifest(&format!( + "[native-dependencies]\napt = [{}]\n", + toml::Value::String(package.to_string()) + ))) + .unwrap(); + + assert_eq!(parsed.native_dependencies["apt"], vec![package]); +} diff --git a/src/rust/tests/lockfile_content_addressed_provenance.rs b/src/rust/tests/lockfile_content_addressed_provenance.rs new file mode 100644 index 0000000..e0e45a1 --- /dev/null +++ b/src/rust/tests/lockfile_content_addressed_provenance.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeSet; + +use zed_interfaces::artifact::ArtifactFormat; +use zed_interfaces::lockfile::{LockedPackage, Lockfile}; + +const DIGEST: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn package(revision: Option<&str>) -> LockedPackage { + LockedPackage { + org: "zed-pkg".to_string(), + name: "fixture".to_string(), + version: "1.2.3".to_string(), + sha256: DIGEST.to_string(), + size: 42, + format: ArtifactFormat::TarGz, + vcs_tag: "v1.2.3".to_string(), + vcs_commit: revision.map(str::to_string), + source: "file:///tmp/registry".to_string(), + } +} + +#[test] +fn canonical_writer_upgrades_legacy_none_without_mutating_the_builder() { + let lock = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: vec![package(None)], + native_dependencies: Vec::new(), + nix_adapters: Vec::new(), + }; + + let rendered = lock.to_toml_string().unwrap(); + let expected = format!("artifact-sha256:{DIGEST}"); + assert!(rendered.contains(&format!("vcs_commit = \"{expected}\""))); + assert_eq!(lock.packages[0].vcs_commit, None); + + let parsed = Lockfile::parse(&rendered).unwrap(); + assert_eq!( + parsed.packages[0].vcs_commit.as_deref(), + Some(expected.as_str()) + ); +} + +#[test] +fn canonical_writer_preserves_a_stronger_verified_revision() { + let revision = "fedcba9876543210fedcba9876543210fedcba98"; + let lock = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: vec![package(Some(revision))], + native_dependencies: Vec::new(), + nix_adapters: Vec::new(), + }; + + let rendered = lock.to_toml_string().unwrap(); + let parsed = Lockfile::parse(&rendered).unwrap(); + assert_eq!(parsed.packages[0].vcs_commit.as_deref(), Some(revision)); + assert!(!rendered.contains("artifact-sha256:")); +} + +#[test] +fn parser_still_rejects_an_omitted_committed_revision() { + let input = format!( + r#"version = 1 + +[[package]] +org = "zed-pkg" +name = "fixture" +version = "1.2.3" +sha256 = "{DIGEST}" +size = 42 +format = "tar.gz" +vcs_tag = "v1.2.3" +source = "file:///tmp/registry" +"# + ); + let error = Lockfile::parse(&input).unwrap_err().to_string(); + assert!(error.contains("vcs_commit"), "unexpected error: {error}"); +} + +#[test] +fn public_schema_keeps_revision_required_despite_the_builder_fallback() { + let schema = schemars::schema_for!(Lockfile); + let value = serde_json::to_value(schema).unwrap(); + let required = value["$defs"]["LockedPackage"]["required"] + .as_array() + .unwrap() + .iter() + .filter_map(|entry| entry.as_str()) + .collect::>(); + assert!(required.contains("format")); + assert!(required.contains("vcs_commit")); +} + +#[test] +fn fallback_derivation_rejects_malformed_or_zero_hashes() { + let digests = ["bad".to_string(), "0".repeat(64)]; + for digest in digests { + let mut bad = package(None); + bad.sha256 = digest; + let lock = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: vec![bad], + native_dependencies: Vec::new(), + nix_adapters: Vec::new(), + }; + let error = lock.to_toml_string().unwrap_err().to_string(); + assert!(error.contains("sha256"), "unexpected error: {error}"); + } +} diff --git a/src/rust/tests/native_dependency_lockfile_contract.rs b/src/rust/tests/native_dependency_lockfile_contract.rs new file mode 100644 index 0000000..490e1ce --- /dev/null +++ b/src/rust/tests/native_dependency_lockfile_contract.rs @@ -0,0 +1,195 @@ +use zed_interfaces::{ + ArtifactFormat, Lockfile, LockfileError, NativeArtifact, NativeDependencyError, + NativeDependencyLock, NativeRegistry, NativeVersionCandidate, +}; + +fn artifact(digit: char) -> NativeArtifact { + NativeArtifact { + sha256: std::iter::repeat_n(digit, 64).collect(), + size: 512, + format: ArtifactFormat::TarGz, + } +} + +fn exact_lock( + registry: NativeRegistry, + package: &str, + declared: &str, + version: &str, + digit: char, +) -> NativeDependencyLock { + NativeDependencyLock::resolve( + registry, + package, + declared, + &[NativeVersionCandidate { + version: version.to_string(), + artifact: artifact(digit), + }], + ) + .expect("fixture must resolve") +} + +#[test] +fn legacy_lockfile_v1_remains_readable_without_native_entries() { + let legacy = r#" +version = 1 + +[[package]] +org = "zedtest" +name = "core" +version = "1.0.0" +sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +size = 128 +format = "tar.gz" +vcs_tag = "v1.0.0" +vcs_commit = "0123456789abcdef0123456789abcdef01234567" +source = "file:///tmp/registry" +"#; + + let parsed = Lockfile::parse(legacy).expect("legacy v1 lockfile must parse"); + assert!(parsed.native_dependencies.is_empty()); + assert!(parsed.nix_adapters.is_empty()); + assert_eq!(parsed.find("zedtest", "core").unwrap().version, "1.0.0"); + + let emitted = parsed.to_toml_string().unwrap(); + assert!(!emitted.contains("native-dependency")); +} + +#[test] +fn native_locks_round_trip_and_serialize_independent_of_insertion_order() { + let npm = exact_lock(NativeRegistry::Npm, "@fiducia/core", "^1.2.3", "1.9.0", 'a'); + let cargo = exact_lock(NativeRegistry::Cargo, "fiducia_core", "1.2.3", "1.9.0", 'b'); + + let mut forward = Lockfile::default(); + forward.upsert_native_dependency(cargo.clone()).unwrap(); + forward.upsert_native_dependency(npm.clone()).unwrap(); + + let mut reverse = Lockfile::default(); + reverse.upsert_native_dependency(npm.clone()).unwrap(); + reverse.upsert_native_dependency(cargo.clone()).unwrap(); + + let forward_toml = forward.to_toml_string().unwrap(); + let reverse_toml = reverse.to_toml_string().unwrap(); + assert_eq!(forward_toml, reverse_toml); + assert_eq!(forward_toml.matches("[[native-dependency]]").count(), 2); + assert!(forward_toml.contains("declared = \"^1.2.3\"")); + assert!(forward_toml.contains("canonical = \"^1.2.3\"")); + + let parsed = Lockfile::parse(&forward_toml).unwrap(); + assert_eq!(parsed, forward); + assert_eq!( + parsed + .find_native_dependency(NativeRegistry::Npm, "@fiducia/core") + .unwrap() + .package + .version, + "1.9.0" + ); + assert_eq!( + parsed + .find_native_dependency(NativeRegistry::Cargo, "fiducia_core") + .unwrap() + .artifact + .sha256, + "b".repeat(64) + ); +} + +#[test] +fn upsert_replaces_only_the_same_registry_and_package_identity() { + let npm_123 = exact_lock(NativeRegistry::Npm, "core", "1.2.3", "1.2.3", 'a'); + let npm_124 = exact_lock(NativeRegistry::Npm, "core", "1.2.4", "1.2.4", 'b'); + let cargo = exact_lock(NativeRegistry::Cargo, "core", "1.2.3", "1.9.0", 'c'); + + let mut lockfile = Lockfile::default(); + lockfile.upsert_native_dependency(npm_123).unwrap(); + lockfile.upsert_native_dependency(cargo).unwrap(); + lockfile.upsert_native_dependency(npm_124).unwrap(); + + assert_eq!(lockfile.native_dependencies.len(), 2); + assert_eq!( + lockfile + .find_native_dependency(NativeRegistry::Npm, "core") + .unwrap() + .package + .version, + "1.2.4" + ); + assert_eq!( + lockfile + .find_native_dependency(NativeRegistry::Cargo, "core") + .unwrap() + .package + .version, + "1.9.0" + ); +} + +#[test] +fn duplicate_native_keys_fail_during_write_and_parse() { + let first = exact_lock(NativeRegistry::Npm, "@fiducia/core", "1.2.3", "1.2.3", 'a'); + let second = exact_lock(NativeRegistry::Npm, "@fiducia/core", "1.2.4", "1.2.4", 'b'); + let duplicate = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: Vec::new(), + native_dependencies: vec![first, second], + nix_adapters: Vec::new(), + }; + + assert!(matches!( + duplicate.to_toml_string(), + Err(LockfileError::DuplicateNativeDependency(_)) + )); + + let raw = toml::to_string_pretty(&duplicate).unwrap(); + assert!(matches!( + Lockfile::parse(&raw), + Err(LockfileError::DuplicateNativeDependency(_)) + )); +} + +#[test] +fn invalid_embedded_provenance_fails_during_upsert_write_and_parse() { + let mut drift = exact_lock(NativeRegistry::Npm, "@fiducia/core", "^1.2.3", "1.9.0", 'a'); + drift.requirement.canonical = "^1.3.0".to_string(); + + let mut lockfile = Lockfile::default(); + assert!(matches!( + lockfile.upsert_native_dependency(drift.clone()), + Err(LockfileError::InvalidNativeDependency(_)) + )); + + lockfile.native_dependencies.push(drift); + assert!(matches!( + lockfile.to_toml_string(), + Err(LockfileError::InvalidNativeDependency(_)) + )); + + let raw = toml::to_string_pretty(&lockfile).unwrap(); + assert!(matches!( + Lockfile::parse(&raw), + Err(LockfileError::InvalidNativeDependency(_)) + )); +} + +#[test] +fn embedded_lock_preserves_native_validation_errors() { + let mut invalid = exact_lock(NativeRegistry::Cargo, "fiducia_core", "1.2.3", "1.9.0", 'a'); + invalid.schema = "zed.native-dependency-lock/v2".to_string(); + assert!(matches!( + invalid.validate(), + Err(NativeDependencyError::UnsupportedSchema { .. }) + )); + + let lockfile = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: Vec::new(), + native_dependencies: vec![invalid], + nix_adapters: Vec::new(), + }; + assert!(matches!( + lockfile.to_toml_string(), + Err(LockfileError::InvalidNativeDependency(_)) + )); +} diff --git a/src/rust/tests/native_dependency_lockfile_schema_contract.rs b/src/rust/tests/native_dependency_lockfile_schema_contract.rs new file mode 100644 index 0000000..1263187 --- /dev/null +++ b/src/rust/tests/native_dependency_lockfile_schema_contract.rs @@ -0,0 +1,23 @@ +use schemars::schema_for; +use serde_json::Value; +use zed_interfaces::Lockfile; + +const LOCKFILE_SCHEMA: &str = include_str!("../../../schemas/lockfile.json"); + +#[test] +fn checked_in_lockfile_schema_includes_native_dependency_provenance() { + let checked_in: Value = + serde_json::from_str(LOCKFILE_SCHEMA).expect("checked-in lockfile schema must parse"); + let generated = serde_json::to_value(schema_for!(Lockfile)).unwrap(); + assert_eq!(checked_in, generated); + + let text = LOCKFILE_SCHEMA; + assert!(text.contains("native-dependency")); + assert!(text.contains("NativeDependencyLock")); + assert!(text.contains("NativeVersionRequirement")); + assert!(text.contains("declared")); + assert!(text.contains("canonical")); + assert!(text.contains("sha256")); + assert!(text.contains("npm")); + assert!(text.contains("cargo")); +} diff --git a/src/rust/tests/native_dependency_schema_contract.rs b/src/rust/tests/native_dependency_schema_contract.rs new file mode 100644 index 0000000..8919f92 --- /dev/null +++ b/src/rust/tests/native_dependency_schema_contract.rs @@ -0,0 +1,23 @@ +use schemars::schema_for; +use serde_json::Value; +use zed_interfaces::NativeDependencyLock; + +const NATIVE_DEPENDENCY_LOCK_SCHEMA: &str = + include_str!("../../../schemas/native-dependency-lock.json"); + +#[test] +fn checked_in_native_dependency_schema_matches_the_public_contract() { + let checked_in: Value = serde_json::from_str(NATIVE_DEPENDENCY_LOCK_SCHEMA) + .expect("checked-in native dependency schema must parse"); + let generated = serde_json::to_value(schema_for!(NativeDependencyLock)).unwrap(); + assert_eq!(checked_in, generated); + + let text = NATIVE_DEPENDENCY_LOCK_SCHEMA; + assert!(text.contains("NativeDependencyLock")); + assert!(text.contains("NativeVersionRequirement")); + assert!(text.contains("declared")); + assert!(text.contains("canonical")); + assert!(text.contains("sha256")); + assert!(text.contains("npm")); + assert!(text.contains("cargo")); +} diff --git a/src/rust/tests/native_registry_schema_contract.rs b/src/rust/tests/native_registry_schema_contract.rs new file mode 100644 index 0000000..76c9ee6 --- /dev/null +++ b/src/rust/tests/native_registry_schema_contract.rs @@ -0,0 +1,20 @@ +use schemars::schema_for; +use serde_json::Value; +use zed_interfaces::NativeRegistryAdapterRecord; + +const NATIVE_REGISTRY_ADAPTER_SCHEMA: &str = + include_str!("../../../schemas/native-registry-adapter-record.json"); + +#[test] +fn checked_in_native_registry_schema_matches_the_public_contract() { + let checked_in: Value = serde_json::from_str(NATIVE_REGISTRY_ADAPTER_SCHEMA) + .expect("checked-in native-registry schema must parse"); + let generated = serde_json::to_value(schema_for!(NativeRegistryAdapterRecord)).unwrap(); + assert_eq!(checked_in, generated); + + let text = NATIVE_REGISTRY_ADAPTER_SCHEMA; + assert!(text.contains("platform_packages")); + assert!(text.contains("sha256")); + assert!(text.contains("npm")); + assert!(text.contains("cargo")); +} diff --git a/src/rust/tests/native_release.rs b/src/rust/tests/native_release.rs new file mode 100644 index 0000000..2f69ab3 --- /dev/null +++ b/src/rust/tests/native_release.rs @@ -0,0 +1,478 @@ +use zed_interfaces::manifest::{ForgeRegistry, Manifest, ManifestError, NativeRegistry}; + +fn manifest(targets: &str) -> String { + format!( + r#" +[package] +org = "acme" +name = "clients" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/clients" + +{targets} +"# + ) +} + +#[test] +fn native_routes_parse_roundtrip_and_stay_sorted() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.rust] +dir = "clients/rust" + +[targets.rust.native] +registry = "crates-io" +package = "acme-client" + +[targets.nodejs] +dir = "clients/typescript" +adapter = "node" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" + +[targets.dart] +dir = "clients/dart" + +[targets.dart.native] +registry = "pub.dev" +package = "acme_client" + +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "pypi" +package = "Acme.Client" +"#, + )) + .unwrap(); + + let routes = parsed.native_release_routes(); + assert_eq!(routes.len(), 4); + assert_eq!(routes[0].target, "dart"); + assert_eq!(routes[0].registry, NativeRegistry::PubDev); + assert_eq!(routes[0].package, "acme_client"); + assert_eq!(routes[1].target, "nodejs"); + assert_eq!(routes[1].registry, NativeRegistry::Npm); + assert_eq!(routes[1].package, "@acme/client"); + assert_eq!(routes[2].target, "python"); + assert_eq!(routes[2].registry, NativeRegistry::PyPi); + assert_eq!(routes[2].package, "Acme.Client"); + assert_eq!(routes[3].target, "rust"); + assert_eq!(routes[3].registry, NativeRegistry::CratesIo); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[targets.nodejs.native]")); + assert!(encoded.contains("registry = \"npm\"")); + assert!(encoded.contains("registry = \"pub.dev\"")); + Manifest::parse(&encoded).unwrap(); +} + +#[test] +fn a_single_language_repository_can_declare_native_and_forge_routes() { + let parsed = Manifest::parse(&manifest( + r#" +[publish.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +"#, + )) + .unwrap(); + + let native = parsed.native_release_routes(); + assert_eq!(native.len(), 1); + assert_eq!(native[0].target, "repository"); + assert_eq!(native[0].dir, "."); + assert_eq!(native[0].registry, NativeRegistry::Npm); + assert_eq!(native[0].package, "@acme/client"); + assert_eq!(native[0].vcs_tag, "v1.2.3"); + + let forge = parsed.forge_release_routes(); + assert_eq!(forge.len(), 3); + assert!(forge.iter().all(|route| route.target == "repository")); + assert!(forge.iter().all(|route| route.dir == ".")); + assert_eq!(forge[0].registry, ForgeRegistry::GithubPackages); + assert_eq!(forge[1].registry, ForgeRegistry::GitlabPackages); + assert_eq!(forge[2].registry, ForgeRegistry::BitbucketPackages); + assert!(forge.iter().all(|route| route.vcs_tag == "v1.2.3")); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[publish.native]")); + assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); +} + +#[test] +fn a_polyglot_package_cannot_mix_root_and_target_native_routes() { + let error = Manifest::parse(&manifest( + r#" +[publish.native] +registry = "npm" +package = "@acme/all-clients" + +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +"#, + )) + .unwrap_err(); + + let message = error.to_string(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); + assert!(message.contains("single-language"), "{message}"); +} + +#[test] +fn whole_repository_target_cannot_route_to_a_native_registry() { + let error = Manifest::parse(&manifest( + r#" +[targets.repository] +dir = "." + +[targets.repository.native] +registry = "npm" +package = "acme-repository" +"#, + )) + .unwrap_err(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); +} + +#[test] +fn native_package_identities_are_registry_specific() { + for targets in [ + r#" +[targets.nodejs] +dir = "clients/typescript" +[targets.nodejs.native] +registry = "npm" +package = "Bad Package" +"#, + r#" +[targets.rust] +dir = "clients/rust" +[targets.rust.native] +registry = "crates-io" +package = "acme/client" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "Bad-Dart-Package" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "123_client" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "class" +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "-bad-name" +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "bad name" +"#, + ] { + assert!(matches!( + Manifest::parse(&manifest(targets)), + Err(ManifestError::InvalidNativeRoute(_, _)) + )); + } +} + +#[test] +fn duplicate_native_destinations_are_rejected() { + let error = Manifest::parse(&manifest( + r#" +[targets.nodejs] +dir = "clients/typescript" +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" + +[targets.browser] +dir = "clients/browser" +[targets.browser.native] +registry = "npm" +package = "@acme/client" +"#, + )) + .unwrap_err(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); +} + +#[test] +fn pypi_duplicate_destinations_use_normalized_names() { + let error = Manifest::parse(&manifest( + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "Friendly_Bard" + +[targets.python-async] +dir = "clients/python-async" +[targets.python-async.native] +registry = "pypi" +package = "friendly...bard" +"#, + )) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("already routed"), "{message}"); +} + +// --- the two ecosystem axes must agree ------------------------------------ + +#[test] +fn a_native_route_that_contradicts_the_install_ecosystem_is_rejected() { + // `native` is outbound (mirror to npm), `ecosystem` is inbound (what a + // consumer needs to install). A python slice routed to npm means one of the + // two is wrong, and either way it stays silent until someone hits it. + let err = Manifest::parse(&manifest( + r#" +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "npm" +package = "acme-client" +"#, + )) + .expect_err("a python target mirrored to npm must not validate"); + let msg = err.to_string(); + assert!(msg.contains("npm"), "{msg}"); + assert!(msg.contains("pypi"), "{msg}"); +} + +#[test] +fn a_native_route_matching_the_target_language_validates() { + for (target, dir, registry, package) in [ + ("nodejs", "clients/ts", "npm", "@acme/client"), + ("rust", "clients/rust", "crates-io", "acme-client"), + ("python", "clients/python", "pypi", "acme-client"), + ("dart", "clients/dart", "pub.dev", "acme_client"), + ] { + let toml = manifest(&format!( + r#" +[targets.{target}] +dir = "{dir}" + +[targets.{target}.native] +registry = "{registry}" +package = "{package}" +"# + )); + let parsed = Manifest::parse(&toml) + .unwrap_or_else(|e| panic!("{target} -> {registry} must validate: {e}")); + let route = &parsed.native_release_routes()[0]; + assert_eq!(route.target, target); + assert_eq!( + route.registry.ecosystem(), + parsed.targets[target].ecosystem_for(target) + ); + } +} + +#[test] +fn an_explicit_ecosystem_override_reconciles_a_deliberate_mismatch() { + // rust-wasm is Rust source consumed by a JS bundler: the mirror really is + // npm, so declaring the inbound ecosystem makes the pair consistent rather + // than requiring the check to be weakened. + let toml = manifest( + r#" +[targets.rust-wasm] +dir = "clients/rust-wasm" +ecosystem = "npm" + +[targets.rust-wasm.native] +registry = "npm" +package = "@acme/client-wasm" +"#, + ); + let parsed = Manifest::parse(&toml).expect("an explicit npm ecosystem must reconcile"); + assert_eq!( + parsed.targets["rust-wasm"].ecosystem_for("rust-wasm"), + NativeRegistry::Npm.ecosystem() + ); +} + +#[test] +fn a_target_key_that_is_not_a_language_never_contradicts_its_route() { + // An arbitrary target slug carries no ecosystem claim, so there is nothing + // for the native route to disagree with. + let toml = manifest( + r#" +[targets.wasm3] +dir = "clients/wasm3" + +[targets.wasm3.native] +registry = "npm" +package = "acme-wasm3" +"#, + ); + assert!(Manifest::parse(&toml).is_ok()); +} + +#[test] +fn forge_package_routes_roundtrip_and_flatten_deterministically() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] + +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "pypi" +package = "acme-client" +forge = ["gitlab-packages"] +"#, + )) + .unwrap(); + + let routes = parsed.forge_release_routes(); + assert_eq!(routes.len(), 4); + assert_eq!(routes[0].target, "nodejs"); + assert_eq!(routes[0].registry, ForgeRegistry::GithubPackages); + assert_eq!(routes[0].format, NativeRegistry::Npm); + assert_eq!(routes[1].registry, ForgeRegistry::GitlabPackages); + assert_eq!(routes[2].registry, ForgeRegistry::BitbucketPackages); + assert_eq!(routes[3].target, "python"); + assert_eq!(routes[3].registry, ForgeRegistry::GitlabPackages); + assert_eq!(routes[3].format, NativeRegistry::PyPi); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("github-packages")); + assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); + + let derived = parsed.manifest_for_target("nodejs").unwrap(); + assert!(derived.targets.is_empty()); + assert_eq!(derived.publish.native, parsed.targets["nodejs"].native); + assert_eq!(derived.native_release_routes()[0].target, "repository"); + assert_eq!( + Manifest::parse(&derived.to_toml_string().unwrap()).unwrap(), + derived + ); +} + +#[test] +fn unsupported_and_duplicate_forge_routes_are_rejected() { + for targets in [ + r#" +[targets.rust] +dir = "clients/rust" +[targets.rust.native] +registry = "crates-io" +package = "acme-client" +forge = ["github-packages"] +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "acme-client" +forge = ["bitbucket-packages"] +"#, + r#" +[targets.nodejs] +dir = "clients/typescript" +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "github-packages"] +"#, + ] { + assert!(matches!( + Manifest::parse(&manifest(targets)), + Err(ManifestError::InvalidNativeRoute(_, _)) + )); + } +} + +#[test] +fn major_native_registry_identities_and_ecosystems_validate() { + for (target, registry, package, ecosystem) in [ + ("java", "maven-central", "com.acme:client", "jvm"), + ("ruby", "rubygems", "acme-client", "gem"), + ("csharp", "nuget", "Acme.Client", "nuget"), + ("php", "packagist", "acme/client", "composer"), + ("golang", "go-modules", "github.com/acme/client", "gomod"), + ] { + let tag_format = if registry == "go-modules" { + format!("tag_format = \"clients/{target}/v{{version}}\"") + } else { + String::new() + }; + let parsed = Manifest::parse(&manifest(&format!( + r#" +[targets.{target}] +dir = "clients/{target}" + +[targets.{target}.native] +registry = "{registry}" +package = "{package}" +{tag_format} +"# + ))) + .unwrap_or_else(|error| panic!("{registry} route must validate: {error}")); + let route = &parsed.native_release_routes()[0]; + assert_eq!(route.registry.ecosystem().as_str(), ecosystem); + if registry == "go-modules" { + assert_eq!(route.vcs_tag, format!("clients/{target}/v1.2.3")); + } + } +} + +#[test] +fn a_subdirectory_go_module_requires_its_native_tag_prefix() { + for tag_format in ["", "tag_format = \"v{version}\""] { + let error = Manifest::parse(&manifest(&format!( + r#" +[targets.golang] +dir = "clients/go" + +[targets.golang.native] +registry = "go-modules" +package = "github.com/acme/client" +{tag_format} +"# + ))) + .unwrap_err(); + assert!(error.to_string().contains("clients/go/")); + } +} diff --git a/src/rust/tests/nix_interop_contract.rs b/src/rust/tests/nix_interop_contract.rs new file mode 100644 index 0000000..ac84955 --- /dev/null +++ b/src/rust/tests/nix_interop_contract.rs @@ -0,0 +1,97 @@ +use zed_interfaces::{ + ArtifactFormat, NixAdapterRecord, NixBuilderNetwork, NixInteropArtifact, NixOutputOrigin, + NixPackageIdentity, NixPolicyEvidence, NixPolicyProfile, NixRealizedOutput, NixStoreReference, +}; + +const HEX_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const HEX_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const NAR_A: &str = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const STORE_A: &str = "/nix/store/00000000000000000000000000000000-tool-1.2.3"; + +fn strict_policy() -> NixPolicyEvidence { + NixPolicyEvidence { + profile: NixPolicyProfile::StrictV1, + pure_evaluation: true, + import_from_derivation: false, + sandbox_required: true, + builder_network: NixBuilderNetwork::Disabled, + dirty_source: false, + publishable: true, + } +} + +fn source(references: Vec) -> NixOutputOrigin { + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized: NixRealizedOutput { + system: "x86_64-linux".to_string(), + output: "out".to_string(), + derivation_json_sha256: HEX_B.to_string(), + store_path: STORE_A.to_string(), + nar_hash: NAR_A.to_string(), + nar_size: 512, + references, + signatures: vec!["cache.example-1:signature".to_string()], + nix_version: "2.35.2".to_string(), + store_info_json_version: 3, + }, + } +} + +fn package() -> NixPackageIdentity { + NixPackageIdentity { + org: "acme".to_string(), + name: "tool".to_string(), + version: "1.2.3".to_string(), + target: None, + } +} + +fn artifact() -> NixInteropArtifact { + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 1024, + } +} + +#[test] +fn public_contract_round_trips_canonical_json() { + let record = + NixAdapterRecord::nix_to_zed(package(), source(Vec::new()), artifact(), strict_policy()); + + let canonical = record.canonical_json_string().unwrap(); + let parsed: NixAdapterRecord = serde_json::from_str(&canonical).unwrap(); + + assert_eq!(record, parsed); + assert_eq!(canonical, parsed.canonical_json_string().unwrap()); + assert!(canonical.contains("\"direction\":\"nix-to-zed\"")); +} + +#[test] +fn public_contract_rejects_a_mutable_flake_selector() { + let mut mutable = source(Vec::new()); + mutable.locked_ref = "github:acme/tool/main".to_string(); + let record = NixAdapterRecord::nix_to_zed(package(), mutable, artifact(), strict_policy()); + + assert!(record.validate().is_err()); +} + +#[test] +fn public_contract_rejects_a_closure_bearing_import() { + let reference = NixStoreReference { + store_path: "/nix/store/11111111111111111111111111111111-glibc".to_string(), + nar_hash: Some(NAR_A.to_string()), + nar_size: Some(2048), + }; + let record = NixAdapterRecord::nix_to_zed( + package(), + source(vec![reference]), + artifact(), + strict_policy(), + ); + + assert!(record.validate().is_err()); +} diff --git a/src/rust/tests/nix_manifest_lock.rs b/src/rust/tests/nix_manifest_lock.rs new file mode 100644 index 0000000..4211f28 --- /dev/null +++ b/src/rust/tests/nix_manifest_lock.rs @@ -0,0 +1,244 @@ +use zed_interfaces::{ + ArtifactFormat, Lockfile, Manifest, NixAdapterRecord, NixBuilderNetwork, NixInteropArtifact, + NixOutputOrigin, NixPackageIdentity, NixPolicyEvidence, NixPolicyProfile, NixRealizedOutput, +}; + +const HEX_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const HEX_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const NAR_A: &str = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const STORE_A: &str = "/nix/store/00000000000000000000000000000000-tool-1.2.3"; + +fn strict_policy() -> NixPolicyEvidence { + NixPolicyEvidence { + profile: NixPolicyProfile::StrictV1, + pure_evaluation: true, + import_from_derivation: false, + sandbox_required: true, + builder_network: NixBuilderNetwork::Disabled, + dirty_source: false, + publishable: true, + } +} + +fn adapter(name: &str, system: &str) -> NixAdapterRecord { + NixAdapterRecord::nix_to_zed( + NixPackageIdentity { + org: "acme".to_string(), + name: name.to_string(), + version: "1.2.3".to_string(), + target: None, + }, + NixOutputOrigin { + locked_ref: format!("github:acme/{name}/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: format!("packages.{system}.{name}"), + realized: NixRealizedOutput { + system: system.to_string(), + output: "out".to_string(), + derivation_json_sha256: HEX_B.to_string(), + store_path: STORE_A.to_string(), + nar_hash: NAR_A.to_string(), + nar_size: 512, + references: Vec::new(), + signatures: vec!["cache.example-1:signature".to_string()], + nix_version: "2.35.2".to_string(), + store_info_json_version: 3, + }, + }, + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 1024, + }, + strict_policy(), + ) +} + +#[test] +fn single_package_nix_export_intent_round_trips_and_plans() { + let manifest = Manifest::parse( + r#" +[package] +org = "acme" +name = "tool" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/tool" + +[publish.nix] +mode = "artifact" +attribute = "acme-tool" +systems = ["x86_64-linux", "aarch64-linux"] +outputs = ["out"] +"#, + ) + .unwrap(); + + let routes = manifest.nix_export_routes(); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].target, "repository"); + assert_eq!(routes[0].dir, "."); + assert_eq!(routes[0].package, "tool"); + assert_eq!(routes[0].intent.resolved_attribute("tool"), "acme-tool"); + + let encoded = manifest.to_toml_string().unwrap(); + assert_eq!(Manifest::parse(&encoded).unwrap(), manifest); +} + +#[test] +fn a_polyglot_target_carries_its_nix_intent_into_the_rerooted_manifest() { + let manifest = Manifest::parse( + r#" +[package] +org = "acme" +name = "sdk" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/sdk" + +[targets.rust] +dir = "clients/rust" + +[targets.rust.nix] +systems = ["x86_64-linux"] +outputs = ["out"] +"#, + ) + .unwrap(); + + let routes = manifest.nix_export_routes(); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].target, "rust"); + assert_eq!(routes[0].package, "sdk-rust"); + + let rerooted = manifest.manifest_for_target("rust").unwrap(); + assert!(rerooted.targets.is_empty()); + assert!(rerooted.publish.nix.is_some()); + assert_eq!(rerooted.nix_export_routes()[0].package, "sdk-rust"); +} + +#[test] +fn root_nix_intent_is_rejected_for_a_polyglot_manifest() { + let error = Manifest::parse( + r#" +[package] +org = "acme" +name = "sdk" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/sdk" + +[publish.nix] +systems = ["x86_64-linux"] +outputs = ["out"] + +[targets.rust] +dir = "clients/rust" +"#, + ) + .unwrap_err(); + + assert!(error.to_string().contains("[targets..nix]")); +} + +#[test] +fn duplicate_effective_nix_attributes_are_rejected() { + let error = Manifest::parse( + r#" +[package] +org = "acme" +name = "sdk" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/sdk" + +[targets.node] +dir = "clients/node" + +[targets.node.nix] +attribute = "sdk-client" +systems = ["x86_64-linux"] +outputs = ["out"] + +[targets.rust] +dir = "clients/rust" + +[targets.rust.nix] +attribute = "sdk-client" +systems = ["x86_64-linux"] +outputs = ["out"] +"#, + ) + .unwrap_err(); + + assert!(error.to_string().contains("already used by target")); +} + +#[test] +fn old_manifests_and_lockfiles_remain_valid_without_nix_metadata() { + let manifest = Manifest::parse( + r#" +[package] +org = "acme" +name = "tool" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/tool" +"#, + ) + .unwrap(); + assert!(manifest.nix_export_routes().is_empty()); + + let lock = Lockfile::parse( + r#" +version = 1 + +[[package]] +org = "acme" +name = "tool" +version = "1.2.3" +sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +size = 42 +format = "tar.gz" +vcs_tag = "v1.2.3" +vcs_commit = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +source = "https://zpkg.example" +"#, + ) + .unwrap(); + assert!(lock.nix_adapters.is_empty()); +} + +#[test] +fn lockfile_adapter_upsert_is_validated_deduplicated_and_deterministic() { + let mut lock = Lockfile::default(); + lock.upsert_nix_adapter(adapter("z-tool", "x86_64-linux")) + .unwrap(); + lock.upsert_nix_adapter(adapter("a-tool", "aarch64-linux")) + .unwrap(); + lock.upsert_nix_adapter(adapter("z-tool", "x86_64-linux")) + .unwrap(); + + assert_eq!(lock.nix_adapters.len(), 2); + let encoded = lock.to_toml_string().unwrap(); + let parsed = Lockfile::parse(&encoded).unwrap(); + assert_eq!(parsed, lock); + assert!(encoded.find("a-tool").unwrap() < encoded.find("z-tool").unwrap()); +} + +#[test] +fn lockfile_refuses_invalid_adapter_provenance() { + let mut invalid = adapter("tool", "x86_64-linux"); + if let NixAdapterRecord::NixToZed { source, .. } = &mut invalid { + source.locked_ref = "github:acme/tool/main".to_string(); + } + + let mut lock = Lockfile::default(); + lock.nix_adapters.push(invalid); + assert!(lock.to_toml_string().is_err()); +} diff --git a/src/rust/tests/nix_schema_contract.rs b/src/rust/tests/nix_schema_contract.rs new file mode 100644 index 0000000..68038dc --- /dev/null +++ b/src/rust/tests/nix_schema_contract.rs @@ -0,0 +1,33 @@ +use schemars::schema_for; +use serde_json::Value; +use zed_interfaces::{NixAdapterRecord, NixExportSection}; + +const NIX_EXPORT_SCHEMA: &str = include_str!("../../../schemas/nix-export-section.json"); +const NIX_ADAPTER_SCHEMA: &str = include_str!("../../../schemas/nix-adapter-record.json"); + +fn checked_in_schema(input: &str) -> Value { + serde_json::from_str(input).expect("checked-in JSON schema must parse") +} + +#[test] +fn checked_in_nix_export_schema_matches_the_public_contract() { + let generated = serde_json::to_value(schema_for!(NixExportSection)).unwrap(); + assert_eq!(checked_in_schema(NIX_EXPORT_SCHEMA), generated); + + let text = NIX_EXPORT_SCHEMA; + assert!(text.contains("systems")); + assert!(text.contains("outputs")); + assert!(text.contains("artifact")); +} + +#[test] +fn checked_in_nix_adapter_schema_matches_the_public_contract() { + let generated = serde_json::to_value(schema_for!(NixAdapterRecord)).unwrap(); + assert_eq!(checked_in_schema(NIX_ADAPTER_SCHEMA), generated); + + let text = NIX_ADAPTER_SCHEMA; + assert!(text.contains("direction")); + assert!(text.contains("schema")); + assert!(text.contains("zed-to-nix")); + assert!(text.contains("nix-to-zed")); +} diff --git a/src/rust/tests/own_manifest.rs b/src/rust/tests/own_manifest.rs new file mode 100644 index 0000000..14e50d6 --- /dev/null +++ b/src/rust/tests/own_manifest.rs @@ -0,0 +1,85 @@ +//! This repository's own `.zpkg.toml` must satisfy the rules this crate +//! defines. zed-interfaces is where the polyglot manifest model lives, so a +//! manifest here that its own parser rejects is the worst possible bug — and +//! the one nobody would notice, because nothing else in CI parses it. + +use std::path::Path; + +use zed_interfaces::manifest::Manifest; + +fn own_manifest() -> Manifest { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.zpkg.toml"); + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + Manifest::parse(&raw).expect("the repository manifest parses and validates") +} + +#[test] +fn the_repository_manifest_is_valid() { + let manifest = own_manifest(); + assert_eq!(manifest.package.name, "zed-interfaces"); + assert!( + manifest.is_polyglot(), + "the language slices are what make this package polyglot" + ); +} + +#[test] +fn every_language_slice_is_its_own_target_with_an_isolated_root() { + let manifest = own_manifest(); + for (target, dir, adapter) in [ + ("rust", "src/rust", "rust"), + ("dart", "src/dart", "dart"), + ("typescript", "src/ts", "node"), + ] { + let section = manifest + .targets + .get(target) + .unwrap_or_else(|| panic!("missing `[targets.{target}]`")); + assert_eq!(section.dir, dir, "target `{target}` moved"); + assert_eq!(section.adapter.as_deref(), Some(adapter)); + assert!( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(dir) + .is_dir(), + "target `{target}` points at `{dir}`, which does not exist" + ); + } +} + +#[test] +fn the_rust_slice_is_the_crate_that_publishes_to_crates_io() { + let manifest = own_manifest(); + let rust = manifest.targets.get("rust").expect("rust target"); + let native = rust + .native + .as_ref() + .expect("the Rust slice keeps the crates.io route"); + assert_eq!(native.package, "zed-interfaces"); + // A target that owned `dir = "."` could not carry this route at all, which + // is why the crate manifest lives in `src/rust/` rather than at the root. + assert_ne!(rust.dir, "."); + assert!( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("Cargo.toml") + .is_file(), + "the crate manifest must sit inside the Rust slice" + ); +} + +#[test] +fn selecting_a_target_yields_that_slice_alone() { + let manifest = own_manifest(); + assert_eq!( + manifest.target_subdir(Some("dart")).unwrap(), + Some("src/dart") + ); + assert_eq!( + manifest.target_subdir(Some("typescript")).unwrap(), + Some("src/ts") + ); + // An unpublished language must fail loudly rather than silently install the + // whole repository. + assert!(manifest.target_subdir(Some("swift")).is_err()); +} diff --git a/src/rust/tests/roundtrip.rs b/src/rust/tests/roundtrip.rs new file mode 100644 index 0000000..63145be --- /dev/null +++ b/src/rust/tests/roundtrip.rs @@ -0,0 +1,836 @@ +use zed_interfaces::ArtifactFormat; +use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; +use zed_interfaces::language::{Ecosystem, Language}; +use zed_interfaces::lockfile::{LockedPackage, Lockfile}; +use zed_interfaces::manifest::{Manifest, ManifestError}; +use zed_interfaces::paths::store_entry_rel; +use zed_interfaces::vcs::Vcs; + +const SAMPLE: &str = r#" +[package] +org = "acme" +name = "http-kit" +version = "1.2.0" +description = "Tiny HTTP helpers" +license = "MIT" + +[package.repository] +vcs = "git" +url = "https://github.com/acme/http-kit" + +[dependencies] +"acme/logkit" = "^0.3" + +[publish] +exclude = ["benches/**"] +smoke_test = "sh scripts/smoke.sh" + +[scripts] +test = "make test" +"#; + +#[test] +fn manifest_roundtrip() { + let m = Manifest::parse(SAMPLE).unwrap(); + assert_eq!(m.full_name(), "acme/http-kit"); + assert_eq!(m.package.repository.vcs, Vcs::Git); + assert_eq!(m.vcs_tag(), "v1.2.0"); + assert_eq!(m.publish.exclude, vec!["benches/**".to_string()]); + assert!(!m.publish.include_readme); + assert_eq!(m.scripts.test.as_deref(), Some("make test")); + + let serialized = m.to_toml_string().unwrap(); + let reparsed = Manifest::parse(&serialized).unwrap(); + assert_eq!(m, reparsed); +} + +#[test] +fn install_dir_defaults_and_overrides() { + // No [install] section -> the default dep dir. + assert_eq!( + Manifest::parse(SAMPLE).unwrap().modules_dir(), + "zed_modules" + ); + + // A configured dir relocates the tree and round-trips. + let with_dir = format!("{SAMPLE}\n[install]\ndir = \".vendor/.zed\"\n"); + let m = Manifest::parse(&with_dir).unwrap(); + assert_eq!(m.modules_dir(), ".vendor/.zed"); + assert_eq!(Manifest::parse(&m.to_toml_string().unwrap()).unwrap(), m); + + // Unsafe dirs are rejected. + for bad in ["/abs/path", "../escape", "a/../../b"] { + let src = format!("{SAMPLE}\n[install]\ndir = \"{bad}\"\n"); + assert!( + matches!( + Manifest::parse(&src), + Err(ManifestError::InvalidInstallDir(_, _)) + ), + "expected {bad} rejected" + ); + } +} + +#[test] +fn manifest_rejects_bad_input() { + let bad_org = SAMPLE.replace("org = \"acme\"", "org = \"Acme!\""); + assert!(matches!( + Manifest::parse(&bad_org), + Err(ManifestError::InvalidOrg(_)) + )); + + let bad_dep = SAMPLE.replace("\"acme/logkit\"", "\"logkit\""); + assert!(matches!( + Manifest::parse(&bad_dep), + Err(ManifestError::InvalidDependencyKey(_)) + )); + + let bad_version = SAMPLE.replace("version = \"1.2.0\"", "version = \"not-semver\""); + assert!(matches!( + Manifest::parse(&bad_version), + Err(ManifestError::InvalidVersion(_, _)) + )); +} + +#[test] +fn lockfile_roundtrip() { + let mut lock = Lockfile::default(); + lock.upsert(LockedPackage { + org: "acme".into(), + name: "http-kit".into(), + version: "1.2.0".into(), + sha256: "ab".repeat(32), + size: 4096, + format: ArtifactFormat::TarGz, + vcs_tag: "v1.2.0".into(), + vcs_commit: Some("deadbeef".into()), + source: "https://registry.zed-pkg.dev".into(), + }); + + let text = lock.to_toml_string().unwrap(); + assert!(text.contains("[[package]]")); + let reparsed = Lockfile::parse(&text).unwrap(); + assert_eq!(lock, reparsed); + assert!(reparsed.find("acme", "http-kit").is_some()); +} + +#[test] +fn excludes_respect_include_readme() { + let with_readme_stripped = effective_excludes(&[], false); + assert!(with_readme_stripped.iter().any(|p| p == "README*")); + assert!(with_readme_stripped.iter().any(|p| p == "tests/**")); + + let readme_kept = effective_excludes(&["extra/**".to_string()], true); + assert!(!readme_kept.iter().any(|p| p == "README*")); + assert!(readme_kept.iter().any(|p| p == "extra/**")); + + assert!(DEFAULT_EXCLUDES.contains(&".github/**")); + assert!(DEFAULT_EXCLUDES.contains(&".zed-pack/**")); + assert!(DEFAULT_EXCLUDES.contains(&"**/node_modules/**")); + assert!(DEFAULT_EXCLUDES.contains(&"**/.dart_tool/**")); + assert!(DEFAULT_EXCLUDES.contains(&"**/build/**")); + assert!(ALWAYS_INCLUDE.contains(&"LICENSE*")); + assert!(ALWAYS_INCLUDE.contains(&".zpkg.toml")); +} + +#[test] +fn store_paths_are_sharded() { + let sha = "abcdef0123".to_string() + &"0".repeat(54); + assert_eq!(store_entry_rel(&sha), format!("store/v1/ab/{sha}")); +} + +/// A polyglot package declares one subtree per ecosystem; consumers pick one. +const POLYGLOT: &str = r#" +[package] +org = "zedtest" +name = "polyglot-lib" +version = "1.0.0" + +[package.repository] +vcs = "git" +url = "https://github.com/zed-pkg-test/polyglot-lib" + +[targets.node] +dir = "node" + +[targets.python] +dir = "python" + +[targets.go] +dir = "go" +"#; + +#[test] +fn polyglot_targets_roundtrip_and_resolve() { + let m = Manifest::parse(POLYGLOT).unwrap(); + assert!(m.is_polyglot()); + assert_eq!(m.targets.len(), 3); + assert_eq!(m.target_subdir(Some("python")).unwrap(), Some("python")); + assert_eq!(m.target_subdir(Some("node")).unwrap(), Some("node")); + // Round-trips through TOML unchanged. + assert_eq!(Manifest::parse(&m.to_toml_string().unwrap()).unwrap(), m); +} + +#[test] +fn a_single_language_package_ignores_target_selection() { + // No [targets] => always the whole tree, even if a consumer asks for one. + // Existing packages must keep installing exactly as before. + let m = Manifest::parse(SAMPLE).unwrap(); + assert!(!m.is_polyglot()); + assert_eq!(m.target_subdir(None).unwrap(), None); + assert_eq!(m.target_subdir(Some("python")).unwrap(), None); +} + +#[test] +fn a_polyglot_package_without_a_request_yields_the_whole_tree() { + // A consumer that has not opted into a target still installs fine. + let m = Manifest::parse(POLYGLOT).unwrap(); + assert_eq!(m.target_subdir(None).unwrap(), None); +} + +#[test] +fn requesting_an_unpublished_target_is_an_error_listing_what_exists() { + let m = Manifest::parse(POLYGLOT).unwrap(); + let err = m + .target_subdir(Some("ruby")) + .expect_err("a target the package does not publish must not silently fall back"); + let msg = err.to_string(); + assert!(msg.contains("ruby"), "{msg}"); + assert!(msg.contains("zedtest/polyglot-lib"), "{msg}"); + // The message enumerates the real targets so the fix is obvious. + for target in ["go", "node", "python"] { + assert!(msg.contains(target), "expected `{target}` listed in: {msg}"); + } +} + +#[test] +fn target_dirs_and_names_are_validated() { + // `..` in a target dir would escape the package on install. + let escaping = POLYGLOT.replace(r#"dir = "python""#, r#"dir = "../../etc""#); + assert!(matches!( + Manifest::parse(&escaping), + Err(ManifestError::InvalidTarget(_, _)) + )); + + // Absolute dirs likewise. + let absolute = POLYGLOT.replace(r#"dir = "python""#, r#"dir = "/etc/passwd""#); + assert!(matches!( + Manifest::parse(&absolute), + Err(ManifestError::InvalidTarget(_, _)) + )); + + // Target names are slugs. + let bad_name = POLYGLOT.replace("[targets.node]", "[targets.\"Node JS\"]"); + assert!(matches!( + Manifest::parse(&bad_name), + Err(ManifestError::InvalidTarget(_, _)) + )); + + // And so is a consumer's requested target. + let bad_request = format!("{SAMPLE}\n[install]\ntarget = \"Python 3\"\n"); + assert!(matches!( + Manifest::parse(&bad_request), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let bad_published_name = POLYGLOT.replace( + "[targets.node]\ndir = \"node\"", + "[targets.node]\ndir = \"node\"\nname = \"Node SDK\"", + ); + assert!(matches!( + Manifest::parse(&bad_published_name), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let duplicate_dir = POLYGLOT.replace(r#"dir = "python""#, r#"dir = "node""#); + assert!(matches!( + Manifest::parse(&duplicate_dir), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let duplicate_name = POLYGLOT.replace( + "[targets.python]\ndir = \"python\"", + "[targets.python]\ndir = \"python\"\nname = \"polyglot-lib-node\"", + ); + assert!(matches!( + Manifest::parse(&duplicate_name), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let bad_adapter = POLYGLOT.replace( + "[targets.node]\ndir = \"node\"", + "[targets.node]\ndir = \"node\"\nadapter = \"npm\"", + ); + assert!(matches!( + Manifest::parse(&bad_adapter), + Err(ManifestError::InvalidTarget(_, _)) + )); +} + +#[test] +fn nested_target_dirs_are_allowed() { + // Real repos often nest, e.g. clients/go. + let nested = POLYGLOT.replace(r#"dir = "go""#, r#"dir = "clients/go""#); + let m = Manifest::parse(&nested).unwrap(); + assert_eq!(m.target_subdir(Some("go")).unwrap(), Some("clients/go")); +} + +#[test] +fn consumer_requested_target_is_read_from_the_install_section() { + let consumer = format!("{SAMPLE}\n[install]\ndir = \".vendor/.zed\"\ntarget = \"python\"\n"); + let m = Manifest::parse(&consumer).unwrap(); + assert_eq!(m.requested_target(), Some("python")); + assert_eq!(m.modules_dir(), ".vendor/.zed"); + assert_eq!(Manifest::parse(&m.to_toml_string().unwrap()).unwrap(), m); + + // Absent or blank = no request. + assert_eq!(Manifest::parse(SAMPLE).unwrap().requested_target(), None); + let blank = format!("{SAMPLE}\n[install]\ntarget = \" \"\n"); + assert_eq!(Manifest::parse(&blank).unwrap().requested_target(), None); +} + +/// The real shape: a client repo publishing one package per language. +const CLIENTS: &str = r#" +[package] +org = "fiducia" +name = "fiducia-clients" +version = "1.1.2" +description = "Fiducia API clients" + +[package.repository] +vcs = "git" +url = "https://github.com/fiducia-cloud/fiducia-clients" + +[targets.nodejs] +dir = "clients/ts" +adapter = "node" + +[targets.java] +dir = "clients/java" +adapter = "java" + +[targets.golang] +dir = "clients/go" +"#; + +#[test] +fn each_target_publishes_under_its_own_package_name() { + let m = Manifest::parse(CLIENTS).unwrap(); + assert_eq!( + m.target_package_name("nodejs").as_deref(), + Some("fiducia-clients-nodejs") + ); + assert_eq!( + m.target_package_name("java").as_deref(), + Some("fiducia-clients-java") + ); + assert_eq!( + m.target_package_name("golang").as_deref(), + Some("fiducia-clients-golang") + ); + assert_eq!(m.target_package_name("ruby"), None); + + // Deterministic, sorted fan-out list. + assert_eq!( + m.target_package_names(), + vec![ + ("golang".to_string(), "fiducia-clients-golang".to_string()), + ("java".to_string(), "fiducia-clients-java".to_string()), + ("nodejs".to_string(), "fiducia-clients-nodejs".to_string()), + ] + ); +} + +#[test] +fn an_explicit_target_name_overrides_the_suffix_convention() { + let custom = CLIENTS.replace( + "[targets.nodejs]\ndir = \"clients/ts\"", + "[targets.nodejs]\ndir = \"clients/ts\"\nname = \"fiducia-js-sdk\"", + ); + let m = Manifest::parse(&custom).unwrap(); + assert_eq!( + m.target_package_name("nodejs").as_deref(), + Some("fiducia-js-sdk") + ); + // Other targets keep the convention. + assert_eq!( + m.target_package_name("java").as_deref(), + Some("fiducia-clients-java") + ); +} + +#[test] +fn the_per_target_manifest_is_a_standalone_single_language_package() { + let base = Manifest::parse(CLIENTS).unwrap(); + let java = base + .manifest_for_target("java") + .expect("java target exists"); + + // It is its own package, sharing org/version/repo with the parent. + assert_eq!(java.package.name, "fiducia-clients-java"); + assert_eq!(java.package.org, "fiducia"); + assert_eq!(java.package.version, "1.1.2"); + assert_eq!(java.full_name(), "fiducia/fiducia-clients-java"); + assert_eq!( + java.package.repository.url, base.package.repository.url, + "the artifact still points back at the source repo" + ); + + // It is NOT itself polyglot — the slice is single-language by construction, + // so a consumer can never recurse into another fan-out. + assert!(!java.is_polyglot()); + assert!(java.targets.is_empty()); + + // It carries the ecosystem wiring its consumers need. + assert_eq!(java.install.adapter.as_deref(), Some("java")); + assert_eq!( + base.manifest_for_target("nodejs") + .unwrap() + .install + .adapter + .as_deref(), + Some("node") + ); + // A target with no adapter inherits the base (here: none set). + assert_eq!( + base.manifest_for_target("golang").unwrap().install.adapter, + None + ); + + // The derived manifest is valid on its own and round-trips. + java.validate().expect("derived manifest must be valid"); + assert_eq!( + Manifest::parse(&java.to_toml_string().unwrap()).unwrap(), + java + ); + + // The description makes the language obvious in registry listings. + assert_eq!( + java.package.description.as_deref(), + Some("Fiducia API clients (java)") + ); + + assert!(base.manifest_for_target("ruby").is_none()); +} + +#[test] +fn derived_target_names_must_still_be_valid_package_names() { + // The suffix convention has to produce a legal slug, or publish would + // emit an unusable package name. + let m = Manifest::parse(CLIENTS).unwrap(); + for (_, name) in m.target_package_names() { + assert!( + zed_interfaces::manifest::is_slug(&name), + "derived name `{name}` is not a valid package name" + ); + } +} + +// --- language / ecosystem tagging ----------------------------------------- + +/// A repo naming its targets the way the published packages read — the +/// colloquial `nodejs` / `golang` a human recalls — rather than the short +/// tokens project inference produces. +const COLLOQUIAL: &str = r#" +[package] +org = "fiducia" +name = "fiducia-clients" +version = "1.1.2" + +[package.repository] +vcs = "git" +url = "https://github.com/fiducia-cloud/fiducia-clients" + +[targets.nodejs] +dir = "clients/ts" + +[targets.golang] +dir = "clients/go" + +[targets.java] +dir = "clients/java" + +[targets.kotlin] +dir = "clients/kotlin" + +[targets.rust-wasm] +dir = "clients/rust-wasm" +ecosystem = "npm" +"#; + +#[test] +fn a_project_inferred_as_node_resolves_a_nodejs_target() { + // The decisive case for synonym resolution. Project inference yields + // `node`/`go` from package.json/go.mod, but these packages publish as + // `-nodejs`/`-golang` because that is what the names should read. Without + // synonym matching every such consumer would hit "publishes no such + // target" while the package ships exactly what they need. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + assert_eq!(m.target_subdir(Some("node")).unwrap(), Some("clients/ts")); + assert_eq!(m.target_subdir(Some("go")).unwrap(), Some("clients/go")); + // …and the spelling the author used keeps working too. + assert_eq!(m.target_subdir(Some("nodejs")).unwrap(), Some("clients/ts")); + assert_eq!(m.target_subdir(Some("golang")).unwrap(), Some("clients/go")); + // As do the ecosystem's own near-synonyms. + assert_eq!( + m.target_subdir(Some("typescript")).unwrap(), + Some("clients/ts") + ); + assert_eq!(m.target_subdir(Some("ts")).unwrap(), Some("clients/ts")); +} + +#[test] +fn synonym_resolution_does_not_collapse_distinct_languages() { + // Java and Kotlin share an ecosystem but are separate packages; asking for + // one must never hand back the other. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + assert_eq!(m.target_subdir(Some("java")).unwrap(), Some("clients/java")); + assert_eq!( + m.target_subdir(Some("kotlin")).unwrap(), + Some("clients/kotlin") + ); + // A JVM language the repo does not publish is still an error, not a + // silent substitution of a sibling JVM target. + assert!(m.target_subdir(Some("scala")).is_err()); +} + +#[test] +fn an_exact_target_key_wins_over_a_synonym() { + // With both `node` and `nodejs` declared, `node` must mean the `node` one. + let both = COLLOQUIAL.replace( + "[targets.golang]\ndir = \"clients/go\"", + "[targets.node]\ndir = \"clients/js-legacy\"", + ); + let m = Manifest::parse(&both).unwrap(); + assert_eq!( + m.target_subdir(Some("node")).unwrap(), + Some("clients/js-legacy") + ); + assert_eq!(m.target_subdir(Some("nodejs")).unwrap(), Some("clients/ts")); +} + +#[test] +fn an_unknown_target_is_still_an_error_after_synonym_expansion() { + // Synonyms must widen what resolves, never turn a real mistake into a + // silent whole-tree install. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + let err = m + .target_subdir(Some("cobol")) + .expect_err("unknown language"); + assert!(err.to_string().contains("cobol"), "{err}"); +} + +#[test] +fn each_published_target_declares_its_own_language_and_ecosystem() { + // This is what the consumer-side guard reads: the artifact for `-java` must + // say `jvm` so an npm-only project can be told it is the wrong one. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + + let java = m.manifest_for_target("java").unwrap(); + assert_eq!(java.package.name, "fiducia-clients-java"); + assert_eq!(java.package.language, Language::Java); + assert_eq!(java.package.ecosystem(), Ecosystem::Jvm); + + let node = m.manifest_for_target("nodejs").unwrap(); + assert_eq!(node.package.name, "fiducia-clients-nodejs"); + assert_eq!(node.package.language, Language::Nodejs); + assert_eq!(node.package.ecosystem(), Ecosystem::Npm); + + // Kotlin shares Java's ecosystem while keeping its own name — the reason + // language and ecosystem are separate axes. + let kotlin = m.manifest_for_target("kotlin").unwrap(); + assert_eq!(kotlin.package.name, "fiducia-clients-kotlin"); + assert_eq!(kotlin.package.language, Language::Kotlin); + assert_eq!(kotlin.package.ecosystem(), Ecosystem::Jvm); + + // Each slice round-trips as a standalone single-language manifest. + for target in ["java", "nodejs", "kotlin"] { + let derived = m.manifest_for_target(target).unwrap(); + let reparsed = Manifest::parse(&derived.to_toml_string().unwrap()).unwrap(); + assert_eq!(reparsed, derived, "{target} manifest must round-trip"); + assert!(!reparsed.is_polyglot()); + } +} + +#[test] +fn an_explicit_target_ecosystem_overrides_the_language_default() { + // rust-wasm is Rust source consumed by a JS bundler, so the package must be + // gated as npm even though the language is a Rust dialect. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + let wasm = m.manifest_for_target("rust-wasm").unwrap(); + assert_eq!(wasm.package.name, "fiducia-clients-rust-wasm"); + assert_eq!(wasm.package.language, Language::RustWasm); + assert_eq!(wasm.package.ecosystem(), Ecosystem::Npm); +} + +#[test] +fn a_target_key_that_is_not_a_known_language_stays_ungated() { + // Arbitrary slugs remain legal target names (pre-existing behavior). Such a + // package publishes and installs, it just carries no ecosystem claim, so + // the guard cannot and must not reject it anywhere. + let custom = POLYGLOT.replace( + "[targets.go]\ndir = \"go\"", + "[targets.wasm3]\ndir = \"w3\"", + ); + let m = Manifest::parse(&custom).unwrap(); + let derived = m.manifest_for_target("wasm3").unwrap(); + assert_eq!(derived.package.name, "polyglot-lib-wasm3"); + assert!(derived.package.language.is_default()); + assert_eq!(derived.package.ecosystem(), Ecosystem::Universal); +} + +#[test] +fn untagged_manifests_keep_their_meaning() { + // Every manifest written before language tagging existed must parse and + // stay ungated — this is the backwards-compatibility contract. + let m = Manifest::parse(SAMPLE).unwrap(); + assert!(m.package.language.is_default()); + assert_eq!(m.package.ecosystem(), Ecosystem::Universal); + // …and must not gain the fields when serialized back out. + let toml = m.to_toml_string().unwrap(); + assert!(!toml.contains("language"), "{toml}"); + assert!(!toml.contains("ecosystem"), "{toml}"); +} + +#[test] +fn include_readme_also_keeps_the_changelog_registries_ask_for() { + // `dart pub publish` fails a package outright for a missing CHANGELOG, and + // `publish.exclude` can only add patterns — so if the default excludes strip + // it, no repo can ship one. A package that opted into its README wants its + // changelog too. + let stripped = effective_excludes(&[], false); + assert!(stripped.iter().any(|p| p.starts_with("README"))); + assert!(stripped.iter().any(|p| p.starts_with("CHANGELOG"))); + + let kept = effective_excludes(&[], true); + assert!( + !kept.iter().any(|p| p.starts_with("README")), + "include_readme must un-exclude READMEs" + ); + assert!( + !kept.iter().any(|p| p.starts_with("CHANGELOG")), + "include_readme must un-exclude CHANGELOGs: {kept:?}" + ); + // Everything else still goes. + assert!(kept.iter().any(|p| p.contains("test"))); +} + +#[test] +fn a_synonym_request_prefers_the_target_named_after_the_language() { + // shared-auth-clients ships separate TypeScript and JavaScript clients, so + // two targets are both `nodejs`. A consumer inferred as `node` must land on + // the canonical one, not on whichever key happens to sort first. + let two_node = r#" +[package] +org = "shared-auth" +name = "shared-auth-clients" +version = "0.1.0" + +[package.repository] +url = "https://github.com/shared-auth/shared-auth-clients" + +[targets.javascript] +dir = "clients/js" + +[targets.nodejs] +dir = "clients/ts" +"#; + let m = Manifest::parse(two_node).unwrap(); + // `javascript` sorts before `nodejs`, so a naive first-match would pick it. + assert_eq!(m.resolve_target_key("node"), Some("nodejs")); + assert_eq!(m.target_subdir(Some("node")).unwrap(), Some("clients/ts")); + // An exact key still wins over the canonical preference. + assert_eq!(m.resolve_target_key("javascript"), Some("javascript")); + assert_eq!( + m.target_subdir(Some("javascript")).unwrap(), + Some("clients/js") + ); + // And a single-match repo is unaffected. + let one = two_node.replace("[targets.javascript]\ndir = \"clients/js\"\n", ""); + let m1 = Manifest::parse(&one).unwrap(); + assert_eq!(m1.resolve_target_key("js"), Some("nodejs")); +} + +// --- Audit chain (tamper-evident log) ----------------------------------- + +/// New chain fields must not break deserialization of a body produced by a +/// server that predates them: `seq` defaults to 0 and the hashes to empty. +#[test] +fn audit_entry_from_a_pre_chain_server_still_parses() { + let legacy = r#"{ + "at": "2026-07-25T00:00:00Z", + "action": "publish", + "subject": "acme/http-kit@1.0.0", + "actor_token_name": "ci", + "actor_role": "publisher" + }"#; + let entry: zed_interfaces::registry::AuditEntry = serde_json::from_str(legacy).unwrap(); + assert_eq!(entry.seq, 0); + assert_eq!(entry.entry_hash, ""); + assert_eq!(entry.prev_hash, None); + assert_eq!(entry.subject, "acme/http-kit@1.0.0"); +} + +/// A full entry round-trips, and the empty chain fields stay off the wire so +/// the serialized shape is unchanged for servers that do not set them. +#[test] +fn audit_entry_roundtrips_and_omits_empty_chain_fields() { + use zed_interfaces::registry::{AuditAction, AuditEntry}; + let entry = AuditEntry { + at: "2026-07-25T00:00:00Z".to_string(), + action: "yank".to_string(), + action_kind: Some(AuditAction::Yank), + subject: "acme/http-kit@1.0.0".to_string(), + actor_token_name: "ci".to_string(), + actor_role: "owner".to_string(), + detail: None, + seq: 7, + entry_hash: "ab".repeat(32), + prev_hash: Some("cd".repeat(32)), + }; + let json = serde_json::to_string(&entry).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), entry); + + let bare = AuditEntry { + entry_hash: String::new(), + prev_hash: None, + ..entry + }; + let value: serde_json::Value = serde_json::to_value(&bare).unwrap(); + assert!( + value.get("entry_hash").is_none(), + "empty hash must be omitted" + ); + assert!( + value.get("prev_hash").is_none(), + "absent prev must be omitted" + ); +} + +/// The preimage must be injective. A separator-joined encoding would let a +/// crafted field (a token literally named `x|publish`) shift boundaries and +/// collide with a different entry; length prefixes must prevent that. +#[test] +fn audit_preimage_resists_field_injection() { + use zed_interfaces::registry::{AuditChainInput, audit_chain_preimage}; + + let base = AuditChainInput { + org_id: "org", + seq: 1, + at: "t", + action: "publish", + subject: "s", + actor_token_id: Some("tok"), + actor_token_name: "ci", + actor_role: "owner", + detail: Some("d"), + prev_hash: "prev", + }; + let digest = |i: &AuditChainInput| audit_chain_preimage(i); + + // Same characters, different field boundaries, must not collide. + let honest = digest(&AuditChainInput { + actor_role: "owner", + detail: None, + ..base + }); + let shifted = digest(&AuditChainInput { + actor_role: "own", + detail: Some("er"), + ..base + }); + assert_ne!(honest, shifted, "field boundaries must be unambiguous"); + + // A value that mimics the length-prefix syntax cannot fake a layout. + let sneaky = digest(&AuditChainInput { + actor_token_name: "5:owner", + actor_role: "x", + detail: None, + ..base + }); + assert_ne!(honest, sneaky); + + // Every distinguishing field must actually participate in the digest. + let reference = digest(&base); + let variants = [ + ( + "org_id", + AuditChainInput { + org_id: "OTHER", + ..base + }, + ), + ("seq", AuditChainInput { seq: 2, ..base }), + ("at", AuditChainInput { at: "T", ..base }), + ( + "action", + AuditChainInput { + action: "yank", + ..base + }, + ), + ( + "subject", + AuditChainInput { + subject: "S", + ..base + }, + ), + ( + "actor_token_id", + AuditChainInput { + actor_token_id: Some("TOK"), + ..base + }, + ), + ( + "actor_token_name", + AuditChainInput { + actor_token_name: "CI", + ..base + }, + ), + ( + "actor_role", + AuditChainInput { + actor_role: "reader", + ..base + }, + ), + ( + "detail", + AuditChainInput { + detail: Some("D"), + ..base + }, + ), + ( + "prev_hash", + AuditChainInput { + prev_hash: "PREV", + ..base + }, + ), + ]; + for (field, variant) in variants { + assert_ne!( + reference, + digest(&variant), + "changing {field} must change the preimage" + ); + } + + // None and the empty string are deliberately the same absence. + assert_eq!( + digest(&AuditChainInput { + actor_token_id: None, + detail: None, + ..base + }), + digest(&AuditChainInput { + actor_token_id: Some(""), + detail: Some(""), + ..base + }), + ); +} diff --git a/src/rust/vcs.rs b/src/rust/vcs.rs new file mode 100644 index 0000000..d7b337f --- /dev/null +++ b/src/rust/vcs.rs @@ -0,0 +1,72 @@ +use std::fmt; +use std::str::FromStr; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Version-control systems a package's source repository can live on. +/// +/// zed-pkg is VCS-agnostic by design: what gets installed is always a +/// registry artifact, and the VCS is where provenance (tags) is anchored. +/// Authors must create a matching tag on their declared backing repo +/// (GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure +/// DevOps, CodeCommit, Radicle, or self-hosted) before publishing. +/// +/// `jj` and Sapling are git-compatible and push to git remotes, so their +/// provenance is verified through git tags. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum Vcs { + #[default] + Git, + Hg, + Jj, + Sapling, + Fossil, + Pijul, +} + +impl Vcs { + pub fn as_str(&self) -> &'static str { + match self { + Vcs::Git => "git", + Vcs::Hg => "hg", + Vcs::Jj => "jj", + Vcs::Sapling => "sapling", + Vcs::Fossil => "fossil", + Vcs::Pijul => "pijul", + } + } + + /// True for systems whose tags live in git ref namespaces (git itself + /// plus the git-compatible layers), so git-based tag verification works. + pub fn uses_git_tags(&self) -> bool { + matches!(self, Vcs::Git | Vcs::Jj | Vcs::Sapling) + } +} + +impl fmt::Display for Vcs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("unknown vcs `{0}` (expected git, hg, jj, sapling, fossil, or pijul)")] +pub struct ParseVcsError(pub String); + +impl FromStr for Vcs { + type Err = ParseVcsError; + + fn from_str(s: &str) -> Result { + match s { + "git" => Ok(Vcs::Git), + "hg" => Ok(Vcs::Hg), + "jj" => Ok(Vcs::Jj), + "sapling" => Ok(Vcs::Sapling), + "fossil" => Ok(Vcs::Fossil), + "pijul" => Ok(Vcs::Pijul), + other => Err(ParseVcsError(other.to_string())), + } + } +} diff --git a/src/rust/version.rs b/src/rust/version.rs new file mode 100644 index 0000000..70a0f9f --- /dev/null +++ b/src/rust/version.rs @@ -0,0 +1,380 @@ +//! Polyglot version handling (zed-docs issue #3). +//! +//! zed-pkg standardizes on **semver** as its resolution algebra, but the +//! ecosystems it federates label releases inconsistently: Go tags `v1.2.3` +//! (and `+incompatible`), Python uses PEP 440 (`1.2.3rc1`, `.postN`), and many +//! tools use calendar versions (`2026.07.24`). A package declares its +//! [`VersionScheme`] so `package.version` is validated the right way, and the +//! resolver normalizes foreign tag spellings to a total order at the edges. +//! +//! The scheme is a property of the *published package*, not of the consumer's +//! requirement — a dependency on an opaque-versioned package is written as an +//! exact string, everything else as a semver range. + +use schemars::JsonSchema; +use semver::{Version, VersionReq}; +use serde::{Deserialize, Serialize}; + +/// How a package's `version` string (and its published tags) should be +/// interpreted. Defaults to [`VersionScheme::Semver`], which covers the vast +/// majority of modern ecosystems. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum VersionScheme { + /// Semantic Versioning. `package.version` must be valid semver; ranges + /// (`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version. + #[default] + Semver, + /// Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver + /// total order (leading zeros dropped, padded to major.minor.patch) so the + /// same range algebra applies. See [`normalize_calver`]. + Calver, + /// Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra: + /// a requirement must match a published version **exactly**. + Opaque, +} + +impl VersionScheme { + pub fn as_str(&self) -> &'static str { + match self { + VersionScheme::Semver => "semver", + VersionScheme::Calver => "calver", + VersionScheme::Opaque => "opaque", + } + } + + /// True for the default scheme (semver); lets manifests omit the field. + pub fn is_default(&self) -> bool { + matches!(self, VersionScheme::Semver) + } + + /// Parse a scheme name, defaulting to semver for empty/unknown input so a + /// stored value can never wedge a read. + pub fn from_str_lenient(s: &str) -> VersionScheme { + match s { + "calver" => VersionScheme::Calver, + "opaque" => VersionScheme::Opaque, + _ => VersionScheme::Semver, + } + } + + /// Validate that `version` is a legal identity under this scheme. + pub fn validate_version(&self, version: &str) -> Result<(), String> { + match self { + VersionScheme::Semver => Version::parse(version) + .map(|_| ()) + .map_err(|e| e.to_string()), + VersionScheme::Calver => normalize_calver(version) + .map(|_| ()) + .ok_or_else(|| format!("`{version}` is not a calendar version (e.g. 2026.07.24)")), + VersionScheme::Opaque => { + if version.trim().is_empty() { + Err("opaque version must be non-empty".to_string()) + } else { + Ok(()) + } + } + } + } +} + +/// Normalize a calendar version to a semver string with a total order. +/// +/// `2026.07.24` → `2026.7.24`, `2026.07` → `2026.7.0`, `2026` → `2026.0.0`. +/// Leading zeros are stripped (semver forbids them); 1–3 dot-separated numeric +/// segments are accepted and padded to `major.minor.patch`. An optional +/// `-prerelease` suffix is preserved. Returns `None` if the shape isn't +/// calendar-like. +pub fn normalize_calver(raw: &str) -> Option { + let raw = raw.strip_prefix('v').unwrap_or(raw); + let (core, pre) = match raw.split_once('-') { + Some((core, pre)) if !pre.is_empty() => (core, Some(pre)), + _ => (raw, None), + }; + let mut parts: Vec = Vec::new(); + for seg in core.split('.') { + if seg.is_empty() || !seg.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + parts.push(seg.parse().ok()?); + } + if parts.is_empty() || parts.len() > 3 { + return None; + } + while parts.len() < 3 { + parts.push(0); + } + let core = format!("{}.{}.{}", parts[0], parts[1], parts[2]); + match pre { + Some(pre) => Some(format!("{core}-{pre}")), + None => Some(core), + } +} + +/// Drop Go's `+incompatible` (and any other build metadata) so a bare Go tag +/// like `v2.0.0+incompatible` parses. Build metadata is ignored by semver +/// precedence anyway, but we strip it for a clean identity. +fn normalize_go(raw: &str) -> Option { + let raw = raw.strip_prefix('v').unwrap_or(raw); + let core = raw.split('+').next().unwrap_or(raw); + Version::parse(core).ok().map(|v| v.to_string()) +} + +/// Map a PEP 440 pre/dev/post spelling onto a semver prerelease so ordering +/// stays sane: `1.2.3rc1` → `1.2.3-rc.1`, `1.2a1` → `1.2.0-a.1`, +/// `1.2.3.dev4` → `1.2.3-dev.4`. Post-releases (`1.2.3.post1`) become build +/// metadata (`1.2.3+post.1`) since semver has no "after the release" ordering. +/// Conservative by design — only the common shapes; returns `None` otherwise. +fn normalize_pep440(raw: &str) -> Option { + let raw = raw.strip_prefix('v').unwrap_or(raw).to_ascii_lowercase(); + // Split the numeric release from the first non-digit/non-dot marker. + let marker = raw.find(|c: char| c.is_ascii_alphabetic())?; + let (release, suffix) = raw.split_at(marker); + let release = release.trim_end_matches('.'); + let mut nums: Vec = Vec::new(); + for seg in release.split('.') { + if seg.is_empty() { + continue; + } + nums.push(seg.parse().ok()?); + } + while nums.len() < 3 { + nums.push(0); + } + if nums.len() > 3 { + return None; + } + let core = format!("{}.{}.{}", nums[0], nums[1], nums[2]); + + // suffix is e.g. "rc1", "a1", "b2", "dev4", "post1" (dots already gone). + let suffix = suffix.trim_start_matches('.'); + let split = suffix + .find(|c: char| c.is_ascii_digit()) + .unwrap_or(suffix.len()); + let (label, num) = suffix.split_at(split); + let num = if num.is_empty() { "0" } else { num }; + if num.bytes().any(|b| !b.is_ascii_digit()) { + return None; + } + let (label, kind) = match label { + "a" | "alpha" => ("alpha", '-'), + "b" | "beta" => ("beta", '-'), + "c" | "rc" | "pre" | "preview" => ("rc", '-'), + "dev" => ("dev", '-'), + "post" | "rev" | "r" => ("post", '+'), + _ => return None, + }; + Some(format!("{core}{kind}{label}.{num}")) +} + +/// Parse a version string to a comparable semver [`Version`], tolerating the +/// common foreign spellings (bare `v`, calendar versions, Go `+incompatible`, +/// a subset of PEP 440). Build metadata is dropped so `2.0.0+incompatible` and +/// `2.0.0` share one identity (semver precedence ignores build metadata). +/// Returns `None` for genuinely opaque strings. +pub fn parse_version(raw: &str) -> Option { + let mut version = parse_version_inner(raw)?; + version.build = semver::BuildMetadata::EMPTY; + Some(version) +} + +fn parse_version_inner(raw: &str) -> Option { + if let Ok(v) = Version::parse(raw) { + return Some(v); + } + if let Some(stripped) = raw.strip_prefix('v') + && let Ok(v) = Version::parse(stripped) + { + return Some(v); + } + if let Some(go) = normalize_go(raw) + && let Ok(v) = Version::parse(&go) + { + return Some(v); + } + if let Some(cal) = normalize_calver(raw) + && let Ok(v) = Version::parse(&cal) + { + return Some(v); + } + if let Some(pep) = normalize_pep440(raw) + && let Ok(v) = Version::parse(&pep) + { + return Some(v); + } + None +} + +/// A resolved dependency requirement. Semver and calendar requirements are +/// ranges; opaque requirements are exact strings. +#[derive(Debug, Clone, PartialEq)] +pub enum Requirement { + Range(VersionReq), + Exact(String), +} + +impl Requirement { + /// Interpret a manifest requirement string. A valid semver range becomes + /// [`Requirement::Range`]; anything else is an exact (opaque) match. + /// + /// npm-style whitespace-AND ranges (`>=1.2 <2`) are accepted by + /// normalizing the whitespace between comparators to the comma the semver + /// crate expects — but only as a fallback, so an opaque tag containing a + /// space is still treated as exact when the normalized form is not a valid + /// range. + pub fn parse(input: &str) -> Requirement { + if let Ok(req) = VersionReq::parse(input) { + return Requirement::Range(req); + } + if input.split_whitespace().count() > 1 { + let normalized = input.split_whitespace().collect::>().join(","); + if let Ok(req) = VersionReq::parse(&normalized) { + return Requirement::Range(req); + } + } + Requirement::Exact(input.to_string()) + } + + /// Does a published version string satisfy this requirement? + pub fn matches(&self, version: &str) -> bool { + match self { + Requirement::Range(req) => parse_version(version).is_some_and(|v| req.matches(&v)), + Requirement::Exact(want) => want == version, + } + } + + /// Reject requirement strings that *look* like semver ranges but fail to + /// parse (e.g. `^1.x.y`, `>= banana`). Without this, a typo'd range would + /// silently degrade to an opaque exact-match tag and never resolve. + /// Strings with no range operators are legitimate opaque tags and pass. + pub fn validate(input: &str) -> Result<(), String> { + let looks_like_range = input.starts_with(['^', '~', '>', '<', '=']) + || input.contains(['*', ',']) + || input.split_whitespace().count() > 1; + if !looks_like_range { + return Ok(()); + } + match Self::parse(input) { + Requirement::Range(_) => Ok(()), + Requirement::Exact(_) => Err(format!( + "`{input}` looks like a version range but is not a valid one" + )), + } + } +} + +/// Pick the version string satisfying `req` from `versions`. +/// +/// For a range: the highest stable (non-prerelease) parseable version that +/// matches, returned in its **original** spelling so the store address and tag +/// stay faithful to what the publisher tagged. For an exact requirement: the +/// verbatim match if present. +pub fn resolve<'a>(req: &Requirement, versions: &'a [String]) -> Option<&'a str> { + match req { + Requirement::Exact(want) => versions + .iter() + .find(|v| v.as_str() == want) + .map(String::as_str), + Requirement::Range(range) => versions + .iter() + .filter_map(|v| parse_version(v).map(|parsed| (parsed, v))) + .filter(|(parsed, _)| parsed.pre.is_empty() && range.matches(parsed)) + .max_by(|a, b| a.0.cmp(&b.0)) + .map(|(_, original)| original.as_str()), + } +} + +/// Sort version strings newest-first, tolerant of foreign spellings. +/// Unparseable (opaque) versions sort after parseable ones, lexically. +pub fn sort_desc(versions: &mut [String]) { + versions.sort_by(|a, b| match (parse_version(a), parse_version(b)) { + (Some(va), Some(vb)) => vb.cmp(&va), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => b.cmp(a), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calver_normalizes_to_semver_order() { + assert_eq!(normalize_calver("2026.07.24").as_deref(), Some("2026.7.24")); + assert_eq!(normalize_calver("2026.07").as_deref(), Some("2026.7.0")); + assert_eq!(normalize_calver("2026").as_deref(), Some("2026.0.0")); + assert_eq!(normalize_calver("v2026.01.02").as_deref(), Some("2026.1.2")); + assert_eq!(normalize_calver("not-a-date"), None); + assert_eq!(normalize_calver("1.2.3.4"), None); + + // Total order holds across months and days. + let older = parse_version("2026.07.24").unwrap(); + let newer = parse_version("2026.08.01").unwrap(); + assert!(newer > older); + } + + #[test] + fn parse_version_tolerates_foreign_spellings() { + assert_eq!(parse_version("1.2.3").unwrap().to_string(), "1.2.3"); + assert_eq!(parse_version("v1.2.3").unwrap().to_string(), "1.2.3"); + // Go +incompatible is stripped to a clean identity. + assert_eq!( + parse_version("v2.0.0+incompatible").unwrap().to_string(), + "2.0.0" + ); + // PEP 440 pre-releases map onto semver prereleases (order preserved). + assert_eq!(parse_version("1.2.3rc1").unwrap().to_string(), "1.2.3-rc.1"); + assert_eq!(parse_version("1.2a1").unwrap().to_string(), "1.2.0-alpha.1"); + assert!(parse_version("1.2.3rc1").unwrap() < parse_version("1.2.3").unwrap()); + assert_eq!(parse_version("legacy-api"), None); + } + + #[test] + fn resolve_range_picks_max_satisfying_stable() { + let versions: Vec = ["1.0.0", "1.2.3", "1.3.0-alpha.1", "2.0.0"] + .iter() + .map(|s| s.to_string()) + .collect(); + let req = Requirement::parse("^1.2"); + assert_eq!(resolve(&req, &versions), Some("1.2.3")); + assert_eq!(resolve(&Requirement::parse("^3"), &versions), None); + } + + #[test] + fn resolve_calver_range() { + let versions: Vec = ["2025.12.01", "2026.07.24", "2026.08.01"] + .iter() + .map(|s| s.to_string()) + .collect(); + // A semver range over calendar versions: pick the newest in 2026. + let req = Requirement::parse(">=2026.0.0, <2027.0.0"); + assert!(matches!(req, Requirement::Range(_))); + assert_eq!(resolve(&req, &versions), Some("2026.08.01")); + } + + #[test] + fn opaque_requires_exact_match() { + let versions: Vec = ["release-candidate-1", "legacy-api", "beta"] + .iter() + .map(|s| s.to_string()) + .collect(); + let req = Requirement::parse("legacy-api"); + assert!(matches!(req, Requirement::Exact(_))); + assert_eq!(resolve(&req, &versions), Some("legacy-api")); + assert_eq!(resolve(&Requirement::parse("nope"), &versions), None); + } + + #[test] + fn scheme_validation() { + assert!(VersionScheme::Semver.validate_version("1.2.3").is_ok()); + assert!( + VersionScheme::Semver + .validate_version("2026.07.24") + .is_err() + ); + assert!(VersionScheme::Calver.validate_version("2026.07.24").is_ok()); + assert!(VersionScheme::Opaque.validate_version("legacy-api").is_ok()); + assert!(VersionScheme::Opaque.validate_version(" ").is_err()); + } +} diff --git a/src/ts/api-error.ts b/src/ts/api-error.ts new file mode 100644 index 0000000..9ae952d --- /dev/null +++ b/src/ts/api-error.ts @@ -0,0 +1,10 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/api-error.json + +/** Error body returned with any non-2xx status. */ +export interface ApiError { + /** Stable machine-readable code, e.g. `not_found`, `sha256_mismatch`, `tag_not_found`, `unauthorized`, `org_taken`. */ + readonly code: string; + readonly message: string; +} diff --git a/src/ts/audit-integrity-response.ts b/src/ts/audit-integrity-response.ts new file mode 100644 index 0000000..d4d687a --- /dev/null +++ b/src/ts/audit-integrity-response.ts @@ -0,0 +1,17 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/audit-integrity-response.json + +/** The result of walking an org's audit chain end to end. */ +export interface AuditIntegrityResponse { + readonly entries_checked: number; + /** The `seq` where verification first failed, if any. */ + readonly first_bad_seq?: number | null; + /** The newest entry's hash — an anchor an operator can record externally so that later truncation of the whole tail is also detectable. */ + readonly head_hash?: string | null; + /** True only when every entry's hash recomputes and every link matches. */ + readonly intact: boolean; + readonly org: string; + /** Machine-readable failure kind: `hash_mismatch` (an entry was edited), `broken_link` (an entry's `prev_hash` does not match its predecessor), or `sequence_gap` (an entry was deleted). */ + readonly problem?: string | null; +} diff --git a/src/ts/audit-log-response.ts b/src/ts/audit-log-response.ts new file mode 100644 index 0000000..94a4b86 --- /dev/null +++ b/src/ts/audit-log-response.ts @@ -0,0 +1,45 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/audit-log-response.json + +/** + * A state-changing action recorded in an org's audit log. Reads are never audited — only mutations of published state and of the namespace itself, so the log answers "who changed what" without drowning in traffic. + * - `publish`: A version was published. + * - `yank`: A version was yanked (hidden from fresh resolution). + * - `unyank`: A previously yanked version was restored. + * - `org_claim`: The org namespace was claimed. + */ +export type AuditAction = "publish" | "yank" | "unyank" | "org_claim"; + +/** Every `AuditAction` value, in schema order — for validation and pickers. */ +export const AUDIT_ACTION_VALUES = ["publish", "yank", "unyank", "org_claim"] as const; + +/** One audit-log record. The actor is identified by the *token* that acted — its name and role, never its secret — which is the identity a registry actually has (zed-docs issue #7 governance). */ +export interface AuditEntry { + /** Raw action string; `action_kind` is the parsed form when recognized. */ + readonly action: string; + /** Parsed action, absent when this server build doesn't recognize it. */ + readonly action_kind?: AuditAction | null; + /** The acting token's role (`owner`/`publisher`/`reader`, or `admin` for unscoped tokens). */ + readonly actor_role: string; + /** Human-readable name of the token that acted. */ + readonly actor_token_name: string; + /** RFC 3339 timestamp of the action. */ + readonly at: string; + /** Extra context, e.g. the artifact sha256 for a publish. */ + readonly detail?: string | null; + /** `sha256(audit_chain_preimage(..))` for this entry, lowercase hex. Empty from a pre-chain server. */ + readonly entry_hash?: string; + /** The previous entry's `entry_hash`; `None` for the first entry in an org's chain. Linking each entry to its predecessor is what makes a silent deletion or edit detectable. */ + readonly prev_hash?: string | null; + /** Position in the org's append-only chain, starting at 1. Gaps mean entries were deleted. Defaults to 0 when read from a server that predates the chain. */ + readonly seq?: number; + /** What was acted on, e.g. `acme/http-kit@1.2.0` or the org slug. */ + readonly subject: string; +} + +export interface AuditLogResponse { + /** Most recent first. */ + readonly entries: readonly AuditEntry[]; + readonly org: string; +} diff --git a/src/ts/claim-org-request.ts b/src/ts/claim-org-request.ts new file mode 100644 index 0000000..1f5fc7c --- /dev/null +++ b/src/ts/claim-org-request.ts @@ -0,0 +1,7 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/claim-org-request.json + +export interface ClaimOrgRequest { + readonly slug: string; +} diff --git a/src/ts/claim-org-response.ts b/src/ts/claim-org-response.ts new file mode 100644 index 0000000..a774aad --- /dev/null +++ b/src/ts/claim-org-response.ts @@ -0,0 +1,9 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/claim-org-response.json + +export interface ClaimOrgResponse { + /** False when the caller already owned the org. */ + readonly created: boolean; + readonly slug: string; +} diff --git a/src/ts/common.ts b/src/ts/common.ts new file mode 100644 index 0000000..993d2ca --- /dev/null +++ b/src/ts/common.ts @@ -0,0 +1,12 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: types shared by more than one schema + +export interface PackageSummary { + readonly description?: string | null; + readonly latest?: string | null; + readonly name: string; + readonly org: string; + /** Free-form tags for filtering/discovery. */ + readonly tags?: readonly string[]; +} diff --git a/src/ts/index.ts b/src/ts/index.ts new file mode 100644 index 0000000..b3cecd6 --- /dev/null +++ b/src/ts/index.ts @@ -0,0 +1,22 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. + +export * from "./api-error"; +export * from "./audit-integrity-response"; +export * from "./audit-log-response"; +export * from "./claim-org-request"; +export * from "./claim-org-response"; +export * from "./common"; +export * from "./package-list-response"; +export * from "./package-metadata"; +export * from "./publish-response"; +export * from "./search-response"; +export * from "./semantic-search-request"; +export * from "./semantic-search-response"; +export * from "./sync-change-event"; +export * from "./sync-conflict-resolution"; +export * from "./sync-error-policy"; +export * from "./sync-write-mode"; +export * from "./version-metadata"; +export * from "./yank-request"; +export * from "./yank-response"; diff --git a/src/ts/package-list-response.ts b/src/ts/package-list-response.ts new file mode 100644 index 0000000..a547f7e --- /dev/null +++ b/src/ts/package-list-response.ts @@ -0,0 +1,12 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/package-list-response.json + +import type { PackageSummary } from "./common"; + +/** Response for `GET /v1/packages` (list all). */ +export interface PackageListResponse { + readonly items: readonly PackageSummary[]; + /** Total packages matching the filter (before limit/offset). */ + readonly total: number; +} diff --git a/src/ts/package-metadata.ts b/src/ts/package-metadata.ts new file mode 100644 index 0000000..4bdd467 --- /dev/null +++ b/src/ts/package-metadata.ts @@ -0,0 +1,37 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/package-metadata.json + +export interface PackageMetadata { + readonly description?: string | null; + readonly latest?: string | null; + readonly name: string; + readonly org: string; + readonly repo_url: string; + /** Free-form tags for filtering/discovery (multi-tag lookup). */ + readonly tags?: readonly string[]; + readonly vcs: Vcs; + /** How this package's versions should be interpreted (semver by default). */ + readonly version_scheme?: VersionScheme; + /** All published, non-yanked versions, newest first. */ + readonly versions: readonly string[]; +} + +/** + * Version-control systems a package's source repository can live on. zed-pkg is VCS-agnostic by design: what gets installed is always a registry artifact, and the VCS is where provenance (tags) is anchored. Authors must create a matching tag on their declared backing repo (GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure DevOps, CodeCommit, Radicle, or self-hosted) before publishing. `jj` and Sapling are git-compatible and push to git remotes, so their provenance is verified through git tags. + */ +export type Vcs = "git" | "hg" | "jj" | "sapling" | "fossil" | "pijul"; + +/** Every `Vcs` value, in schema order — for validation and pickers. */ +export const VCS_VALUES = ["git", "hg", "jj", "sapling", "fossil", "pijul"] as const; + +/** + * How a package's `version` string (and its published tags) should be interpreted. Defaults to [`VersionScheme::Semver`], which covers the vast majority of modern ecosystems. + * - `semver`: Semantic Versioning. `package.version` must be valid semver; ranges (`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version. + * - `calver`: Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver total order (leading zeros dropped, padded to major.minor.patch) so the same range algebra applies. See [`normalize_calver`]. + * - `opaque`: Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra: a requirement must match a published version **exactly**. + */ +export type VersionScheme = "semver" | "calver" | "opaque"; + +/** Every `VersionScheme` value, in schema order — for validation and pickers. */ +export const VERSION_SCHEME_VALUES = ["semver", "calver", "opaque"] as const; diff --git a/src/ts/package.json b/src/ts/package.json new file mode 100644 index 0000000..937a9e6 --- /dev/null +++ b/src/ts/package.json @@ -0,0 +1,28 @@ +{ + "name": "@zed-pkg/zed-interfaces", + "version": "0.1.0", + "description": "Front-end contract types for the zed-pkg registry, generated from the JSON Schemas that the Rust crate in src/rust is the source of truth for.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/zed-pkg/zed-interfaces.git", + "directory": "src/ts" + }, + "type": "module", + "types": "./index.ts", + "exports": { + ".": "./index.ts", + "./*": "./*.ts" + }, + "files": [ + "*.ts", + "README.md" + ], + "sideEffects": false, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/src/ts/publish-response.ts b/src/ts/publish-response.ts new file mode 100644 index 0000000..f717dc6 --- /dev/null +++ b/src/ts/publish-response.ts @@ -0,0 +1,10 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/publish-response.json + +export interface PublishResponse { + readonly name: string; + readonly org: string; + readonly sha256: string; + readonly version: string; +} diff --git a/src/ts/search-response.ts b/src/ts/search-response.ts new file mode 100644 index 0000000..5888006 --- /dev/null +++ b/src/ts/search-response.ts @@ -0,0 +1,10 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/search-response.json + +import type { PackageSummary } from "./common"; + +export interface SearchResponse { + readonly items: readonly PackageSummary[]; + readonly query: string; +} diff --git a/src/ts/semantic-search-request.ts b/src/ts/semantic-search-request.ts new file mode 100644 index 0000000..4fef875 --- /dev/null +++ b/src/ts/semantic-search-request.ts @@ -0,0 +1,14 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/semantic-search-request.json + +/** Body of `POST /v1/search/semantic` (RAG). The caller computes the query embedding with its model; the server ranks stored package embeddings from the SAME model by cosine distance. Vectors up to 2050 dims are accepted and zero-padded server-side. */ +export interface SemanticSearchRequest { + /** The query embedding (native width; padded to 2050 server-side). */ + readonly embedding: readonly number[]; + readonly limit?: number; + /** Embedding model id, e.g. `openai/text-embedding-3-small`. Only stored embeddings from this model are searched. */ + readonly model: string; + /** Optional tag filter: results must carry all of these tags. */ + readonly tags?: readonly string[]; +} diff --git a/src/ts/semantic-search-response.ts b/src/ts/semantic-search-response.ts new file mode 100644 index 0000000..727686f --- /dev/null +++ b/src/ts/semantic-search-response.ts @@ -0,0 +1,16 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/semantic-search-response.json + +export interface SemanticHit { + readonly description?: string | null; + /** Cosine distance (0 = identical direction, 2 = opposite). Lower is nearer. */ + readonly distance: number; + readonly name: string; + readonly org: string; + readonly tags?: readonly string[]; +} + +export interface SemanticSearchResponse { + readonly items: readonly SemanticHit[]; +} diff --git a/src/ts/sync-change-event.ts b/src/ts/sync-change-event.ts new file mode 100644 index 0000000..df3fa89 --- /dev/null +++ b/src/ts/sync-change-event.ts @@ -0,0 +1,27 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-change-event.json + +/** Hybrid Logical Clock stamp — the reconciliation key. Total order: `wall_ms`, then `counter`, then `actor`. */ +export interface Hlc { + readonly actor: string; + readonly counter: number; + readonly wall_ms: number; +} + +/** The change envelope both transports decode to (mirrors zed-sync `ChangeEvent`). `version` is the ONLY reconciliation key; `sync_sequence` is the catch-up cursor and never feeds reconciliation. */ +export interface SyncChangeEvent { + readonly at_ms: number; + readonly id: string; + readonly op: SyncOp; + readonly row?: unknown; + readonly sync_sequence?: number | null; + readonly table: string; + readonly version: Hlc; + readonly write_key?: string | null; +} + +export type SyncOp = "upsert" | "delete"; + +/** Every `SyncOp` value, in schema order — for validation and pickers. */ +export const SYNC_OP_VALUES = ["upsert", "delete"] as const; diff --git a/src/ts/sync-conflict-resolution.ts b/src/ts/sync-conflict-resolution.ts new file mode 100644 index 0000000..e35280a --- /dev/null +++ b/src/ts/sync-conflict-resolution.ts @@ -0,0 +1,11 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-conflict-resolution.json + +/** + * How a dirty-vs-newer-remote conflict resolves (mirrors zed-sync `ConflictResolution`). + */ +export type SyncConflictResolution = "server_wins" | "last_write_wins"; + +/** Every `SyncConflictResolution` value, in schema order — for validation and pickers. */ +export const SYNC_CONFLICT_RESOLUTION_VALUES = ["server_wins", "last_write_wins"] as const; diff --git a/src/ts/sync-error-policy.ts b/src/ts/sync-error-policy.ts new file mode 100644 index 0000000..7be1b34 --- /dev/null +++ b/src/ts/sync-error-policy.ts @@ -0,0 +1,11 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-error-policy.json + +/** + * How write/flush errors are surfaced (mirrors zed-sync `ErrorPolicy`). + */ +export type SyncErrorPolicy = "throw_only" | "emit_only" | "throw_and_emit" | "silent"; + +/** Every `SyncErrorPolicy` value, in schema order — for validation and pickers. */ +export const SYNC_ERROR_POLICY_VALUES = ["throw_only", "emit_only", "throw_and_emit", "silent"] as const; diff --git a/src/ts/sync-write-mode.ts b/src/ts/sync-write-mode.ts new file mode 100644 index 0000000..f5e37c6 --- /dev/null +++ b/src/ts/sync-write-mode.ts @@ -0,0 +1,11 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-write-mode.json + +/** + * Per-write optimism level (mirrors zed-sync `WriteMode`). Enum, not a boolean: a write names its exact behavior. + */ +export type SyncWriteMode = "local_only" | "optimistic_queue" | "optimistic_await_ack" | "server_first" | "server_only"; + +/** Every `SyncWriteMode` value, in schema order — for validation and pickers. */ +export const SYNC_WRITE_MODE_VALUES = ["local_only", "optimistic_queue", "optimistic_await_ack", "server_first", "server_only"] as const; diff --git a/src/ts/tsconfig.json b/src/ts/tsconfig.json new file mode 100644 index 0000000..a14bd4a --- /dev/null +++ b/src/ts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/src/ts/version-metadata.ts b/src/ts/version-metadata.ts new file mode 100644 index 0000000..449e56f --- /dev/null +++ b/src/ts/version-metadata.ts @@ -0,0 +1,27 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/version-metadata.json + +/** + * On-the-wire formats for published package artifacts. + */ +export type ArtifactFormat = "tar.gz" | "zip"; + +/** Every `ArtifactFormat` value, in schema order — for validation and pickers. */ +export const ARTIFACT_FORMAT_VALUES = ["tar.gz", "zip"] as const; + +export interface VersionMetadata { + /** Absolute or registry-relative URL the artifact can be fetched from. */ + readonly download_url: string; + readonly format?: ArtifactFormat; + readonly name: string; + readonly org: string; + /** RFC 3339 timestamp. */ + readonly published_at: string; + readonly sha256: string; + readonly size: number; + readonly vcs_commit?: string | null; + readonly vcs_tag: string; + readonly version: string; + readonly yanked?: boolean; +} diff --git a/src/ts/yank-request.ts b/src/ts/yank-request.ts new file mode 100644 index 0000000..8965ebe --- /dev/null +++ b/src/ts/yank-request.ts @@ -0,0 +1,8 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/yank-request.json + +/** Body for the yank route. `yanked: false` restores a yanked version. */ +export interface YankRequest { + readonly yanked: boolean; +} diff --git a/src/ts/yank-response.ts b/src/ts/yank-response.ts new file mode 100644 index 0000000..80aba79 --- /dev/null +++ b/src/ts/yank-response.ts @@ -0,0 +1,10 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/yank-response.json + +export interface YankResponse { + readonly name: string; + readonly org: string; + readonly version: string; + readonly yanked: boolean; +}