From 0eac5a9dd8e98ebaeb035b6806a7520a07b6a301 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Thu, 23 Jul 2026 14:22:29 -0500 Subject: [PATCH 001/122] Initial scaffold of zed-interfaces Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + Cargo.lock | 290 ++++++++++++++++++++++++++++++++ Cargo.toml | 15 ++ LICENSE | 21 +++ README.md | 85 ++++++++++ examples/generate_schemas.rs | 33 ++++ schemas/api-error.json | 19 +++ schemas/claim-org-request.json | 13 ++ schemas/claim-org-response.json | 18 ++ schemas/lockfile.json | 83 +++++++++ schemas/manifest.json | 154 +++++++++++++++++ schemas/package-metadata.json | 59 +++++++ schemas/publish-meta.json | 200 ++++++++++++++++++++++ schemas/publish-response.json | 25 +++ schemas/search-response.json | 49 ++++++ schemas/version-metadata.json | 69 ++++++++ src/artifact.rs | 36 ++++ src/excludes.rs | 55 ++++++ src/lib.rs | 20 +++ src/lockfile.rs | 94 +++++++++++ src/manifest.rs | 180 ++++++++++++++++++++ src/paths.rs | 44 +++++ src/registry.rs | 156 +++++++++++++++++ src/vcs.rs | 72 ++++++++ tests/roundtrip.rs | 108 ++++++++++++ 25 files changed, 1899 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/generate_schemas.rs create mode 100644 schemas/api-error.json create mode 100644 schemas/claim-org-request.json create mode 100644 schemas/claim-org-response.json create mode 100644 schemas/lockfile.json create mode 100644 schemas/manifest.json create mode 100644 schemas/package-metadata.json create mode 100644 schemas/publish-meta.json create mode 100644 schemas/publish-response.json create mode 100644 schemas/search-response.json create mode 100644 schemas/version-metadata.json create mode 100644 src/artifact.rs create mode 100644 src/excludes.rs create mode 100644 src/lib.rs create mode 100644 src/lockfile.rs create mode 100644 src/manifest.rs create mode 100644 src/paths.rs create mode 100644 src/registry.rs create mode 100644 src/vcs.rs create mode 100644 tests/roundtrip.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4037a86 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,290 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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 = "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 = "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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = [ + "schemars", + "semver", + "serde", + "serde_json", + "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..ab18f0a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[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" + +[dependencies] +schemars = "1.2.1" +semver = { version = "1.0.28", features = ["serde"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +thiserror = "2.0.19" +toml = "1.1.3" 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/README.md b/README.md new file mode 100644 index 0000000..0b5f556 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# zed-interfaces + +Core interface definitions for [zed-pkg](https://github.com/zed-pkg), the +universal package manager backed by the VCS hosts you already use. + +This crate is the contract everything else builds against: + +- **`.zpkg.toml`** — the package manifest at the repo root, TOML only (`manifest` module) +- **`.zpkg.lock`** — the lockfile with artifact hashes and VCS provenance (`lockfile`) +- **Registry REST API** — URL scheme and JSON DTOs shared by `zed-api-server`, + `zed-cli`, `zed-web-server`, and the SDKs in `zed-clients` (`registry`) +- **Publish excludes** — the default rules that strip tests, CI config, + `.github/`, and READMEs from published artifacts (`excludes`) +- **Filesystem layout** — `$HOME/.zed-pkg` store, `zed_modules/` symlink dir, + archive structure (`paths`) +- **VCS + artifact enums** — `git`/`hg`, `tar.gz`/`zip` (`vcs`, `artifact`) + +## The model in one page + +A package is `/`. Its source of truth is a repository on any VCS +host — GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo/Gitea, Azure +DevOps, CodeCommit, Radicle, or a server you run — using git, hg, jj, sapling, +fossil, or pijul (jj and sapling verify through git tags since they push to +git remotes). The registry at zpkg.tech is the primary artifact host; the +declared backing repo doubles as mirror/backup. What gets installed is never +a clone: `zed publish` packs a pruned artifact (no tests, no +CI config, no README unless opted in; licenses always kept), verifies that a +VCS tag matching `publish.tag_format` (default `v{version}`) points at the +published commit, and uploads the archive to the registry, which stores it in +S3-compatible object storage (Cloudflare R2, S3, MinIO). + +`zed install` resolves semver requirements against registry metadata, downloads +each artifact once into the global content-addressed store +(`$HOME/.zed-pkg/store/v1///pkg`), verifies its sha256, and +symlinks it into the project's `zed_modules//` — pnpm-style, one +copy per machine no matter how many projects use it. In containers, +`--install-mode copy` materializes files instead of symlinking so image layers +stay self-contained across multi-stage builds. + +The lockfile pins `sha256`, `size`, `vcs_tag`, and `vcs_commit` per package: +installs are reproducible and every artifact traces back to source. + +## Registry API surface + +| Method | Path | Body / response | +| --- | --- | --- | +| GET | `/v1/packages/{org}/{name}` | `PackageMetadata` | +| GET | `/v1/packages/{org}/{name}/versions/{version}` | `VersionMetadata` | +| PUT | `/v1/packages/{org}/{name}/versions/{version}` | multipart `meta` (`PublishMeta` JSON) + `artifact` (bytes) → `PublishResponse` | +| GET | `/v1/artifacts/{sha256}` | artifact bytes or redirect to presigned URL | +| GET | `/v1/search?q=` | `SearchResponse` | +| POST | `/v1/orgs` | `ClaimOrgRequest` → `ClaimOrgResponse` | +| GET | `/healthz` | liveness | + +Errors use `ApiError { code, message }`. Authenticated routes take +`Authorization: Bearer `. + +## JSON Schemas + +`schemas/` holds generated JSON Schema files for every wire type, used by the +non-Rust SDKs in [zed-clients](https://github.com/zed-pkg/zed-clients). +Regenerate after changing any type: + +```sh +cargo run --example generate_schemas +``` + +## Development + +This repo is developed side by side with its siblings; the other Rust repos +depend on it via `zed-interfaces = { path = "../zed-interfaces" }`: + +```sh +git clone https://github.com/zed-pkg/zed-interfaces +git clone https://github.com/zed-pkg/zed-cli +# ... siblings in the same parent directory +``` + +```sh +cargo test +``` + +## License + +MIT diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs new file mode 100644 index 0000000..87cdbaf --- /dev/null +++ b/examples/generate_schemas.rs @@ -0,0 +1,33 @@ +//! Regenerates the JSON Schemas under `schemas/`, which the non-Rust +//! client libraries in `zed-clients` use for codegen and validation. +//! +//! 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() { + let dir = Path::new("schemas"); + fs::create_dir_all(dir).expect("schemas dir"); + + write::(dir, "manifest"); + write::(dir, "lockfile"); + 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, "api-error"); +} 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/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/lockfile.json b/schemas/lockfile.json new file mode 100644 index 0000000..c260ec9 --- /dev/null +++ b/schemas/lockfile.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Lockfile", + "description": "The `zed.lock` file written next to `zed.toml` after resolution.\n\nSerialized as TOML with one `[[package]]` table per locked package,\nCargo.lock-style. Every entry pins the exact artifact hash and the VCS\ntag it was published from, so installs are reproducible and every\nartifact is traceable back to source.", + "type": "object", + "properties": { + "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": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "sha256": { + "description": "Hex sha256 of the artifact archive; also its store address.", + "type": "string" + }, + "size": { + "description": "Artifact size in bytes.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "source": { + "description": "Base URL of the registry the artifact was resolved from.", + "type": "string" + }, + "vcs_commit": { + "type": [ + "string", + "null" + ] + }, + "vcs_tag": { + "description": "VCS tag the version was published from, e.g. `v1.2.0`.", + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version", + "sha256", + "size", + "vcs_tag", + "source" + ] + } + } +} diff --git a/schemas/manifest.json b/schemas/manifest.json new file mode 100644 index 0000000..4cbe51e --- /dev/null +++ b/schemas/manifest.json @@ -0,0 +1,154 @@ +{ + "$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": { + "dependencies": { + "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "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 + } + } + }, + "required": [ + "package" + ], + "$defs": { + "PackageSection": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "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": "Semver version of this package.", + "type": "string" + } + }, + "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 + }, + "smoke_test": { + "description": "Command run by `zed test-local` inside a throwaway consumer project\nthat has this package installed the same way a real consumer 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 + } + } + }, + "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" + ] + } + } +} diff --git a/schemas/package-metadata.json b/schemas/package-metadata.json new file mode 100644 index 0000000..5cd8bb2 --- /dev/null +++ b/schemas/package-metadata.json @@ -0,0 +1,59 @@ +{ + "$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" + }, + "vcs": { + "$ref": "#/$defs/Vcs" + }, + "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" + ] + } + } +} diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json new file mode 100644 index 0000000..1aad6af --- /dev/null +++ b/schemas/publish-meta.json @@ -0,0 +1,200 @@ +{ + "$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" + ] + }, + "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": { + "dependencies": { + "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "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 + } + } + }, + "required": [ + "package" + ] + }, + "PackageSection": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "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": "Semver version of this package.", + "type": "string" + } + }, + "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 + }, + "smoke_test": { + "description": "Command run by `zed test-local` inside a throwaway consumer project\nthat has this package installed the same way a real consumer 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 + } + } + }, + "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" + ] + } + } +} 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..38e6191 --- /dev/null +++ b/schemas/search-response.json @@ -0,0 +1,49 @@ +{ + "$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" + } + }, + "required": [ + "org", + "name" + ] + } + } +} 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/src/artifact.rs b/src/artifact.rs new file mode 100644 index 0000000..180d0e7 --- /dev/null +++ b/src/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/excludes.rs b/src/excludes.rs new file mode 100644 index 0000000..31f900d --- /dev/null +++ b/src/excludes.rs @@ -0,0 +1,55 @@ +//! Publish-time exclusion rules. +//! +//! zed-pkg's core disk-space promise: published artifacts carry what runs, +//! not what develops. Tests, CI configuration, VCS metadata, and READMEs +//! are stripped by default; 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", + "**/*_test.py", + ".github/**", + ".gitlab/**", + ".gitlab-ci.yml", + "bitbucket-pipelines.yml", + ".circleci/**", + ".travis.yml", + "azure-pipelines.yml", + "README*", + "CHANGELOG*", + ".zedignore", + ".zed/**", + ".zpkg.lock", + "zed_modules/**", +]; + +/// 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"]; + +/// The effective exclusion list for a package: built-in defaults (minus +/// README 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. +pub fn effective_excludes(extra: &[String], include_readme: bool) -> Vec { + let mut out: Vec = Vec::new(); + for pattern in DEFAULT_EXCLUDES { + if include_readme && pattern.starts_with("README") { + continue; + } + out.push((*pattern).to_string()); + } + out.extend(extra.iter().cloned()); + out +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2c69d94 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,20 @@ +//! Core interface definitions for the zed-pkg universal package manager. +//! +//! This crate is the single source of truth for the on-disk formats +//! (`zed.toml`, `zed.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 excludes; +pub mod lockfile; +pub mod manifest; +pub mod paths; +pub mod registry; +pub mod vcs; + +pub use artifact::ArtifactFormat; +pub use lockfile::{LockedPackage, Lockfile}; +pub use manifest::{Manifest, ManifestError}; +pub use vcs::Vcs; diff --git a/src/lockfile.rs b/src/lockfile.rs new file mode 100644 index 0000000..2807de0 --- /dev/null +++ b/src/lockfile.rs @@ -0,0 +1,94 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::artifact::ArtifactFormat; + +/// The `zed.lock` file written next to `zed.toml` after resolution. +/// +/// Serialized as TOML with one `[[package]]` table per locked package, +/// Cargo.lock-style. Every entry pins the exact artifact hash and the VCS +/// tag it was published from, so installs are reproducible and every +/// artifact is traceable back to source. +#[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, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct LockedPackage { + pub org: String, + pub name: String, + pub version: String, + /// Hex sha256 of the artifact archive; also its store address. + pub sha256: String, + /// Artifact size in bytes. + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, + /// VCS tag the version was published from, e.g. `v1.2.0`. + pub vcs_tag: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_commit: Option, + /// Base URL of the registry the artifact was resolved from. + 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), +} + +impl Default for Lockfile { + fn default() -> Self { + Self { + version: Self::CURRENT_VERSION, + packages: 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(|e| LockfileError::Toml(e.to_string()))?; + if lockfile.version > Self::CURRENT_VERSION { + return Err(LockfileError::UnsupportedVersion( + lockfile.version, + Self::CURRENT_VERSION, + )); + } + Ok(lockfile) + } + + pub fn to_toml_string(&self) -> Result { + toml::to_string_pretty(self).map_err(|e| LockfileError::Toml(e.to_string())) + } + + pub fn find(&self, org: &str, name: &str) -> Option<&LockedPackage> { + self.packages + .iter() + .find(|p| p.org == org && p.name == name) + } + + /// Insert or replace the entry for `org/name`, keeping entries sorted. + pub fn upsert(&mut self, package: LockedPackage) { + self.packages + .retain(|p| !(p.org == package.org && p.name == package.name)); + self.packages.push(package); + self.packages + .sort_by(|a, b| (&a.org, &a.name).cmp(&(&b.org, &b.name))); + } +} + +impl LockedPackage { + pub fn full_name(&self) -> String { + format!("{}/{}", self.org, self.name) + } +} diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..09423bf --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,180 @@ +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::vcs::Vcs; + +/// 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, + /// Dependencies keyed by `org/name`, valued by a semver requirement. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub dependencies: BTreeMap, + #[serde(default)] + pub publish: PublishSection, + #[serde(default)] + pub scripts: ScriptsSection, +} + +#[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, + /// Semver version of this package. + pub version: String, + #[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, +} + +/// 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 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, +} + +impl Default for PublishSection { + fn default() -> Self { + Self { + exclude: Vec::new(), + include_readme: false, + smoke_test: None, + tag_format: "v{version}".to_string(), + } + } +} + +#[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, +} + +#[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("manifest toml error: {0}")] + Toml(String), +} + +/// 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 == '-') +} + +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())); + } + semver::Version::parse(&self.package.version).map_err(|e| { + ManifestError::InvalidVersion(self.package.version.clone(), e.to_string()) + })?; + for (key, req) in &self.dependencies { + let mut parts = key.splitn(2, '/'); + let (org, name) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); + if !is_slug(org) || !is_slug(name) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } + semver::VersionReq::parse(req).map_err(|e| { + ManifestError::InvalidDependencyReq(key.clone(), req.clone(), e.to_string()) + })?; + } + Ok(()) + } + + /// `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/paths.rs b/src/paths.rs new file mode 100644 index 0000000..e1f80d1 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,44 @@ +//! 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"; + +/// 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}") +} diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..bac6921 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,156 @@ +//! 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. +pub fn search_path() -> String { + format!("{API_V1}/search") +} + +/// `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) — claim an org namespace. +pub fn orgs_path() -> String { + format!("{API_V1}/orgs") +} + +/// `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, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest: Option, + /// 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, +} + +#[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, +} + +/// 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/vcs.rs b/src/vcs.rs new file mode 100644 index 0000000..d7b337f --- /dev/null +++ b/src/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/tests/roundtrip.rs b/tests/roundtrip.rs new file mode 100644 index 0000000..5360209 --- /dev/null +++ b/tests/roundtrip.rs @@ -0,0 +1,108 @@ +use zed_interfaces::ArtifactFormat; +use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; +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 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!(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}")); +} From ee50b45bdb70ff49d1350169d6775d5737561e61 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Fri, 24 Jul 2026 08:47:37 -0500 Subject: [PATCH 002/122] Calendar + semver versioning, zip artifacts, and zed-sync interop types - zed-interfaces: VersionScheme (semver/calver/opaque), tolerant version parsing (v-prefix, Go +incompatible, PEP 440), npm-style range parsing, shared sync contract types (SyncWriteMode/ErrorPolicy/ChangeEvent), regenerated schemas. - zed-cli: resolve via the shared calver-aware resolver; deterministic zip artifacts alongside tar.gz with magic-byte extraction. - zed-api-server: persist and serve version_scheme (entity + migration + handlers). - zed-clients: dependency-free sync wire types bridge. Co-Authored-By: Claude Fable 5 --- examples/generate_schemas.rs | 5 + schemas/manifest.json | 26 ++- schemas/package-metadata.json | 24 +++ schemas/publish-meta.json | 26 ++- schemas/sync-change-event.json | 80 ++++++++ schemas/sync-error-policy.json | 12 ++ schemas/sync-write-mode.json | 13 ++ src/lib.rs | 3 + src/manifest.rs | 31 ++- src/registry.rs | 6 + src/sync.rs | 104 ++++++++++ src/version.rs | 361 +++++++++++++++++++++++++++++++++ 12 files changed, 682 insertions(+), 9 deletions(-) create mode 100644 schemas/sync-change-event.json create mode 100644 schemas/sync-error-policy.json create mode 100644 schemas/sync-write-mode.json create mode 100644 src/sync.rs create mode 100644 src/version.rs diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 87cdbaf..f81dbdd 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -30,4 +30,9 @@ fn main() { write::(dir, "claim-org-request"); write::(dir, "claim-org-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"); } diff --git a/schemas/manifest.json b/schemas/manifest.json index 4cbe51e..36342c3 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -67,8 +67,12 @@ "$ref": "#/$defs/RepositorySection" }, "version": { - "description": "Semver version of this package.", + "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": [ @@ -149,6 +153,26 @@ "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/package-metadata.json b/schemas/package-metadata.json index 5cd8bb2..578460a 100644 --- a/schemas/package-metadata.json +++ b/schemas/package-metadata.json @@ -27,6 +27,10 @@ "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", @@ -54,6 +58,26 @@ "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 index 1aad6af..5bfc03f 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -113,8 +113,12 @@ "$ref": "#/$defs/RepositorySection" }, "version": { - "description": "Semver version of this package.", + "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": [ @@ -195,6 +199,26 @@ "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/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-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/src/lib.rs b/src/lib.rs index 2c69d94..b8b2246 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,9 +12,12 @@ pub mod lockfile; pub mod manifest; pub mod paths; pub mod registry; +pub mod sync; pub mod vcs; +pub mod version; pub use artifact::ArtifactFormat; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; pub use vcs::Vcs; +pub use version::{Requirement, VersionScheme}; diff --git a/src/manifest.rs b/src/manifest.rs index 09423bf..425a0cd 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -4,6 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; 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. @@ -45,8 +46,16 @@ pub struct PackageSection { pub org: String, /// Package name, unique within the org. Lowercase slug. pub name: String, - /// Semver version of this package. + /// 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")] @@ -144,18 +153,26 @@ impl Manifest { if !is_slug(&self.package.name) { return Err(ManifestError::InvalidName(self.package.name.clone())); } - semver::Version::parse(&self.package.version).map_err(|e| { - ManifestError::InvalidVersion(self.package.version.clone(), e.to_string()) - })?; + self.package + .version_scheme + .validate_version(&self.package.version) + .map_err(|e| ManifestError::InvalidVersion(self.package.version.clone(), e))?; for (key, req) in &self.dependencies { let mut parts = key.splitn(2, '/'); let (org, name) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); if !is_slug(org) || !is_slug(name) { return Err(ManifestError::InvalidDependencyKey(key.clone())); } - semver::VersionReq::parse(req).map_err(|e| { - ManifestError::InvalidDependencyReq(key.clone(), req.clone(), e.to_string()) - })?; + // A requirement is either a semver range or an exact (opaque) tag; + // only an empty string is invalid. + if req.trim().is_empty() { + return Err(ManifestError::InvalidDependencyReq( + key.clone(), + req.clone(), + "requirement must not be empty".to_string(), + )); + } + let _ = Requirement::parse(req); } Ok(()) } diff --git a/src/registry.rs b/src/registry.rs index bac6921..44662b3 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -77,6 +77,12 @@ pub struct PackageMetadata { 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, /// All published, non-yanked versions, newest first. diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..fe31d71 --- /dev/null +++ b/src/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/version.rs b/src/version.rs new file mode 100644 index 0000000..aee5b26 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,361 @@ +//! 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') { + if let Ok(v) = Version::parse(stripped) { + return Some(v); + } + } + if let Some(go) = normalize_go(raw) { + if let Ok(v) = Version::parse(&go) { + return Some(v); + } + } + if let Some(cal) = normalize_calver(raw) { + if let Ok(v) = Version::parse(&cal) { + return Some(v); + } + } + if let Some(pep) = normalize_pep440(raw) { + if 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, + } + } +} + +/// 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()); + } +} From 56dc77a9937f8fb1214542102633fbcdb49eda92 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 10:32:52 -0500 Subject: [PATCH 003/122] Harden manifest + add build/bin/workspace/overrides, yank DTOs, sha/url validation - PackageSection gains bin, build ([build] command+outputs), build_dependencies, workspace ([workspace] members), and overrides.build sections for the polyglot build-cache, binary-hoisting, monorepo, and consumer-patch features. - Validate repository URL scheme, bin names/targets, and build outputs; reject range-shaped requirements that don't parse (no silent degrade to opaque tag). - Add is_sha256_hex + is_safe_relative_path guards for the CLI trust boundary. - Add build_entry_rel/current_platform (per-platform build cache) and BIN_DIR. - Yank REST DTOs (YankRequest/YankResponse) + yank_path helper. - Regenerate JSON schemas; fix stale zed.toml/zed.lock doc references. Co-Authored-By: Claude Fable 5 --- examples/generate_schemas.rs | 3 + schemas/lockfile.json | 2 +- schemas/manifest.json | 86 ++++++++++++++ schemas/publish-meta.json | 86 ++++++++++++++ schemas/sync-conflict-resolution.json | 10 ++ schemas/yank-request.json | 14 +++ schemas/yank-response.json | 25 ++++ src/lib.rs | 2 +- src/lockfile.rs | 2 +- src/manifest.rs | 161 +++++++++++++++++++++++++- src/paths.rs | 19 +++ src/registry.rs | 21 ++++ src/version.rs | 19 +++ 13 files changed, 443 insertions(+), 7 deletions(-) create mode 100644 schemas/sync-conflict-resolution.json create mode 100644 schemas/yank-request.json create mode 100644 schemas/yank-response.json diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index f81dbdd..0db10a7 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -29,10 +29,13 @@ fn main() { write::(dir, "search-response"); write::(dir, "claim-org-request"); write::(dir, "claim-org-response"); + write::(dir, "yank-request"); + write::(dir, "yank-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"); } diff --git a/schemas/lockfile.json b/schemas/lockfile.json index c260ec9..b1b3d31 100644 --- a/schemas/lockfile.json +++ b/schemas/lockfile.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Lockfile", - "description": "The `zed.lock` file written next to `zed.toml` after resolution.\n\nSerialized as TOML with one `[[package]]` table per locked package,\nCargo.lock-style. Every entry pins the exact artifact hash and the VCS\ntag it was published from, so installs are reproducible and every\nartifact is traceable back to source.", + "description": "The `.zpkg.lock` file written next to `.zpkg.toml` after resolution.\n\nSerialized as TOML with one `[[package]]` table per locked package,\nCargo.lock-style. Every entry pins the exact artifact hash and the VCS\ntag it was published from, so installs are reproducible and every\nartifact is traceable back to source.", "type": "object", "properties": { "package": { diff --git a/schemas/manifest.json b/schemas/manifest.json index 36342c3..9da7bd4 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -4,6 +4,31 @@ "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. Consumers get them hoisted into\n`zed_modules/.bin/` and runnable via `zed run `.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "build": { + "description": "Optional post-extract build step (compiled extensions, codegen).\nBuilds run in an isolated staging copy — never inside the immutable\nsource store — and results are cached per (sha256, platform).", + "anyOf": [ + { + "$ref": "#/$defs/BuildSection" + }, + { + "type": "null" + } + ] + }, + "build_dependencies": { + "description": "Dependencies needed only while running this package's `[build]`\ncommand. Never linked into a consumer's zed_modules/.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "dependencies": { "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", "type": "object", @@ -11,6 +36,10 @@ "type": "string" } }, + "overrides": { + "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing build command without waiting on upstream).", + "$ref": "#/$defs/OverridesSection" + }, "package": { "$ref": "#/$defs/PackageSection" }, @@ -28,12 +57,56 @@ "default": { "test": null } + }, + "workspace": { + "description": "Monorepo workspace configuration; only meaningful in a workspace\nroot manifest.", + "anyOf": [ + { + "$ref": "#/$defs/WorkspaceSection" + }, + { + "type": "null" + } + ] } }, "required": [ "package" ], "$defs": { + "BuildSection": { + "description": "A post-extract build step. `command` runs via `sh -c` inside a staging\ncopy of the package; `outputs` optionally narrows what is promoted into\nthe per-platform build cache (default: the whole staged tree).", + "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" + ] + }, + "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": { @@ -173,6 +246,19 @@ "const": "opaque" } ] + }, + "WorkspaceSection": { + "description": "Workspace configuration for monorepos: member globs relative to the\nworkspace root, e.g. `[\"packages/*\", \"apps/*\"]`. Dependencies that\nresolve to a member are symlinked straight to the member's source\ndirectory instead of going through the registry.", + "type": "object", + "properties": { + "members": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } } } } diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 5bfc03f..ef0c909 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -46,10 +46,55 @@ "zip" ] }, + "BuildSection": { + "description": "A post-extract build step. `command` runs via `sh -c` inside a staging\ncopy of the package; `outputs` optionally narrows what is promoted into\nthe per-platform build cache (default: the whole staged tree).", + "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" + ] + }, "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. Consumers get them hoisted into\n`zed_modules/.bin/` and runnable via `zed run `.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "build": { + "description": "Optional post-extract build step (compiled extensions, codegen).\nBuilds run in an isolated staging copy — never inside the immutable\nsource store — and results are cached per (sha256, platform).", + "anyOf": [ + { + "$ref": "#/$defs/BuildSection" + }, + { + "type": "null" + } + ] + }, + "build_dependencies": { + "description": "Dependencies needed only while running this package's `[build]`\ncommand. Never linked into a consumer's zed_modules/.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "dependencies": { "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", "type": "object", @@ -57,6 +102,10 @@ "type": "string" } }, + "overrides": { + "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing build command without waiting on upstream).", + "$ref": "#/$defs/OverridesSection" + }, "package": { "$ref": "#/$defs/PackageSection" }, @@ -74,12 +123,36 @@ "default": { "test": null } + }, + "workspace": { + "description": "Monorepo workspace configuration; only meaningful in a workspace\nroot manifest.", + "anyOf": [ + { + "$ref": "#/$defs/WorkspaceSection" + }, + { + "type": "null" + } + ] } }, "required": [ "package" ] }, + "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": { @@ -219,6 +292,19 @@ "const": "opaque" } ] + }, + "WorkspaceSection": { + "description": "Workspace configuration for monorepos: member globs relative to the\nworkspace root, e.g. `[\"packages/*\", \"apps/*\"]`. Dependencies that\nresolve to a member are symlinked straight to the member's source\ndirectory instead of going through the registry.", + "type": "object", + "properties": { + "members": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } } } } 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/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/lib.rs b/src/lib.rs index b8b2246..dbd1146 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! Core interface definitions for the zed-pkg universal package manager. //! //! This crate is the single source of truth for the on-disk formats -//! (`zed.toml`, `zed.lock`, store layout), the registry REST API DTOs, and +//! (`.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`. diff --git a/src/lockfile.rs b/src/lockfile.rs index 2807de0..23a01ae 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::artifact::ArtifactFormat; -/// The `zed.lock` file written next to `zed.toml` after resolution. +/// The `.zpkg.lock` file written next to `.zpkg.toml` after resolution. /// /// Serialized as TOML with one `[[package]]` table per locked package, /// Cargo.lock-style. Every entry pins the exact artifact hash and the VCS diff --git a/src/manifest.rs b/src/manifest.rs index 425a0cd..cf928a3 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -34,10 +34,32 @@ pub struct Manifest { /// Dependencies keyed by `org/name`, valued by a semver requirement. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub dependencies: BTreeMap, + /// Dependencies needed only while running this package's `[build]` + /// command. Never linked into a consumer's zed_modules/. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub build_dependencies: BTreeMap, #[serde(default)] pub publish: PublishSection, #[serde(default)] pub scripts: ScriptsSection, + /// Executables this package exposes, keyed by command name, valued by a + /// path relative to the package root. Consumers get them hoisted into + /// `zed_modules/.bin/` and runnable via `zed run `. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bin: BTreeMap, + /// Optional post-extract build step (compiled extensions, codegen). + /// Builds run in an isolated staging copy — never inside the immutable + /// source store — and results are cached per (sha256, platform). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, + /// Monorepo workspace configuration; only meaningful in a workspace + /// root manifest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, + /// Consumer-side patches for dependencies (e.g. fixing a dependency's + /// broken or missing build command without waiting on upstream). + #[serde(default, skip_serializing_if = "OverridesSection::is_empty")] + pub overrides: OverridesSection, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -108,6 +130,44 @@ pub struct ScriptsSection { pub test: Option, } +/// A post-extract build step. `command` runs via `sh -c` inside a staging +/// copy of the package; `outputs` optionally narrows what is promoted into +/// the per-platform build cache (default: the whole staged tree). +#[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, +} + +/// Workspace configuration for monorepos: member globs relative to the +/// workspace root, e.g. `["packages/*", "apps/*"]`. Dependencies that +/// resolve to a member are symlinked 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]")] @@ -120,6 +180,12 @@ pub enum ManifestError { 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("manifest toml error: {0}")] Toml(String), } @@ -133,6 +199,39 @@ pub fn is_slug(s: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } +/// 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). +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("://")) +} + impl Manifest { /// Parse and validate a `.zpkg.toml` document. pub fn parse(input: &str) -> Result { @@ -157,14 +256,19 @@ impl Manifest { .version_scheme .validate_version(&self.package.version) .map_err(|e| ManifestError::InvalidVersion(self.package.version.clone(), e))?; - for (key, req) in &self.dependencies { + 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) { let mut parts = key.splitn(2, '/'); let (org, name) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); if !is_slug(org) || !is_slug(name) { return Err(ManifestError::InvalidDependencyKey(key.clone())); } - // A requirement is either a semver range or an exact (opaque) tag; - // only an empty string is invalid. + // A requirement is either a semver range or an exact (opaque) tag. if req.trim().is_empty() { return Err(ManifestError::InvalidDependencyReq( key.clone(), @@ -172,7 +276,56 @@ impl Manifest { "requirement must not be empty".to_string(), )); } - let _ = Requirement::parse(req); + // 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, + )); + } + } + 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 { + let mut parts = key.splitn(2, '/'); + let (org, name) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); + if !is_slug(org) || !is_slug(name) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } } Ok(()) } diff --git a/src/paths.rs b/src/paths.rs index e1f80d1..f03a1e7 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -36,9 +36,28 @@ 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/registry.rs b/src/registry.rs index 44662b3..d70c6f3 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -49,6 +49,13 @@ 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") @@ -134,6 +141,20 @@ pub struct PublishResponse { 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, diff --git a/src/version.rs b/src/version.rs index aee5b26..fbc6732 100644 --- a/src/version.rs +++ b/src/version.rs @@ -242,6 +242,25 @@ impl Requirement { 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`. From 0bdc44e8e9c05b529043bc72cfd52ba626aa30e5 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 10:37:39 -0500 Subject: [PATCH 004/122] Add CI: fmt, test, and a schema-drift gate The generated schemas/ are the contract the non-Rust SDKs mirror; verify they stay in sync with the source types on every push. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b13cec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: ci +on: + push: + branches: [main] + pull_request: + +jobs: + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Format + run: cargo fmt --check + - name: Test + run: cargo test + # The generated JSON schemas are committed for the non-Rust SDKs to + # mirror; fail if they drift from the source types. + - name: Schemas are up to date + run: | + cargo run --example generate_schemas + git diff --exit-code schemas/ \ + || (echo "schemas/ is stale; run 'cargo run --example generate_schemas' and commit" && exit 1) From 3b1b8b5c65992ec4a65d432adeed1782d2e53317 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Fri, 24 Jul 2026 11:14:50 -0500 Subject: [PATCH 005/122] Manifest: workspace section for monorepo installs Co-Authored-By: Claude Fable 5 --- rust-toolchain.toml | 5 ++ src/manifest.rs | 143 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 rust-toolchain.toml 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/src/manifest.rs b/src/manifest.rs index 425a0cd..54c4e5a 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -31,15 +31,62 @@ use crate::version::{Requirement, VersionScheme}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct Manifest { pub package: PackageSection, + /// Monorepo workspace declaration (zed-docs issue #7). When present, + /// `zed install` at this root resolves every member against one store and + /// writes one `.zpkg.lock`; member→member dependencies link by path. + #[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 to *build* this package (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. + #[serde( + default, + rename = "build-dependencies", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub build_dependencies: BTreeMap, + /// This package's own build step, run after extraction on the consumer's + /// machine when the package ships source that needs compiling. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, + /// Consumer-side patches to a *dependency's* build step, keyed by + /// `org/name`. Lets a project fix a broken/missing upstream build locally. + #[serde( + default, + rename = "build-overrides", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub build_overrides: BTreeMap, + /// Executables this package exposes, `name -> path relative to the package + /// root`. On install they are hoisted into `zed_modules/.bin/` and run 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, } +/// A build step: the command to run after extraction, and the artifacts to +/// expose. Because compiled output is OS/arch-specific, zed-pkg runs this in a +/// sandbox 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, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct BuildSection { + /// Command executed with `sh -c` in the sandboxed copy of the source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + /// Files (relative paths) to expose to consumers. When empty, the whole + /// built tree is exposed. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct PackageSection { /// Namespace the package is published under. Lowercase slug. @@ -108,6 +155,15 @@ pub struct ScriptsSection { pub test: Option, } +/// Monorepo workspace membership. `members` are glob patterns (relative to +/// the workspace root) selecting directories that each contain a `.zpkg.toml`, +/// e.g. `["packages/*", "apps/*"]`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct WorkspaceSection { + pub members: Vec, +} + #[derive(Debug, thiserror::Error)] pub enum ManifestError { #[error("invalid org slug `{0}`: must match [a-z0-9][a-z0-9-]*[a-z0-9]")] @@ -120,10 +176,37 @@ pub enum ManifestError { InvalidDependencyKey(String), #[error("invalid requirement `{1}` for dependency `{0}`: {2}")] InvalidDependencyReq(String, String, String), + #[error("invalid bin `{0}`: {1}")] + InvalidBin(String, String), + #[error("invalid workspace member pattern `{0}`")] + InvalidWorkspaceMember(String), #[error("manifest toml error: {0}")] Toml(String), } +/// True for a relative path that stays within the package (no absolute paths, +/// no `..` traversal). Used to keep hoisted bins and build outputs contained. +pub fn is_safe_relative_path(path: &str) -> bool { + let p = std::path::Path::new(path); + !path.is_empty() + && p.is_relative() + && p.components().all(|c| { + matches!( + c, + std::path::Component::Normal(_) | std::path::Component::CurDir + ) + }) +} + +/// 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 the lowercase slugs zed-pkg accepts as org and package names. pub fn is_slug(s: &str) -> bool { !s.is_empty() @@ -174,9 +257,69 @@ impl Manifest { } let _ = Requirement::parse(req); } + for (key, req) in &self.build_dependencies { + if !is_dependency_key(key) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } + if req.trim().is_empty() { + return Err(ManifestError::InvalidDependencyReq( + key.clone(), + req.clone(), + "build-dependency requirement must not be empty".to_string(), + )); + } + } + for key in self.build_overrides.keys() { + if !is_dependency_key(key) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } + } + for (name, path) in &self.bin { + if name.trim().is_empty() || name.contains('/') || name.contains('\\') { + return Err(ManifestError::InvalidBin( + name.clone(), + "bin name must be non-empty with no path separators".to_string(), + )); + } + if !is_safe_relative_path(path) { + return Err(ManifestError::InvalidBin( + name.clone(), + format!("bin path `{path}` must be relative and stay inside the package"), + )); + } + } + if let Some(ws) = &self.workspace { + for pat in &ws.members { + if pat.trim().is_empty() { + return Err(ManifestError::InvalidWorkspaceMember(pat.clone())); + } + } + } Ok(()) } + /// 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 + /// `[build-overrides]` entry if present, else the dependency's own + /// `[build]`. Returns `None` when neither declares a build command. + pub fn effective_build( + &self, + dep_key: &str, + dep_build: Option<&BuildSection>, + ) -> Option { + self.build_overrides + .get(dep_key) + .or(dep_build) + .filter(|b| b.command.is_some()) + .cloned() + } + /// `org/name`, the canonical package identifier. pub fn full_name(&self) -> String { format!("{}/{}", self.package.org, self.package.name) From 92fbd60f3f45a42108888bb35fe36fffc34551eb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:42:13 -0500 Subject: [PATCH 006/122] =?UTF-8?q?manifest:=20[install].dir=20=E2=80=94?= =?UTF-8?q?=20configurable=20dependency=20location?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an [install] section with a `dir` (default `zed_modules`; e.g. `.vendor/.zed` or `.deps/.zed`) plus an optional `adapter`, and a Manifest::modules_dir() helper. The dir must be a safe relative path (InvalidInstallDir otherwise). This lets zed COMPLEMENT npm/maven rather than replace them: the few zed-sourced deps land in a project-relative tree the ecosystem adapter then wires into the toolchain. Co-Authored-By: Claude Fable 5 --- src/manifest.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++ tests/roundtrip.rs | 21 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 8a5bd71..7c5a389 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -73,6 +73,14 @@ pub struct Manifest { 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, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -144,6 +152,28 @@ pub struct ScriptsSection { 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, +} + +impl InstallSection { + pub fn is_empty(&self) -> bool { + self.dir.is_none() && self.adapter.is_none() + } +} + /// 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 @@ -205,6 +235,8 @@ pub enum ManifestError { InvalidBuild(String), #[error("invalid workspace member pattern `{0}`")] InvalidWorkspaceMember(String), + #[error("invalid install dir `{0}`: {1}")] + InvalidInstallDir(String, String), #[error("manifest toml error: {0}")] Toml(String), } @@ -359,9 +391,28 @@ impl Manifest { } } } + 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) + } + /// True when this manifest declares a non-empty monorepo workspace. pub fn is_workspace_root(&self) -> bool { self.workspace diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 5360209..7e3cf8d 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -43,6 +43,27 @@ fn manifest_roundtrip() { 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!\""); From 478dad021ab67450aa98528ea579a34b6352aec0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:42:37 -0500 Subject: [PATCH 007/122] fmt: format install_dir roundtrip test Co-Authored-By: Claude Fable 5 --- tests/roundtrip.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 7e3cf8d..f4aefcc 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -46,7 +46,10 @@ fn manifest_roundtrip() { #[test] fn install_dir_defaults_and_overrides() { // No [install] section -> the default dep dir. - assert_eq!(Manifest::parse(SAMPLE).unwrap().modules_dir(), "zed_modules"); + 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"); @@ -58,7 +61,10 @@ fn install_dir_defaults_and_overrides() { 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(_, _))), + matches!( + Manifest::parse(&src), + Err(ManifestError::InvalidInstallDir(_, _)) + ), "expected {bad} rejected" ); } From ad858f3b96eff195c0f04f43f5e4f49da2c76ef2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:57:01 -0500 Subject: [PATCH 008/122] schemas: regenerate for [install] section The InstallSection addition (install.dir / install.adapter) landed in the Rust types but the committed JSON schemas were never regenerated, so the "Schemas are up to date" CI gate failed. Regenerated via `cargo run --example generate_schemas`. Co-Authored-By: Claude Opus 5 (1M context) --- schemas/manifest.json | 24 ++++++++++++++++++++++++ schemas/publish-meta.json | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/schemas/manifest.json b/schemas/manifest.json index 5428d7e..968e208 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -36,6 +36,10 @@ "type": "string" } }, + "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" + }, "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" @@ -94,6 +98,26 @@ "command" ] }, + "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" + ] + } + } + }, "OverridesSection": { "description": "Consumer-side dependency patches, keyed by `org/name`.", "type": "object", diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index f63d3ed..52c037a 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -66,6 +66,26 @@ "command" ] }, + "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" + ] + } + } + }, "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", @@ -102,6 +122,10 @@ "type": "string" } }, + "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" + }, "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" From 17ee671dc33c5e6eadd7936787918baaf3154dd8 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Sat, 25 Jul 2026 16:34:02 -0500 Subject: [PATCH 009/122] Audit-log contract: path helper + AuditAction/AuditEntry DTOs The shared contract for the org audit trail (zed-docs issue #7), so the server, CLI, and generated client schemas cannot disagree on it. `AuditEntry` carries the raw `action` string alongside a parsed `action_kind`, and `AuditAction::parse` returns Option: an unrecognized action from a newer server deserializes cleanly on an older client instead of failing the whole response. Co-Authored-By: Claude Opus 5 (1M context) --- src/registry.rs | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/registry.rs b/src/registry.rs index d70c6f3..837a67b 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -61,6 +61,12 @@ pub fn orgs_path() -> String { format!("{API_V1}/orgs") } +/// `GET ?limit=` (bearer token, org `owner` or admin) — the org's audit log: +/// who changed published state, what, and when (zed-docs issue #7 governance). +pub fn audit_path(org: &str) -> String { + format!("{API_V1}/orgs/{org}/audit") +} + /// `GET` — liveness probe. pub fn healthz_path() -> String { "/healthz".to_string() @@ -173,6 +179,76 @@ pub struct SearchResponse { pub items: Vec, } +/// 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, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AuditLogResponse { + pub org: String, + /// Most recent first. + pub entries: Vec, +} + /// Error body returned with any non-2xx status. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct ApiError { From 5491be699ff31b00d9c1b50f3455900f236da47d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 17:01:46 -0500 Subject: [PATCH 010/122] =?UTF-8?q?manifest:=20[targets]=20=E2=80=94=20pol?= =?UTF-8?q?yglot=20packages=20that=20fan=20out=20one=20package=20per=20lan?= =?UTF-8?q?guage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client repo (fiducia-clients, zed-clients, …) ships the same library for several ecosystems. Installing all of them into every consumer is wrong: a Java build should never download the Node sources. [targets.] declares one language subtree per ecosystem: [targets.nodejs] dir = "clients/ts" adapter = "node" [targets.java] dir = "clients/java" adapter = "java" [targets.golang] dir = "clients/go" Each target is its own PUBLISHED package, named - by default (overridable per target), so one repo and one version fan out to fiducia/fiducia-clients-{nodejs,java,golang}@1.1.2 — a consumer depends on the one it needs and downloads only those bytes. manifest_for_target() derives the standalone manifest that ships inside each artifact: same org/version/repo, the target's own name and adapter, and no [targets] (a slice is single-language by construction, so fan-out cannot recurse). [install].target + target_subdir() cover the consumer side for path/workspace deps that point at the polyglot root. Target names and dirs are validated (slug names, no absolute/.. dirs). Packages without [targets] are entirely unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- src/manifest.rs | 183 +++++++++++++++++++++++++++++++- tests/roundtrip.rs | 254 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 436 insertions(+), 1 deletion(-) diff --git a/src/manifest.rs b/src/manifest.rs index 7c5a389..3abbcf2 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -81,6 +81,15 @@ pub struct Manifest { /// `.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)] @@ -166,14 +175,56 @@ pub struct InstallSection { /// 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.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, +} + /// 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 @@ -237,6 +288,8 @@ pub enum ManifestError { InvalidWorkspaceMember(String), #[error("invalid install dir `{0}`: {1}")] InvalidInstallDir(String, String), + #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), #[error("manifest toml error: {0}")] Toml(String), } @@ -250,6 +303,12 @@ pub fn is_slug(s: &str) -> bool { .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, '/'); @@ -345,6 +404,30 @@ impl Manifest { )); } } + 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), + )); + } + } + // 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('.') @@ -413,6 +496,104 @@ impl Manifest { .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)) + }) + } + + /// 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), + }); + 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; + Some(derived) + } + + /// 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); + }; + match self.targets.get(requested) { + 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(", ") + ), + )) + } + } + } + /// True when this manifest declares a non-empty monorepo workspace. pub fn is_workspace_root(&self) -> bool { self.workspace diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index f4aefcc..76cb7e5 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -133,3 +133,257 @@ 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(_, _)) + )); +} + +#[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" + ); + } +} From 1d815bb5609c102d409bc98ec45646865b9ea2ae Mon Sep 17 00:00:00 2001 From: alex-mills Date: Sat, 25 Jul 2026 17:05:12 -0500 Subject: [PATCH 011/122] schemas: regenerate for [targets] The schema-drift gate caught it: [targets] landed in the manifest without regenerating schemas/, which the non-Rust SDKs codegen from. Co-Authored-By: Claude Fable 5 --- schemas/manifest.json | 41 +++++++++++++++++++++++++++++++++++++++ schemas/publish-meta.json | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/schemas/manifest.json b/schemas/manifest.json index 968e208..7026acd 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -62,6 +62,13 @@ "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": [ @@ -115,6 +122,13 @@ "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" + ] } } }, @@ -239,6 +253,33 @@ } } }, + "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" + }, + "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" + ] + } + }, + "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", diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 52c037a..c30177e 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -83,6 +83,13 @@ "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" + ] } } }, @@ -148,6 +155,13 @@ "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": [ @@ -285,6 +299,33 @@ } } }, + "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" + }, + "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" + ] + } + }, + "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", From b676f193b625619b11e1048d01a1c0a1a124562f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 17:14:20 -0500 Subject: [PATCH 012/122] feat: publish isolated packages from polyglot targets --- schemas/manifest.json | 41 ++++++++++++++++++++++++++++++++ schemas/publish-meta.json | 41 ++++++++++++++++++++++++++++++++ src/excludes.rs | 18 ++++++++++++++ src/manifest.rs | 49 +++++++++++++++++++++++++++++++++++++++ tests/roundtrip.rs | 36 ++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+) diff --git a/schemas/manifest.json b/schemas/manifest.json index 968e208..7026acd 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -62,6 +62,13 @@ "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": [ @@ -115,6 +122,13 @@ "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" + ] } } }, @@ -239,6 +253,33 @@ } } }, + "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" + }, + "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" + ] + } + }, + "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", diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 52c037a..c30177e 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -83,6 +83,13 @@ "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" + ] } } }, @@ -148,6 +155,13 @@ "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": [ @@ -285,6 +299,33 @@ } } }, + "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" + }, + "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" + ] + } + }, + "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", diff --git a/src/excludes.rs b/src/excludes.rs index 31f900d..ae8d931 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -32,6 +32,24 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ ".zed/**", ".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. diff --git a/src/manifest.rs b/src/manifest.rs index 3abbcf2..9b94037 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -404,6 +404,8 @@ impl Manifest { )); } } + let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new(); for (name, target) in &self.targets { if !is_target_name(name) { return Err(ManifestError::InvalidTarget( @@ -417,6 +419,53 @@ impl Manifest { 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(adapter) = target.adapter.as_deref() + && !matches!(adapter, "node" | "java" | "none") + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + ), + )); + } } // A blank request means "no target", the same way a blank // `[install].dir` falls back to the default rather than erroring. diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 76cb7e5..0afac66 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -124,6 +124,9 @@ fn excludes_respect_include_readme() { assert!(readme_kept.iter().any(|p| p == "extra/**")); assert!(DEFAULT_EXCLUDES.contains(&".github/**")); + 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")); } @@ -227,6 +230,39 @@ fn target_dirs_and_names_are_validated() { 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] From 940befe36227f3fd6a23ae718c9131caae0f3978 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Sun, 26 Jul 2026 10:17:30 -0500 Subject: [PATCH 013/122] registry: package tags + list/RAG DTOs (PackageListResponse, SemanticSearch*, EmbeddingUpsertRequest); regen schemas Companion to zed-api-server's package_embedding (pgvector 2050) + tags work. Co-Authored-By: Claude Fable 5 --- examples/generate_schemas.rs | 6 ++ schemas/embedding-upsert-request.json | 28 +++++++++ schemas/package-list-response.json | 60 +++++++++++++++++++ schemas/package-metadata.json | 7 +++ schemas/search-response.json | 7 +++ schemas/semantic-search-request.json | 37 ++++++++++++ schemas/semantic-search-response.json | 51 ++++++++++++++++ src/excludes.rs | 9 +++ src/registry.rs | 85 ++++++++++++++++++++++++++- 9 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 schemas/embedding-upsert-request.json create mode 100644 schemas/package-list-response.json create mode 100644 schemas/semantic-search-request.json create mode 100644 schemas/semantic-search-response.json diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 0db10a7..0de25cb 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -38,4 +38,10 @@ fn main() { 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/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/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 index 578460a..5079cb0 100644 --- a/schemas/package-metadata.json +++ b/schemas/package-metadata.json @@ -24,6 +24,13 @@ "repo_url": { "type": "string" }, + "tags": { + "description": "Free-form tags for filtering/discovery (multi-tag lookup).", + "type": "array", + "items": { + "type": "string" + } + }, "vcs": { "$ref": "#/$defs/Vcs" }, diff --git a/schemas/search-response.json b/schemas/search-response.json index 38e6191..86d2a0c 100644 --- a/schemas/search-response.json +++ b/schemas/search-response.json @@ -38,6 +38,13 @@ }, "org": { "type": "string" + }, + "tags": { + "description": "Free-form tags for filtering/discovery.", + "type": "array", + "items": { + "type": "string" + } } }, "required": [ 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/src/excludes.rs b/src/excludes.rs index ae8d931..7481e5a 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -18,7 +18,16 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ "**/*.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", diff --git a/src/registry.rs b/src/registry.rs index 837a67b..61ce4ed 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -37,11 +37,30 @@ pub fn artifact_path(sha256: &str) -> String { format!("{API_V1}/artifacts/{sha256}") } -/// `GET ?q=` — search packages. +/// `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. @@ -80,6 +99,9 @@ pub struct PackageSummary { 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)] @@ -98,6 +120,9 @@ pub struct PackageMetadata { 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, } @@ -179,6 +204,64 @@ pub struct SearchResponse { 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. From 90534dd8468d98281356f2767f1a0b498598f868 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sun, 26 Jul 2026 12:11:03 -0500 Subject: [PATCH 014/122] fix: exclude zed pack output from artifacts --- src/excludes.rs | 1 + tests/roundtrip.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/excludes.rs b/src/excludes.rs index 7481e5a..d174841 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -39,6 +39,7 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ "CHANGELOG*", ".zedignore", ".zed/**", + ".zed-pack/**", ".zpkg.lock", "zed_modules/**", "node_modules/**", diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 0afac66..4372b28 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -124,6 +124,7 @@ fn excludes_respect_include_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/**")); From 415e871b1fb3dd97744c134351408a3224805dfb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 00:01:34 -0500 Subject: [PATCH 015/122] Exclude generated zed pack output from artifacts Support whole-repository targets safely by excluding generated pack output and covering the boundary in tests. --- src/excludes.rs | 1 + tests/roundtrip.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/excludes.rs b/src/excludes.rs index 7481e5a..d174841 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -39,6 +39,7 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ "CHANGELOG*", ".zedignore", ".zed/**", + ".zed-pack/**", ".zpkg.lock", "zed_modules/**", "node_modules/**", diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 0afac66..4372b28 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -124,6 +124,7 @@ fn excludes_respect_include_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/**")); From ce84fde03fde66624b63d6a0b24df7a75abca1f1 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:21:48 -0500 Subject: [PATCH 016/122] ci: apply DEN-100 native release schema --- .github/workflows/apply-den-100-temporary.yml | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 .github/workflows/apply-den-100-temporary.yml diff --git a/.github/workflows/apply-den-100-temporary.yml b/.github/workflows/apply-den-100-temporary.yml new file mode 100644 index 0000000..ff34c9e --- /dev/null +++ b/.github/workflows/apply-den-100-temporary.yml @@ -0,0 +1,363 @@ +name: Apply DEN-100 native release schema (temporary) + +on: + push: + branches: + - feat/den-100-native-release-routing + workflow_dispatch: + +permissions: + contents: write + +jobs: + apply: + if: github.repository == 'zed-pkg/zed-interfaces' && github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: feat/den-100-native-release-routing + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt + - name: Apply typed native release routing + run: | + python3 - <<'PY' + from pathlib import Path + + path = Path("src/manifest.rs") + text = path.read_text(encoding="utf-8") + + target_old = ''' #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, + } + + /// A post-extract build step.'''.replace(" ", " ") + target_new = ''' #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, + /// Optional routing to the target's native ecosystem registry. This is + /// declarative metadata only: the native manifest remains authoritative, + /// and arbitrary commands are intentionally not representable here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native: Option, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct NativeReleaseSection { + pub registry: NativeRegistry, + pub package: String, + } + + #[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, + )] + #[serde(rename_all = "kebab-case")] + pub enum NativeRegistry { + Npm, + CratesIo, + } + + impl NativeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + } + } + + 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), + }; + if valid { + Ok(()) + } else { + Err(format!( + "package `{package}` is not a valid {} package identity", + self.as_str() + )) + } + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct NativeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: NativeRegistry, + pub package: String, + } + + fn is_valid_npm_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 214 + && !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.ends_with(['-', '_']) + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) + } + + /// A post-extract build step.'''.replace(" ", " ") + assert target_old in text, "TargetSection insertion point changed" + text = text.replace(target_old, target_new, 1) + + error_old = ''' #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), + #[error("manifest toml error: {0}")]'''.replace(" ", " ") + error_new = ''' #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), + #[error("invalid native release route for target `{0}`: {1}")] + InvalidNativeRoute(String, String), + #[error("manifest toml error: {0}")]'''.replace(" ", " ") + assert error_old in text, "ManifestError insertion point changed" + text = text.replace(error_old, error_new, 1) + + maps_old = ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new();'''.replace(" ", " ") + maps_new = ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new(); + let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new();'''.replace(" ", " ") + assert maps_old in text, "target validation maps changed" + text = text.replace(maps_old, maps_new, 1) + + adapter_old = ''' if let Some(adapter) = target.adapter.as_deref() + && !matches!(adapter, "node" | "java" | "none") + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + ), + )); + } + }'''.replace(" ", " ") + adapter_new = ''' if let Some(adapter) = target.adapter.as_deref() + && !matches!(adapter, "node" | "java" | "none") + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + ), + )); + } + 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(), + )); + } + native + .registry + .validate_package(&native.package) + .map_err(|reason| { + ManifestError::InvalidNativeRoute(name.clone(), reason) + })?; + let route = (native.registry, native.package.clone()); + 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 + ), + )); + } + } + }'''.replace(" ", " ") + assert adapter_old in text, "target adapter validation changed" + text = text.replace(adapter_old, adapter_new, 1) + + method_old = ''' /// 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)> {'''.replace(" ", " ") + method_new = ''' /// 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.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(), + }) + }) + .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)> {'''.replace(" ", " ") + assert method_old in text, "native route method insertion point changed" + text = text.replace(method_old, method_new, 1) + + path.write_text(text, encoding="utf-8") + + tests = r'''use zed_interfaces::manifest::{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" + "#, + )) + .unwrap(); + + let routes = parsed.native_release_routes(); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].target, "nodejs"); + assert_eq!(routes[0].registry, NativeRegistry::Npm); + assert_eq!(routes[0].package, "@acme/client"); + assert_eq!(routes[1].target, "rust"); + assert_eq!(routes[1].registry, NativeRegistry::CratesIo); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[targets.nodejs.native]")); + assert!(encoded.contains("registry = \"npm\"")); + Manifest::parse(&encoded).unwrap(); + } + + #[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" + "#, + ] { + 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(_, _))); + } + ''' + Path("tests/native_release.rs").write_text( + "\n".join(line[10:] if line.startswith(" ") else line for line in tests.splitlines()) + "\n", + encoding="utf-8", + ) + PY + - name: Format and test + run: | + cargo fmt --all + cargo test --locked + - name: Commit schema and tests to the feature branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/manifest.rs tests/native_release.rs + git commit -m "feat: declare native release routing" + git push origin HEAD:feat/den-100-native-release-routing From 36d99239139a23b01f9f0d2e99ddba634ae8173b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:23:15 -0500 Subject: [PATCH 017/122] ci: run DEN-100 schema patch from PR --- .../workflows/apply-den-100-pr-temporary.yml | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/apply-den-100-pr-temporary.yml diff --git a/.github/workflows/apply-den-100-pr-temporary.yml b/.github/workflows/apply-den-100-pr-temporary.yml new file mode 100644 index 0000000..ef1af4a --- /dev/null +++ b/.github/workflows/apply-den-100-pr-temporary.yml @@ -0,0 +1,58 @@ +name: Apply DEN-100 native release schema from PR (temporary) + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + +jobs: + apply: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'feat/den-100-native-release-routing' && + github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt + - name: Reuse and execute the reviewed patch body + run: | + python3 - <<'PY' + from pathlib import Path + + source = Path('.github/workflows/apply-den-100-temporary.yml').read_text(encoding='utf-8') + marker = " python3 - <<'PY'\n" + start = source.index(marker) + len(marker) + end = source.index("\n PY", start) + block = source[start:end] + script = "\n".join( + line[10:] if line.startswith(" ") else line + for line in block.splitlines() + ) + exec(compile(script, 'apply-den-100', 'exec')) + PY + - name: Format and test + run: | + cargo fmt --all + cargo test --locked + - name: Commit schema and tests to the feature branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/manifest.rs tests/native_release.rs + if git diff --cached --quiet; then + echo "Schema and tests are already current." + exit 0 + fi + git commit -m "feat: declare native release routing" + git push origin HEAD:feat/den-100-native-release-routing From 11b42399143651dea6ac1929834117365350a156 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:27:21 -0500 Subject: [PATCH 018/122] chore: add deterministic native schema patch --- scripts/apply_native_release_schema.py | 225 +++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 scripts/apply_native_release_schema.py diff --git a/scripts/apply_native_release_schema.py b/scripts/apply_native_release_schema.py new file mode 100644 index 0000000..d2707d7 --- /dev/null +++ b/scripts/apply_native_release_schema.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Apply the DEN-100 native release routing schema to src/manifest.rs. + +Temporary branch-local helper. It is intentionally deterministic and refuses to +continue when an expected insertion point has changed. +""" + +from pathlib import Path + + +PATH = Path("src/manifest.rs") +text = PATH.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly one insertion point, found {count}") + text = text.replace(old, new, 1) + + +replace_once( + ''' #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, +} + +/// A post-extract build step.''', + ''' #[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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseSection { + pub registry: NativeRegistry, + pub package: String, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum NativeRegistry { + Npm, + CratesIo, +} + +impl NativeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + } + } + + 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), + }; + if valid { + Ok(()) + } else { + Err(format!( + "package `{package}` is not a valid {} package identity", + self.as_str() + )) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: NativeRegistry, + pub package: String, +} + +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, '-' | '_')) +} + +/// A post-extract build step.''', + "TargetSection/native types", +) + +replace_once( + ''' #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), + #[error("manifest toml error: {0}")]''', + ''' #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), + #[error("invalid native release route for target `{0}`: {1}")] + InvalidNativeRoute(String, String), + #[error("manifest toml error: {0}")]''', + "ManifestError", +) + +replace_once( + ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new();''', + ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new(); + let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new();''', + "target validation maps", +) + +replace_once( + ''' if let Some(adapter) = target.adapter.as_deref() + && !matches!(adapter, "node" | "java" | "none") + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + ), + )); + } + }''', + ''' if let Some(adapter) = target.adapter.as_deref() + && !matches!(adapter, "node" | "java" | "none") + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + ), + )); + } + 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(), + )); + } + native + .registry + .validate_package(&native.package) + .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; + let route = (native.registry, native.package.clone()); + 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 + ), + )); + } + } + }''', + "native route validation", +) + +replace_once( + ''' /// 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)> {''', + ''' /// 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.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(), + }) + }) + .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)> {''', + "native_release_routes method", +) + +PATH.write_text(text, encoding="utf-8") +print(f"updated {PATH}") From 3f0b919ded0b75c09b78bc1a67e69ca9f91436de Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:27:36 -0500 Subject: [PATCH 019/122] test: cover native release routing --- tests/native_release.rs | 115 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/native_release.rs diff --git a/tests/native_release.rs b/tests/native_release.rs new file mode 100644 index 0000000..5109af8 --- /dev/null +++ b/tests/native_release.rs @@ -0,0 +1,115 @@ +use zed_interfaces::manifest::{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" +"#, + )) + .unwrap(); + + let routes = parsed.native_release_routes(); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].target, "nodejs"); + assert_eq!(routes[0].registry, NativeRegistry::Npm); + assert_eq!(routes[0].package, "@acme/client"); + assert_eq!(routes[1].target, "rust"); + assert_eq!(routes[1].registry, NativeRegistry::CratesIo); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[targets.nodejs.native]")); + assert!(encoded.contains("registry = \"npm\"")); + Manifest::parse(&encoded).unwrap(); +} + +#[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" +"#, + ] { + 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(_, _))); +} From fa5285734e4c9f8a152d48dd103e4bc46b3ebc91 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:27:55 -0500 Subject: [PATCH 020/122] ci: apply native schema from checked-in script --- .../workflows/apply-den-100-pr-temporary.yml | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/.github/workflows/apply-den-100-pr-temporary.yml b/.github/workflows/apply-den-100-pr-temporary.yml index ef1af4a..67d9e12 100644 --- a/.github/workflows/apply-den-100-pr-temporary.yml +++ b/.github/workflows/apply-den-100-pr-temporary.yml @@ -25,27 +25,13 @@ jobs: with: toolchain: stable components: rustfmt - - name: Reuse and execute the reviewed patch body - run: | - python3 - <<'PY' - from pathlib import Path - - source = Path('.github/workflows/apply-den-100-temporary.yml').read_text(encoding='utf-8') - marker = " python3 - <<'PY'\n" - start = source.index(marker) + len(marker) - end = source.index("\n PY", start) - block = source[start:end] - script = "\n".join( - line[10:] if line.startswith(" ") else line - for line in block.splitlines() - ) - exec(compile(script, 'apply-den-100', 'exec')) - PY + - name: Apply typed native release routing + run: python3 scripts/apply_native_release_schema.py - name: Format and test run: | cargo fmt --all cargo test --locked - - name: Commit schema and tests to the feature branch + - name: Commit schema to the feature branch run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" From 86e6cc2573bdc14d53b0feb9b28c2e79bf2fcc65 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:28:25 +0000 Subject: [PATCH 021/122] feat: declare native release routing --- src/manifest.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 9b94037..ad542a9 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -223,6 +223,92 @@ pub struct TargetSection { /// 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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseSection { + pub registry: NativeRegistry, + pub package: String, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum NativeRegistry { + Npm, + CratesIo, +} + +impl NativeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + } + } + + 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), + }; + if valid { + Ok(()) + } else { + Err(format!( + "package `{package}` is not a valid {} package identity", + self.as_str() + )) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: NativeRegistry, + pub package: String, +} + +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, '-' | '_')) } /// A post-extract build step. Because compiled output is OS/arch-specific, @@ -290,6 +376,8 @@ pub enum ManifestError { InvalidInstallDir(String, String), #[error("invalid target `{0}`: {1}")] InvalidTarget(String, String), + #[error("invalid native release route for target `{0}`: {1}")] + InvalidNativeRoute(String, String), #[error("manifest toml error: {0}")] Toml(String), } @@ -406,6 +494,7 @@ impl Manifest { } let mut target_dirs = BTreeMap::<&str, &str>::new(); let mut published_names = BTreeMap::::new(); + let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new(); for (name, target) in &self.targets { if !is_target_name(name) { return Err(ManifestError::InvalidTarget( @@ -466,6 +555,30 @@ impl Manifest { ), )); } + 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(), + )); + } + native + .registry + .validate_package(&native.package) + .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; + let route = (native.registry, native.package.clone()); + 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. @@ -570,6 +683,22 @@ impl Manifest { }) } + /// 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.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(), + }) + }) + .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)> { From b0d2c5dd1ccd70d9b95c892a0a44e3ab60a4866c Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:29:03 -0500 Subject: [PATCH 022/122] chore: remove temporary schema workflow --- .../workflows/apply-den-100-pr-temporary.yml | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/apply-den-100-pr-temporary.yml diff --git a/.github/workflows/apply-den-100-pr-temporary.yml b/.github/workflows/apply-den-100-pr-temporary.yml deleted file mode 100644 index 67d9e12..0000000 --- a/.github/workflows/apply-den-100-pr-temporary.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Apply DEN-100 native release schema from PR (temporary) - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: write - -jobs: - apply: - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.head_ref == 'feat/den-100-native-release-routing' && - github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - name: Apply typed native release routing - run: python3 scripts/apply_native_release_schema.py - - name: Format and test - run: | - cargo fmt --all - cargo test --locked - - name: Commit schema to the feature branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/manifest.rs tests/native_release.rs - if git diff --cached --quiet; then - echo "Schema and tests are already current." - exit 0 - fi - git commit -m "feat: declare native release routing" - git push origin HEAD:feat/den-100-native-release-routing From a613fd1535b5ee3f2faafb67873db137860e4243 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:29:11 -0500 Subject: [PATCH 023/122] chore: remove temporary schema patcher --- .github/workflows/apply-den-100-temporary.yml | 363 ------------------ 1 file changed, 363 deletions(-) delete mode 100644 .github/workflows/apply-den-100-temporary.yml diff --git a/.github/workflows/apply-den-100-temporary.yml b/.github/workflows/apply-den-100-temporary.yml deleted file mode 100644 index ff34c9e..0000000 --- a/.github/workflows/apply-den-100-temporary.yml +++ /dev/null @@ -1,363 +0,0 @@ -name: Apply DEN-100 native release schema (temporary) - -on: - push: - branches: - - feat/den-100-native-release-routing - workflow_dispatch: - -permissions: - contents: write - -jobs: - apply: - if: github.repository == 'zed-pkg/zed-interfaces' && github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: feat/den-100-native-release-routing - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - name: Apply typed native release routing - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path("src/manifest.rs") - text = path.read_text(encoding="utf-8") - - target_old = ''' #[serde(default, skip_serializing_if = "Option::is_none")] - pub adapter: Option, - } - - /// A post-extract build step.'''.replace(" ", " ") - target_new = ''' #[serde(default, skip_serializing_if = "Option::is_none")] - pub adapter: Option, - /// Optional routing to the target's native ecosystem registry. This is - /// declarative metadata only: the native manifest remains authoritative, - /// and arbitrary commands are intentionally not representable here. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native: Option, - } - - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] - pub struct NativeReleaseSection { - pub registry: NativeRegistry, - pub package: String, - } - - #[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, - )] - #[serde(rename_all = "kebab-case")] - pub enum NativeRegistry { - Npm, - CratesIo, - } - - impl NativeRegistry { - pub fn as_str(self) -> &'static str { - match self { - Self::Npm => "npm", - Self::CratesIo => "crates-io", - } - } - - 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), - }; - if valid { - Ok(()) - } else { - Err(format!( - "package `{package}` is not a valid {} package identity", - self.as_str() - )) - } - } - } - - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] - pub struct NativeReleaseRoute { - pub target: String, - pub dir: String, - pub registry: NativeRegistry, - pub package: String, - } - - fn is_valid_npm_component(value: &str) -> bool { - !value.is_empty() - && value.len() <= 214 - && !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.ends_with(['-', '_']) - && value - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) - } - - /// A post-extract build step.'''.replace(" ", " ") - assert target_old in text, "TargetSection insertion point changed" - text = text.replace(target_old, target_new, 1) - - error_old = ''' #[error("invalid target `{0}`: {1}")] - InvalidTarget(String, String), - #[error("manifest toml error: {0}")]'''.replace(" ", " ") - error_new = ''' #[error("invalid target `{0}`: {1}")] - InvalidTarget(String, String), - #[error("invalid native release route for target `{0}`: {1}")] - InvalidNativeRoute(String, String), - #[error("manifest toml error: {0}")]'''.replace(" ", " ") - assert error_old in text, "ManifestError insertion point changed" - text = text.replace(error_old, error_new, 1) - - maps_old = ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); - let mut published_names = BTreeMap::::new();'''.replace(" ", " ") - maps_new = ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); - let mut published_names = BTreeMap::::new(); - let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new();'''.replace(" ", " ") - assert maps_old in text, "target validation maps changed" - text = text.replace(maps_old, maps_new, 1) - - adapter_old = ''' if let Some(adapter) = target.adapter.as_deref() - && !matches!(adapter, "node" | "java" | "none") - { - return Err(ManifestError::InvalidTarget( - name.clone(), - format!( - "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" - ), - )); - } - }'''.replace(" ", " ") - adapter_new = ''' if let Some(adapter) = target.adapter.as_deref() - && !matches!(adapter, "node" | "java" | "none") - { - return Err(ManifestError::InvalidTarget( - name.clone(), - format!( - "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" - ), - )); - } - 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(), - )); - } - native - .registry - .validate_package(&native.package) - .map_err(|reason| { - ManifestError::InvalidNativeRoute(name.clone(), reason) - })?; - let route = (native.registry, native.package.clone()); - 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 - ), - )); - } - } - }'''.replace(" ", " ") - assert adapter_old in text, "target adapter validation changed" - text = text.replace(adapter_old, adapter_new, 1) - - method_old = ''' /// 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)> {'''.replace(" ", " ") - method_new = ''' /// 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.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(), - }) - }) - .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)> {'''.replace(" ", " ") - assert method_old in text, "native route method insertion point changed" - text = text.replace(method_old, method_new, 1) - - path.write_text(text, encoding="utf-8") - - tests = r'''use zed_interfaces::manifest::{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" - "#, - )) - .unwrap(); - - let routes = parsed.native_release_routes(); - assert_eq!(routes.len(), 2); - assert_eq!(routes[0].target, "nodejs"); - assert_eq!(routes[0].registry, NativeRegistry::Npm); - assert_eq!(routes[0].package, "@acme/client"); - assert_eq!(routes[1].target, "rust"); - assert_eq!(routes[1].registry, NativeRegistry::CratesIo); - - let encoded = parsed.to_toml_string().unwrap(); - assert!(encoded.contains("[targets.nodejs.native]")); - assert!(encoded.contains("registry = \"npm\"")); - Manifest::parse(&encoded).unwrap(); - } - - #[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" - "#, - ] { - 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(_, _))); - } - ''' - Path("tests/native_release.rs").write_text( - "\n".join(line[10:] if line.startswith(" ") else line for line in tests.splitlines()) + "\n", - encoding="utf-8", - ) - PY - - name: Format and test - run: | - cargo fmt --all - cargo test --locked - - name: Commit schema and tests to the feature branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/manifest.rs tests/native_release.rs - git commit -m "feat: declare native release routing" - git push origin HEAD:feat/den-100-native-release-routing From df173d7e233aee510c51e0c78066dc0178038057 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:29:17 -0500 Subject: [PATCH 024/122] chore: remove temporary schema helper --- scripts/apply_native_release_schema.py | 225 ------------------------- 1 file changed, 225 deletions(-) delete mode 100644 scripts/apply_native_release_schema.py diff --git a/scripts/apply_native_release_schema.py b/scripts/apply_native_release_schema.py deleted file mode 100644 index d2707d7..0000000 --- a/scripts/apply_native_release_schema.py +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the DEN-100 native release routing schema to src/manifest.rs. - -Temporary branch-local helper. It is intentionally deterministic and refuses to -continue when an expected insertion point has changed. -""" - -from pathlib import Path - - -PATH = Path("src/manifest.rs") -text = PATH.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly one insertion point, found {count}") - text = text.replace(old, new, 1) - - -replace_once( - ''' #[serde(default, skip_serializing_if = "Option::is_none")] - pub adapter: Option, -} - -/// A post-extract build step.''', - ''' #[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. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct NativeReleaseSection { - pub registry: NativeRegistry, - pub package: String, -} - -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, -)] -#[serde(rename_all = "kebab-case")] -pub enum NativeRegistry { - Npm, - CratesIo, -} - -impl NativeRegistry { - pub fn as_str(self) -> &'static str { - match self { - Self::Npm => "npm", - Self::CratesIo => "crates-io", - } - } - - 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), - }; - if valid { - Ok(()) - } else { - Err(format!( - "package `{package}` is not a valid {} package identity", - self.as_str() - )) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct NativeReleaseRoute { - pub target: String, - pub dir: String, - pub registry: NativeRegistry, - pub package: String, -} - -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, '-' | '_')) -} - -/// A post-extract build step.''', - "TargetSection/native types", -) - -replace_once( - ''' #[error("invalid target `{0}`: {1}")] - InvalidTarget(String, String), - #[error("manifest toml error: {0}")]''', - ''' #[error("invalid target `{0}`: {1}")] - InvalidTarget(String, String), - #[error("invalid native release route for target `{0}`: {1}")] - InvalidNativeRoute(String, String), - #[error("manifest toml error: {0}")]''', - "ManifestError", -) - -replace_once( - ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); - let mut published_names = BTreeMap::::new();''', - ''' let mut target_dirs = BTreeMap::<&str, &str>::new(); - let mut published_names = BTreeMap::::new(); - let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new();''', - "target validation maps", -) - -replace_once( - ''' if let Some(adapter) = target.adapter.as_deref() - && !matches!(adapter, "node" | "java" | "none") - { - return Err(ManifestError::InvalidTarget( - name.clone(), - format!( - "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" - ), - )); - } - }''', - ''' if let Some(adapter) = target.adapter.as_deref() - && !matches!(adapter, "node" | "java" | "none") - { - return Err(ManifestError::InvalidTarget( - name.clone(), - format!( - "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" - ), - )); - } - 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(), - )); - } - native - .registry - .validate_package(&native.package) - .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; - let route = (native.registry, native.package.clone()); - 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 - ), - )); - } - } - }''', - "native route validation", -) - -replace_once( - ''' /// 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)> {''', - ''' /// 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.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(), - }) - }) - .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)> {''', - "native_release_routes method", -) - -PATH.write_text(text, encoding="utf-8") -print(f"updated {PATH}") From 90534bcc74be38d876c08381afb4b9152f405d34 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:30:22 -0500 Subject: [PATCH 025/122] ci: regenerate native release schemas --- .../regenerate-schemas-temporary.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/regenerate-schemas-temporary.yml diff --git a/.github/workflows/regenerate-schemas-temporary.yml b/.github/workflows/regenerate-schemas-temporary.yml new file mode 100644 index 0000000..a67fef0 --- /dev/null +++ b/.github/workflows/regenerate-schemas-temporary.yml @@ -0,0 +1,39 @@ +name: Regenerate native release schemas (temporary) + +on: + pull_request: + types: [synchronize] + +permissions: + contents: write + +jobs: + regenerate: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'feat/den-100-native-release-routing' && + github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + - name: Regenerate committed JSON schemas + run: cargo run --example generate_schemas + - name: Commit generated schemas to the feature branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add schemas/ + if git diff --cached --quiet; then + echo "Schemas are already current." + exit 0 + fi + git commit -m "schemas: add native release routing" + git push origin HEAD:feat/den-100-native-release-routing From b9949e9e02f7f195fe324acb32f6d892840b9784 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:30:49 +0000 Subject: [PATCH 026/122] schemas: add native release routing --- schemas/manifest.json | 33 +++++++++++++++++++++++++++++++++ schemas/publish-meta.json | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/schemas/manifest.json b/schemas/manifest.json index 7026acd..dbe2eaf 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -132,6 +132,28 @@ } } }, + "NativeRegistry": { + "type": "string", + "enum": [ + "npm", + "crates-io" + ] + }, + "NativeReleaseSection": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + } + }, + "required": [ + "registry", + "package" + ] + }, "OverridesSection": { "description": "Consumer-side dependency patches, keyed by `org/name`.", "type": "object", @@ -274,6 +296,17 @@ "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.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] } }, "required": [ diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index c30177e..751ca46 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -178,6 +178,28 @@ "package" ] }, + "NativeRegistry": { + "type": "string", + "enum": [ + "npm", + "crates-io" + ] + }, + "NativeReleaseSection": { + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + } + }, + "required": [ + "registry", + "package" + ] + }, "OverridesSection": { "description": "Consumer-side dependency patches, keyed by `org/name`.", "type": "object", @@ -320,6 +342,17 @@ "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.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] } }, "required": [ From 564c0ae0e48612d985b8d937ecd1c62e2871efdf Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 14:31:14 -0500 Subject: [PATCH 027/122] chore: remove temporary schema regeneration workflow --- .../regenerate-schemas-temporary.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/regenerate-schemas-temporary.yml diff --git a/.github/workflows/regenerate-schemas-temporary.yml b/.github/workflows/regenerate-schemas-temporary.yml deleted file mode 100644 index a67fef0..0000000 --- a/.github/workflows/regenerate-schemas-temporary.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Regenerate native release schemas (temporary) - -on: - pull_request: - types: [synchronize] - -permissions: - contents: write - -jobs: - regenerate: - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.head_ref == 'feat/den-100-native-release-routing' && - github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - - name: Regenerate committed JSON schemas - run: cargo run --example generate_schemas - - name: Commit generated schemas to the feature branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add schemas/ - if git diff --cached --quiet; then - echo "Schemas are already current." - exit 0 - fi - git commit -m "schemas: add native release routing" - git push origin HEAD:feat/den-100-native-release-routing From f7c253f0d218e17fb6e59df2cf34511c8db05ad4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:25:04 -0500 Subject: [PATCH 028/122] chore: add deterministic pub.dev routing patch --- scripts/apply_pubdev_routing.py | 201 ++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 scripts/apply_pubdev_routing.py diff --git a/scripts/apply_pubdev_routing.py b/scripts/apply_pubdev_routing.py new file mode 100644 index 0000000..bb50c24 --- /dev/null +++ b/scripts/apply_pubdev_routing.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Extend native release routing with pub.dev.""" + +from pathlib import Path + + +MANIFEST = Path("src/manifest.rs") +text = MANIFEST.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one insertion point, found {count}") + text = text.replace(old, new, 1) + + +replace_once( + '''pub enum NativeRegistry { + Npm, + CratesIo, +}''', + '''pub enum NativeRegistry { + Npm, + CratesIo, + #[serde(rename = "pub.dev")] + PubDev, +}''', + "NativeRegistry enum", +) + +replace_once( + ''' match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + }''', + ''' match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + Self::PubDev => "pub.dev", + }''', + "NativeRegistry::as_str", +) + +replace_once( + ''' let valid = match self { + Self::Npm => is_valid_npm_package(package), + Self::CratesIo => is_valid_crates_package(package), + };''', + ''' 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), + };''', + "native package validation dispatch", +) + +replace_once( + '''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, '-' | '_')) +} + +/// A post-extract build step.''', + '''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_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 == '_') +} + +/// A post-extract build step.''', + "pub.dev package validator", +) + +MANIFEST.write_text(text, encoding="utf-8") + +TEST = Path("tests/native_release.rs") +tests = TEST.read_text(encoding="utf-8") + +old_routes = '''[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +"#,''' +new_routes = '''[targets.nodejs.native] +registry = "npm" +package = "@acme/client" + +[targets.dart] +dir = "clients/dart" + +[targets.dart.native] +registry = "pub.dev" +package = "acme_client" +"#,''' +if tests.count(old_routes) != 1: + raise SystemExit("route fixture insertion point changed") +tests = tests.replace(old_routes, new_routes, 1) + +tests = tests.replace( + ''' assert_eq!(routes.len(), 2); + assert_eq!(routes[0].target, "nodejs");''', + ''' assert_eq!(routes.len(), 3); + 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");''', + 1, +) +tests = tests.replace( + ''' assert_eq!(routes[0].registry, NativeRegistry::Npm); + assert_eq!(routes[0].package, "@acme/client"); + assert_eq!(routes[1].target, "rust"); + assert_eq!(routes[1].registry, NativeRegistry::CratesIo);''', + ''' assert_eq!(routes[1].registry, NativeRegistry::Npm); + assert_eq!(routes[1].package, "@acme/client"); + assert_eq!(routes[2].target, "rust"); + assert_eq!(routes[2].registry, NativeRegistry::CratesIo);''', + 1, +) +tests = tests.replace( + ''' assert!(encoded.contains("registry = \\"npm\\"")); + Manifest::parse(&encoded).unwrap();''', + ''' assert!(encoded.contains("registry = \\"npm\\"")); + assert!(encoded.contains("registry = \\"pub.dev\\"")); + Manifest::parse(&encoded).unwrap();''', + 1, +) + +invalid_anchor = ''' r#" +[targets.rust] +dir = "clients/rust" +[targets.rust.native] +registry = "crates-io" +package = "acme/client" +"#, + ] {''' +invalid_replacement = ''' 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" +"#, + ] {''' +if tests.count(invalid_anchor) != 1: + raise SystemExit("invalid-route fixture insertion point changed") +tests = tests.replace(invalid_anchor, invalid_replacement, 1) + +TEST.write_text(tests, encoding="utf-8") +print("added pub.dev native routing") From 1f329a25e7fc2ff444e881805c32074194723655 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:25:28 -0500 Subject: [PATCH 029/122] ci: apply pub.dev native routing --- .../apply-pubdev-routing-temporary.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/apply-pubdev-routing-temporary.yml diff --git a/.github/workflows/apply-pubdev-routing-temporary.yml b/.github/workflows/apply-pubdev-routing-temporary.yml new file mode 100644 index 0000000..67677b6 --- /dev/null +++ b/.github/workflows/apply-pubdev-routing-temporary.yml @@ -0,0 +1,45 @@ +name: Apply pub.dev native routing (temporary) + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + +jobs: + apply: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'feat/den-100-pubdev-routing' && + github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt + - name: Apply pub.dev route and tests + run: python3 scripts/apply_pubdev_routing.py + - name: Format, test, and regenerate schemas + run: | + cargo fmt --all + cargo test --locked + cargo run --example generate_schemas + - name: Commit production changes to the feature branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/manifest.rs tests/native_release.rs schemas/ + if git diff --cached --quiet; then + echo "pub.dev routing is already current." + exit 0 + fi + git commit -m "feat: declare pub.dev release routing" + git push origin HEAD:feat/den-100-pubdev-routing From 95e81cb569f8a27eea67aa8ad92d83740bd022f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:26:01 +0000 Subject: [PATCH 030/122] feat: declare pub.dev release routing --- schemas/manifest.json | 3 ++- schemas/publish-meta.json | 3 ++- src/manifest.rs | 19 +++++++++++++++++ tests/native_release.rs | 44 +++++++++++++++++++++++++++++++++------ 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/schemas/manifest.json b/schemas/manifest.json index dbe2eaf..37a8c61 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -136,7 +136,8 @@ "type": "string", "enum": [ "npm", - "crates-io" + "crates-io", + "pub.dev" ] }, "NativeReleaseSection": { diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 751ca46..a584f96 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -182,7 +182,8 @@ "type": "string", "enum": [ "npm", - "crates-io" + "crates-io", + "pub.dev" ] }, "NativeReleaseSection": { diff --git a/src/manifest.rs b/src/manifest.rs index ad542a9..19f3bb2 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -243,6 +243,8 @@ pub struct NativeReleaseSection { pub enum NativeRegistry { Npm, CratesIo, + #[serde(rename = "pub.dev")] + PubDev, } impl NativeRegistry { @@ -250,6 +252,7 @@ impl NativeRegistry { match self { Self::Npm => "npm", Self::CratesIo => "crates-io", + Self::PubDev => "pub.dev", } } @@ -257,6 +260,7 @@ impl NativeRegistry { 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), }; if valid { Ok(()) @@ -311,6 +315,21 @@ fn is_valid_crates_package(value: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) } +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 == '_') +} + /// 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 diff --git a/tests/native_release.rs b/tests/native_release.rs index 5109af8..ea61750 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -34,21 +34,32 @@ adapter = "node" [targets.nodejs.native] registry = "npm" package = "@acme/client" + +[targets.dart] +dir = "clients/dart" + +[targets.dart.native] +registry = "pub.dev" +package = "acme_client" "#, )) .unwrap(); let routes = parsed.native_release_routes(); - assert_eq!(routes.len(), 2); - assert_eq!(routes[0].target, "nodejs"); - assert_eq!(routes[0].registry, NativeRegistry::Npm); - assert_eq!(routes[0].package, "@acme/client"); - assert_eq!(routes[1].target, "rust"); - assert_eq!(routes[1].registry, NativeRegistry::CratesIo); + assert_eq!(routes.len(), 3); + 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, "rust"); + assert_eq!(routes[2].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(); } @@ -84,6 +95,27 @@ 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" "#, ] { assert!(matches!( From 4d5af59f783e6653bfa20f32de0fc2aaab7b21cf Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:26:37 -0500 Subject: [PATCH 031/122] chore: remove temporary pub.dev routing workflow --- .../apply-pubdev-routing-temporary.yml | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/apply-pubdev-routing-temporary.yml diff --git a/.github/workflows/apply-pubdev-routing-temporary.yml b/.github/workflows/apply-pubdev-routing-temporary.yml deleted file mode 100644 index 67677b6..0000000 --- a/.github/workflows/apply-pubdev-routing-temporary.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Apply pub.dev native routing (temporary) - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: write - -jobs: - apply: - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.head_ref == 'feat/den-100-pubdev-routing' && - github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - name: Apply pub.dev route and tests - run: python3 scripts/apply_pubdev_routing.py - - name: Format, test, and regenerate schemas - run: | - cargo fmt --all - cargo test --locked - cargo run --example generate_schemas - - name: Commit production changes to the feature branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/manifest.rs tests/native_release.rs schemas/ - if git diff --cached --quiet; then - echo "pub.dev routing is already current." - exit 0 - fi - git commit -m "feat: declare pub.dev release routing" - git push origin HEAD:feat/den-100-pubdev-routing From 9e112081e2c9e8136a68634f7c9d837290a5668f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:26:44 -0500 Subject: [PATCH 032/122] chore: remove temporary pub.dev routing helper --- scripts/apply_pubdev_routing.py | 201 -------------------------------- 1 file changed, 201 deletions(-) delete mode 100644 scripts/apply_pubdev_routing.py diff --git a/scripts/apply_pubdev_routing.py b/scripts/apply_pubdev_routing.py deleted file mode 100644 index bb50c24..0000000 --- a/scripts/apply_pubdev_routing.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -"""Extend native release routing with pub.dev.""" - -from pathlib import Path - - -MANIFEST = Path("src/manifest.rs") -text = MANIFEST.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one insertion point, found {count}") - text = text.replace(old, new, 1) - - -replace_once( - '''pub enum NativeRegistry { - Npm, - CratesIo, -}''', - '''pub enum NativeRegistry { - Npm, - CratesIo, - #[serde(rename = "pub.dev")] - PubDev, -}''', - "NativeRegistry enum", -) - -replace_once( - ''' match self { - Self::Npm => "npm", - Self::CratesIo => "crates-io", - }''', - ''' match self { - Self::Npm => "npm", - Self::CratesIo => "crates-io", - Self::PubDev => "pub.dev", - }''', - "NativeRegistry::as_str", -) - -replace_once( - ''' let valid = match self { - Self::Npm => is_valid_npm_package(package), - Self::CratesIo => is_valid_crates_package(package), - };''', - ''' 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), - };''', - "native package validation dispatch", -) - -replace_once( - '''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, '-' | '_')) -} - -/// A post-extract build step.''', - '''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_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 == '_') -} - -/// A post-extract build step.''', - "pub.dev package validator", -) - -MANIFEST.write_text(text, encoding="utf-8") - -TEST = Path("tests/native_release.rs") -tests = TEST.read_text(encoding="utf-8") - -old_routes = '''[targets.nodejs.native] -registry = "npm" -package = "@acme/client" -"#,''' -new_routes = '''[targets.nodejs.native] -registry = "npm" -package = "@acme/client" - -[targets.dart] -dir = "clients/dart" - -[targets.dart.native] -registry = "pub.dev" -package = "acme_client" -"#,''' -if tests.count(old_routes) != 1: - raise SystemExit("route fixture insertion point changed") -tests = tests.replace(old_routes, new_routes, 1) - -tests = tests.replace( - ''' assert_eq!(routes.len(), 2); - assert_eq!(routes[0].target, "nodejs");''', - ''' assert_eq!(routes.len(), 3); - 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");''', - 1, -) -tests = tests.replace( - ''' assert_eq!(routes[0].registry, NativeRegistry::Npm); - assert_eq!(routes[0].package, "@acme/client"); - assert_eq!(routes[1].target, "rust"); - assert_eq!(routes[1].registry, NativeRegistry::CratesIo);''', - ''' assert_eq!(routes[1].registry, NativeRegistry::Npm); - assert_eq!(routes[1].package, "@acme/client"); - assert_eq!(routes[2].target, "rust"); - assert_eq!(routes[2].registry, NativeRegistry::CratesIo);''', - 1, -) -tests = tests.replace( - ''' assert!(encoded.contains("registry = \\"npm\\"")); - Manifest::parse(&encoded).unwrap();''', - ''' assert!(encoded.contains("registry = \\"npm\\"")); - assert!(encoded.contains("registry = \\"pub.dev\\"")); - Manifest::parse(&encoded).unwrap();''', - 1, -) - -invalid_anchor = ''' r#" -[targets.rust] -dir = "clients/rust" -[targets.rust.native] -registry = "crates-io" -package = "acme/client" -"#, - ] {''' -invalid_replacement = ''' 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" -"#, - ] {''' -if tests.count(invalid_anchor) != 1: - raise SystemExit("invalid-route fixture insertion point changed") -tests = tests.replace(invalid_anchor, invalid_replacement, 1) - -TEST.write_text(tests, encoding="utf-8") -print("added pub.dev native routing") From 20da7305b15ed54de3615ca2b4a9193b45509c63 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:43:28 -0500 Subject: [PATCH 033/122] chore: add deterministic PyPI routing patch --- scripts/apply_pypi_routing.py | 210 ++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 scripts/apply_pypi_routing.py diff --git a/scripts/apply_pypi_routing.py b/scripts/apply_pypi_routing.py new file mode 100644 index 0000000..107964c --- /dev/null +++ b/scripts/apply_pypi_routing.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Extend typed native release routing with PyPI semantics.""" + +from pathlib import Path + + +MANIFEST = Path("src/manifest.rs") +text = MANIFEST.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global text + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one insertion point, found {count}") + text = text.replace(old, new, 1) + + +replace_once( + ''' #[serde(rename = "pub.dev")] + PubDev, +}''', + ''' #[serde(rename = "pub.dev")] + PubDev, + PyPi, +}''', + "NativeRegistry::PyPi", +) +replace_once( + ''' Self::PubDev => "pub.dev", + }''', + ''' Self::PubDev => "pub.dev", + Self::PyPi => "pypi", + }''', + "PyPI display name", +) +replace_once( + ''' Self::PubDev => is_valid_pubdev_package(package), + };''', + ''' Self::PubDev => is_valid_pubdev_package(package), + Self::PyPi => is_valid_pypi_package(package), + };''', + "PyPI syntax validation", +) +replace_once( + ''' } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseRoute {''', + ''' } + + fn canonical_package(self, package: &str) -> String { + match self { + Self::PyPi => normalize_pypi_package(package), + _ => package.to_string(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseRoute {''', + "registry canonical package method", +) +replace_once( + '''fn is_valid_pubdev_package(value: &str) -> bool {''', + '''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 {''', + "PyPI helpers", +) +replace_once( + ''' let route = (native.registry, native.package.clone()); + if let Some(previous) = native_routes.insert(route, name.as_str()) {''', + ''' 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()) {''', + "normalized native route collision key", +) +MANIFEST.write_text(text, encoding="utf-8") + +TEST = Path("tests/native_release.rs") +tests = TEST.read_text(encoding="utf-8") + +anchor = '''[targets.dart.native] +registry = "pub.dev" +package = "acme_client" +"#,''' +replacement = '''[targets.dart.native] +registry = "pub.dev" +package = "acme_client" + +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "py-pi" +package = "Acme.Client" +"#,''' +# Serde rename_all kebab-case would make PyPi -> py-pi, but desired wire spelling is pypi. +# Patch source enum with an explicit rename after initial insertion. +text = MANIFEST.read_text(encoding="utf-8") +text = text.replace(' PyPi,\n}', ' #[serde(rename = "pypi")]\n PyPi,\n}', 1) +MANIFEST.write_text(text, encoding="utf-8") +replacement = replacement.replace('registry = "py-pi"', 'registry = "pypi"') +if tests.count(anchor) != 1: + raise SystemExit("route fixture insertion point changed") +tests = tests.replace(anchor, replacement, 1) +tests = tests.replace('assert_eq!(routes.len(), 3);', 'assert_eq!(routes.len(), 4);', 1) +tests = tests.replace( + ''' assert_eq!(routes[2].target, "rust"); + assert_eq!(routes[2].registry, NativeRegistry::CratesIo);''', + ''' 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);''', + 1, +) +tests = tests.replace( + ''' assert!(encoded.contains("registry = \"pub.dev\""));''', + ''' assert!(encoded.contains("registry = \"pub.dev\"")); + assert!(encoded.contains("registry = \"pypi\""));''', + 1, +) +invalid_anchor = ''' r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "class" +"#, + ] {''' +invalid_replacement = ''' 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" +"#, + ] {''' +if tests.count(invalid_anchor) != 1: + raise SystemExit("invalid package fixture insertion point changed") +tests = tests.replace(invalid_anchor, invalid_replacement, 1) + +append = r''' + +#[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}"); +} +''' +tests += append +TEST.write_text(tests, encoding="utf-8") +print("added PyPI routing and normalized collision checks") From 9a4032e90315768b1331f576bb69fdfd983d60e8 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:43:48 -0500 Subject: [PATCH 034/122] ci: apply PyPI native routing --- .../apply-pypi-routing-temporary.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/apply-pypi-routing-temporary.yml diff --git a/.github/workflows/apply-pypi-routing-temporary.yml b/.github/workflows/apply-pypi-routing-temporary.yml new file mode 100644 index 0000000..4a7fb79 --- /dev/null +++ b/.github/workflows/apply-pypi-routing-temporary.yml @@ -0,0 +1,45 @@ +name: Apply PyPI native routing (temporary) + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + +jobs: + apply: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref == 'feat/den-100-pypi-routing' && + github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt + - name: Apply PyPI routing and tests + run: python3 scripts/apply_pypi_routing.py + - name: Format, test, and regenerate schemas + run: | + cargo fmt --all + cargo test --locked + cargo run --example generate_schemas + - name: Commit production changes to the feature branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/manifest.rs tests/native_release.rs schemas/ + if git diff --cached --quiet; then + echo "PyPI routing is already current." + exit 0 + fi + git commit -m "feat: declare PyPI release routing" + git push origin HEAD:feat/den-100-pypi-routing From 983f8dc40562fd665d1f0c705ce9d658483f0e75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:44:42 +0000 Subject: [PATCH 035/122] feat: declare PyPI release routing --- schemas/manifest.json | 3 ++- schemas/publish-meta.json | 3 ++- src/manifest.rs | 40 +++++++++++++++++++++++++++++- tests/native_release.rs | 52 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/schemas/manifest.json b/schemas/manifest.json index 37a8c61..e8df8ca 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -137,7 +137,8 @@ "enum": [ "npm", "crates-io", - "pub.dev" + "pub.dev", + "pypi" ] }, "NativeReleaseSection": { diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index a584f96..3d48b37 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -183,7 +183,8 @@ "enum": [ "npm", "crates-io", - "pub.dev" + "pub.dev", + "pypi" ] }, "NativeReleaseSection": { diff --git a/src/manifest.rs b/src/manifest.rs index 19f3bb2..8391e94 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -245,6 +245,8 @@ pub enum NativeRegistry { CratesIo, #[serde(rename = "pub.dev")] PubDev, + #[serde(rename = "pypi")] + PyPi, } impl NativeRegistry { @@ -253,6 +255,7 @@ impl NativeRegistry { Self::Npm => "npm", Self::CratesIo => "crates-io", Self::PubDev => "pub.dev", + Self::PyPi => "pypi", } } @@ -261,6 +264,7 @@ impl NativeRegistry { 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), }; if valid { Ok(()) @@ -271,6 +275,13 @@ impl NativeRegistry { )) } } + + fn canonical_package(self, package: &str) -> String { + match self { + Self::PyPi => normalize_pypi_package(package), + _ => package.to_string(), + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -315,6 +326,32 @@ fn is_valid_crates_package(value: &str) -> bool { .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", @@ -586,7 +623,8 @@ impl Manifest { .registry .validate_package(&native.package) .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; - let route = (native.registry, native.package.clone()); + 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(), diff --git a/tests/native_release.rs b/tests/native_release.rs index ea61750..065b336 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -41,20 +41,30 @@ 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(), 3); + 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, "rust"); - assert_eq!(routes[2].registry, NativeRegistry::CratesIo); + 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]")); @@ -116,6 +126,20 @@ 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!( @@ -145,3 +169,25 @@ 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}"); +} From 9eff1e13ddbe263b4569bd78e6d7620441c69b6e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:45:33 -0500 Subject: [PATCH 036/122] chore: remove temporary PyPI routing workflow --- .../apply-pypi-routing-temporary.yml | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/apply-pypi-routing-temporary.yml diff --git a/.github/workflows/apply-pypi-routing-temporary.yml b/.github/workflows/apply-pypi-routing-temporary.yml deleted file mode 100644 index 4a7fb79..0000000 --- a/.github/workflows/apply-pypi-routing-temporary.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Apply PyPI native routing (temporary) - -on: - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: write - -jobs: - apply: - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.head_ref == 'feat/den-100-pypi-routing' && - github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - name: Apply PyPI routing and tests - run: python3 scripts/apply_pypi_routing.py - - name: Format, test, and regenerate schemas - run: | - cargo fmt --all - cargo test --locked - cargo run --example generate_schemas - - name: Commit production changes to the feature branch - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/manifest.rs tests/native_release.rs schemas/ - if git diff --cached --quiet; then - echo "PyPI routing is already current." - exit 0 - fi - git commit -m "feat: declare PyPI release routing" - git push origin HEAD:feat/den-100-pypi-routing From 05f382bf666326ee2337d40319ad16e09f3fcd71 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 27 Jul 2026 15:45:42 -0500 Subject: [PATCH 037/122] chore: remove temporary PyPI routing helper --- scripts/apply_pypi_routing.py | 210 ---------------------------------- 1 file changed, 210 deletions(-) delete mode 100644 scripts/apply_pypi_routing.py diff --git a/scripts/apply_pypi_routing.py b/scripts/apply_pypi_routing.py deleted file mode 100644 index 107964c..0000000 --- a/scripts/apply_pypi_routing.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -"""Extend typed native release routing with PyPI semantics.""" - -from pathlib import Path - - -MANIFEST = Path("src/manifest.rs") -text = MANIFEST.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global text - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one insertion point, found {count}") - text = text.replace(old, new, 1) - - -replace_once( - ''' #[serde(rename = "pub.dev")] - PubDev, -}''', - ''' #[serde(rename = "pub.dev")] - PubDev, - PyPi, -}''', - "NativeRegistry::PyPi", -) -replace_once( - ''' Self::PubDev => "pub.dev", - }''', - ''' Self::PubDev => "pub.dev", - Self::PyPi => "pypi", - }''', - "PyPI display name", -) -replace_once( - ''' Self::PubDev => is_valid_pubdev_package(package), - };''', - ''' Self::PubDev => is_valid_pubdev_package(package), - Self::PyPi => is_valid_pypi_package(package), - };''', - "PyPI syntax validation", -) -replace_once( - ''' } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct NativeReleaseRoute {''', - ''' } - - fn canonical_package(self, package: &str) -> String { - match self { - Self::PyPi => normalize_pypi_package(package), - _ => package.to_string(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct NativeReleaseRoute {''', - "registry canonical package method", -) -replace_once( - '''fn is_valid_pubdev_package(value: &str) -> bool {''', - '''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 {''', - "PyPI helpers", -) -replace_once( - ''' let route = (native.registry, native.package.clone()); - if let Some(previous) = native_routes.insert(route, name.as_str()) {''', - ''' 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()) {''', - "normalized native route collision key", -) -MANIFEST.write_text(text, encoding="utf-8") - -TEST = Path("tests/native_release.rs") -tests = TEST.read_text(encoding="utf-8") - -anchor = '''[targets.dart.native] -registry = "pub.dev" -package = "acme_client" -"#,''' -replacement = '''[targets.dart.native] -registry = "pub.dev" -package = "acme_client" - -[targets.python] -dir = "clients/python" - -[targets.python.native] -registry = "py-pi" -package = "Acme.Client" -"#,''' -# Serde rename_all kebab-case would make PyPi -> py-pi, but desired wire spelling is pypi. -# Patch source enum with an explicit rename after initial insertion. -text = MANIFEST.read_text(encoding="utf-8") -text = text.replace(' PyPi,\n}', ' #[serde(rename = "pypi")]\n PyPi,\n}', 1) -MANIFEST.write_text(text, encoding="utf-8") -replacement = replacement.replace('registry = "py-pi"', 'registry = "pypi"') -if tests.count(anchor) != 1: - raise SystemExit("route fixture insertion point changed") -tests = tests.replace(anchor, replacement, 1) -tests = tests.replace('assert_eq!(routes.len(), 3);', 'assert_eq!(routes.len(), 4);', 1) -tests = tests.replace( - ''' assert_eq!(routes[2].target, "rust"); - assert_eq!(routes[2].registry, NativeRegistry::CratesIo);''', - ''' 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);''', - 1, -) -tests = tests.replace( - ''' assert!(encoded.contains("registry = \"pub.dev\""));''', - ''' assert!(encoded.contains("registry = \"pub.dev\"")); - assert!(encoded.contains("registry = \"pypi\""));''', - 1, -) -invalid_anchor = ''' r#" -[targets.dart] -dir = "clients/dart" -[targets.dart.native] -registry = "pub.dev" -package = "class" -"#, - ] {''' -invalid_replacement = ''' 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" -"#, - ] {''' -if tests.count(invalid_anchor) != 1: - raise SystemExit("invalid package fixture insertion point changed") -tests = tests.replace(invalid_anchor, invalid_replacement, 1) - -append = r''' - -#[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}"); -} -''' -tests += append -TEST.write_text(tests, encoding="utf-8") -print("added PyPI routing and normalized collision checks") From 7a731361f8e49fe54cee078a35fcbf80e0906c3b Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:16:56 -0500 Subject: [PATCH 038/122] manifest: language/ecosystem tagging for per-language packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A polyglot repo publishes one package per language (`-`), but nothing recorded which language a published artifact was *for*, so a consumer could install the `-java` slice into a Node project and get a tree its toolchain never reads, with no complaint at any point. Two axes, because conflating them breaks one case or the other: * `language` names the package. Java, Kotlin, Scala and Clojure are four clients a human asks for by name, so they are four packages. * `ecosystem` decides who may install it. Those same four are one ecosystem (jvm), and all equally valid in a Gradle project. Keying only on language would leave an 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. `manifest_for_target` stamps both onto each derived per-target manifest, so a published slice self-describes and a consumer has something to check. Also: * `resolve_target_key` matches through language synonyms. Project inference yields `node`/`go` while these repos publish `-nodejs`/`-golang` because that is how the names should read; without synonym matching every such consumer hit "publishes no such target" despite the package shipping exactly what they needed. * `NativeRegistry::ecosystem()` bridges the outbound mirror to the inbound install gate, and `validate` now rejects a target whose native route contradicts its install ecosystem — a python slice mirrored to npm means one of the two is wrong, and either way it stays silent until someone hits it. * `ADAPTERS` is shared so the CLI and the manifest cannot disagree about which adapter names are legal. Co-Authored-By: Claude Opus 5 (1M context) --- schemas/manifest.json | 123 ++++++- schemas/publish-meta.json | 123 ++++++- src/language.rs | 660 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/manifest.rs | 149 ++++++++- tests/native_release.rs | 89 +++++ tests/roundtrip.rs | 162 ++++++++++ 7 files changed, 1303 insertions(+), 5 deletions(-) create mode 100644 src/language.rs diff --git a/schemas/manifest.json b/schemas/manifest.json index e8df8ca..f2b9ff0 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -105,6 +105,56 @@ "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" + } + ] + }, "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", @@ -132,6 +182,65 @@ } } }, + "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": [ @@ -178,12 +287,20 @@ "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", @@ -292,6 +409,10 @@ "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" + }, "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": [ @@ -300,7 +421,7 @@ ] }, "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.", + "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" diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 3d48b37..beffa91 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -66,6 +66,56 @@ "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" + } + ] + }, "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", @@ -93,6 +143,65 @@ } } }, + "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", @@ -224,12 +333,20 @@ "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", @@ -338,6 +455,10 @@ "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" + }, "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": [ @@ -346,7 +467,7 @@ ] }, "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.", + "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" diff --git a/src/language.rs b/src/language.rs new file mode 100644 index 0000000..d7b9fb9 --- /dev/null +++ b/src/language.rs @@ -0,0 +1,660 @@ +//! 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)] +#[derive(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)] +#[derive(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/lib.rs b/src/lib.rs index dbd1146..cdb283b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod artifact; pub mod excludes; +pub mod language; pub mod lockfile; pub mod manifest; pub mod paths; @@ -17,6 +18,7 @@ pub mod vcs; pub mod version; pub use artifact::ArtifactFormat; +pub use language::{Ecosystem, Language, detect_ecosystems}; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; pub use vcs::Vcs; diff --git a/src/manifest.rs b/src/manifest.rs index 8391e94..6a3acd2 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::language::{Ecosystem, Language}; use crate::vcs::Vcs; use crate::version::{Requirement, VersionScheme}; @@ -115,6 +116,38 @@ pub struct PackageSection { 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 @@ -226,8 +259,39 @@ pub struct TargetSection { /// 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, + /// 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)] @@ -282,6 +346,22 @@ impl NativeRegistry { _ => 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, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -438,6 +518,16 @@ pub enum ManifestError { 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() @@ -602,12 +692,13 @@ impl Manifest { )); } if let Some(adapter) = target.adapter.as_deref() - && !matches!(adapter, "node" | "java" | "none") + && !ADAPTERS.contains(&adapter) { return Err(ManifestError::InvalidTarget( name.clone(), format!( - "adapter `{adapter}` is unsupported; expected `node`, `java`, or `none`" + "adapter `{adapter}` is unsupported; expected one of {}", + ADAPTERS.join(", ") ), )); } @@ -635,6 +726,24 @@ impl Manifest { ), )); } + // The outbound mirror and the inbound install gate describe the + // same ecosystem. If they disagree, one of them is wrong and the + // slice 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 inbound = target.ecosystem_for(name); + let outbound = native.registry.ecosystem(); + if !inbound.is_default() && inbound != outbound { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + format!( + "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ + set `ecosystem` on the target if the mirror is right" + , + native.registry.as_str() + ), + )); + } } } // A blank request means "no target", the same way a blank @@ -790,9 +899,37 @@ impl Manifest { // 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())?; + 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. /// @@ -811,7 +948,13 @@ impl Manifest { let Some(requested) = requested else { return Ok(None); }; - match self.targets.get(requested) { + // 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(); diff --git a/tests/native_release.rs b/tests/native_release.rs index 065b336..1477e8a 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -191,3 +191,92 @@ package = "friendly...bard" 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()); +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 4372b28..a2857b6 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -1,6 +1,7 @@ use zed_interfaces::ArtifactFormat; use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; use zed_interfaces::lockfile::{LockedPackage, Lockfile}; +use zed_interfaces::language::{Ecosystem, Language}; use zed_interfaces::manifest::{Manifest, ManifestError}; use zed_interfaces::paths::store_entry_rel; use zed_interfaces::vcs::Vcs; @@ -424,3 +425,164 @@ fn derived_target_names_must_still_be_valid_package_names() { ); } } + +// --- 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}"); +} From 3fea838928e4e644bd561df1cf36234a21a8d33b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 29 Jul 2026 23:24:45 -0500 Subject: [PATCH 039/122] wip: draft multi-registry schema --- src/lib.rs | 2 + src/manifest.rs | 147 +++++++++++++++++++++++++++++++++++++++++++++ src/publish.rs | 110 +++++++++++++++++++++++++++++++++ tests/roundtrip.rs | 130 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 src/publish.rs diff --git a/src/lib.rs b/src/lib.rs index dbd1146..bc63017 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod excludes; pub mod lockfile; pub mod manifest; pub mod paths; +pub mod publish; pub mod registry; pub mod sync; pub mod vcs; @@ -19,5 +20,6 @@ pub mod version; pub use artifact::ArtifactFormat; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; +pub use publish::PublishRegistry; pub use vcs::Vcs; pub use version::{Requirement, VersionScheme}; diff --git a/src/manifest.rs b/src/manifest.rs index 9b94037..cc27d31 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::publish::PublishRegistry; use crate::vcs::Vcs; use crate::version::{Requirement, VersionScheme}; @@ -140,6 +141,20 @@ pub struct PublishSection { /// VCS tag template that must exist and point at the published commit. /// `{version}` is substituted with `package.version`. pub tag_format: String, + /// Native package-manager format (`npm`, `cargo`, `pypi`, `maven`, ...). + /// Zed stores its own deterministic artifact regardless of this value; + /// forge registries use it to reject unsupported routes before upload. + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + /// Registries that should receive this package. Omitted preserves the + /// historical behavior: publish only to the configured Zed registry. + #[serde(skip_serializing_if = "Option::is_none")] + pub registries: Option>, + /// Optional endpoint overrides, keyed by registry family. Secrets never + /// belong here; authentication comes from the environment/credential + /// store used by the relevant publisher. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub registry_urls: BTreeMap, } impl Default for PublishSection { @@ -149,6 +164,9 @@ impl Default for PublishSection { include_readme: false, smoke_test: None, tag_format: "v{version}".to_string(), + format: None, + registries: None, + registry_urls: BTreeMap::new(), } } } @@ -223,6 +241,18 @@ pub struct TargetSection { /// gets the right wiring without configuring it. #[serde(default, skip_serializing_if = "Option::is_none")] pub adapter: Option, + /// This target's native package-manager format. It overrides + /// `[publish].format` when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + /// This target's registry fan-out. It overrides `[publish].registries` + /// when present; omission inherits the package default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registries: Option>, + /// Target-specific endpoint overrides layered over + /// `[publish.registry_urls]`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub registry_urls: BTreeMap, } /// A post-extract build step. Because compiled output is OS/arch-specific, @@ -290,6 +320,8 @@ pub enum ManifestError { InvalidInstallDir(String, String), #[error("invalid target `{0}`: {1}")] InvalidTarget(String, String), + #[error("invalid publish route `{0}`: {1}")] + InvalidPublishRoute(String, String), #[error("manifest toml error: {0}")] Toml(String), } @@ -352,6 +384,76 @@ fn is_allowed_repo_url(url: &str) -> bool { || (url.contains('@') && url.contains(':') && !url.contains("://")) } +fn is_allowed_registry_url(url: &str) -> bool { + (url.starts_with("https://") || url.starts_with("http://") || url.starts_with("file://")) + && !url.chars().any(char::is_whitespace) +} + +fn validate_publish_route( + label: &str, + format: Option<&str>, + registries: Option<&[PublishRegistry]>, + registry_urls: &BTreeMap, +) -> Result<(), ManifestError> { + let selected = registries.unwrap_or(&[PublishRegistry::Zed]); + if selected.is_empty() { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + "registries cannot be empty; omit the field for the default Zed registry".to_string(), + )); + } + let mut unique = std::collections::BTreeSet::new(); + for registry in selected { + if !unique.insert(*registry) { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("registry `{registry}` is listed more than once"), + )); + } + } + + let format = format.map(str::trim).filter(|value| !value.is_empty()); + if let Some(format) = format + && !is_target_name(format) + { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("format `{format}` must use [a-z0-9][a-z0-9-]*"), + )); + } + for registry in selected { + if *registry != PublishRegistry::Zed && format.is_none() { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("registry `{registry}` requires an explicit package format"), + )); + } + if let Some(format) = format + && !registry.supports_format(format) + { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("registry `{registry}` does not support format `{format}`"), + )); + } + } + for (registry, url) in registry_urls { + if !selected.contains(registry) { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("URL override for `{registry}` has no matching entry in registries"), + )); + } + if !is_allowed_registry_url(url) { + return Err(ManifestError::InvalidPublishRoute( + label.to_string(), + format!("registry URL `{url}` must be an http(s) or file URL without whitespace"), + )); + } + } + Ok(()) +} + impl Manifest { /// Parse and validate a `.zpkg.toml` document. pub fn parse(input: &str) -> Result { @@ -467,6 +569,25 @@ impl Manifest { )); } } + if self.targets.is_empty() { + validate_publish_route( + "package", + self.publish.format.as_deref(), + self.publish.registries.as_deref(), + &self.publish.registry_urls, + )?; + } else { + for (name, target) in &self.targets { + let format = target.format.as_deref().or(self.publish.format.as_deref()); + let registries = target + .registries + .as_deref() + .or(self.publish.registries.as_deref()); + let mut urls = self.publish.registry_urls.clone(); + urls.extend(target.registry_urls.clone()); + validate_publish_route(name, format, registries, &urls)?; + } + } // 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() @@ -604,9 +725,35 @@ impl Manifest { // The consumer-facing wiring for this ecosystem. derived.install.adapter = section.adapter.clone().or(self.install.adapter.clone()); derived.install.target = None; + derived.publish.format = section.format.clone().or(self.publish.format.clone()); + if section.registries.is_some() { + derived.publish.registries = section.registries.clone(); + } + derived + .publish + .registry_urls + .extend(section.registry_urls.clone()); Some(derived) } + /// The effective registry fan-out for this package. Manifests written + /// before multi-registry support continue to resolve to Zed only. + pub fn publish_registries(&self) -> Vec { + self.publish + .registries + .clone() + .unwrap_or_else(|| vec![PublishRegistry::Zed]) + } + + /// An endpoint override for a registry family, when the manifest names + /// one. The CLI's `--registry` remains the default for `zed`. + pub fn publish_registry_url(&self, registry: PublishRegistry) -> Option<&str> { + self.publish + .registry_urls + .get(®istry) + .map(String::as_str) + } + /// Resolve which subdirectory of *this* (dependency) package a consumer /// asking for `requested` should get. /// diff --git a/src/publish.rs b/src/publish.rs new file mode 100644 index 0000000..58615eb --- /dev/null +++ b/src/publish.rs @@ -0,0 +1,110 @@ +use std::fmt; +use std::str::FromStr; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A registry family that can receive one package produced by `zed publish`. +/// +/// `zed` is zpkg.tech (or the CLI's configured Zed-compatible registry), +/// `native` is the ecosystem's canonical registry (npmjs, crates.io, PyPI, +/// and so on), and the remaining variants are package registries operated by +/// source forges. The source repository itself remains configured separately +/// under `[package.repository]`. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum PublishRegistry { + Zed, + Native, + GithubPackages, + GitlabPackages, + BitbucketPackages, +} + +impl PublishRegistry { + pub const ALL: [Self; 5] = [ + Self::Zed, + Self::Native, + Self::GithubPackages, + Self::GitlabPackages, + Self::BitbucketPackages, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Zed => "zed", + Self::Native => "native", + Self::GithubPackages => "github-packages", + Self::GitlabPackages => "gitlab-packages", + Self::BitbucketPackages => "bitbucket-packages", + } + } + + /// Whether this registry family currently accepts the package-manager + /// format. Zed accepts every format because it stores the deterministic + /// zpkg artifact; forge registries intentionally fail closed to the + /// formats their public APIs document. + pub fn supports_format(self, format: &str) -> bool { + match self { + Self::Zed => true, + Self::Native => !matches!(format, "zpkg" | "generic"), + Self::GithubPackages => { + matches!(format, "npm" | "rubygems" | "maven" | "nuget" | "container") + } + Self::GitlabPackages => matches!( + format, + "composer" + | "conan" + | "debian" + | "generic" + | "go" + | "helm" + | "maven" + | "npm" + | "nuget" + | "pypi" + | "rubygems" + | "terraform" + ), + Self::BitbucketPackages => matches!(format, "npm" | "maven" | "container"), + } + } +} + +impl fmt::Display for PublishRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsePublishRegistryError(pub String); + +impl fmt::Display for ParsePublishRegistryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "unknown publish registry `{}` (expected zed, native, github-packages, gitlab-packages, or bitbucket-packages)", + self.0 + ) + } +} + +impl std::error::Error for ParsePublishRegistryError {} + +impl FromStr for PublishRegistry { + type Err = ParsePublishRegistryError; + + fn from_str(value: &str) -> Result { + match value { + "zed" => Ok(Self::Zed), + "native" => Ok(Self::Native), + "github-packages" | "github" => Ok(Self::GithubPackages), + "gitlab-packages" | "gitlab" => Ok(Self::GitlabPackages), + "bitbucket-packages" | "bitbucket" => Ok(Self::BitbucketPackages), + other => Err(ParsePublishRegistryError(other.to_string())), + } + } +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 4372b28..a325f73 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -3,6 +3,7 @@ use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_exclu use zed_interfaces::lockfile::{LockedPackage, Lockfile}; use zed_interfaces::manifest::{Manifest, ManifestError}; use zed_interfaces::paths::store_entry_rel; +use zed_interfaces::publish::PublishRegistry; use zed_interfaces::vcs::Vcs; const SAMPLE: &str = r#" @@ -424,3 +425,132 @@ fn derived_target_names_must_still_be_valid_package_names() { ); } } + +#[test] +fn polyglot_targets_declare_registry_fanout_by_native_format() { + let source = CLIENTS + .replace( + "[targets.nodejs]\ndir = \"clients/ts\"\nadapter = \"node\"", + r#"[targets.nodejs] +dir = "clients/ts" +adapter = "node" +format = "npm" +registries = ["zed", "native", "github-packages", "gitlab-packages", "bitbucket-packages"] + +[targets.nodejs.registry_urls] +gitlab-packages = "https://gitlab.example.com""#, + ) + .replace( + "[targets.java]\ndir = \"clients/java\"\nadapter = \"java\"", + r#"[targets.java] +dir = "clients/java" +adapter = "java" +format = "maven" +registries = ["zed", "native", "github-packages", "gitlab-packages", "bitbucket-packages"]"#, + ) + .replace( + "[targets.golang]\ndir = \"clients/go\"", + r#"[targets.golang] +dir = "clients/go" +format = "go" +registries = ["zed", "native", "gitlab-packages"]"#, + ); + + let manifest = Manifest::parse(&source).expect("all declared host/format pairs are supported"); + let node = manifest.manifest_for_target("nodejs").unwrap(); + assert_eq!(node.publish.format.as_deref(), Some("npm")); + assert_eq!( + node.publish_registries(), + vec![ + PublishRegistry::Zed, + PublishRegistry::Native, + PublishRegistry::GithubPackages, + PublishRegistry::GitlabPackages, + PublishRegistry::BitbucketPackages, + ] + ); + assert_eq!( + node.publish_registry_url(PublishRegistry::GitlabPackages), + Some("https://gitlab.example.com") + ); + + let go = manifest.manifest_for_target("golang").unwrap(); + assert_eq!(go.publish.format.as_deref(), Some("go")); + assert_eq!( + go.publish_registries(), + vec![ + PublishRegistry::Zed, + PublishRegistry::Native, + PublishRegistry::GitlabPackages, + ] + ); + + assert_eq!( + Manifest::parse(&manifest.to_toml_string().unwrap()).unwrap(), + manifest + ); +} + +#[test] +fn old_manifests_default_to_zed_only() { + let manifest = Manifest::parse(SAMPLE).unwrap(); + assert_eq!(manifest.publish_registries(), vec![PublishRegistry::Zed]); + assert!(manifest.publish.format.is_none()); +} + +#[test] +fn unsupported_forge_routes_fail_before_publish() { + let github_cargo = CLIENTS.replace( + "[targets.nodejs]\ndir = \"clients/ts\"\nadapter = \"node\"", + r#"[targets.nodejs] +dir = "clients/ts" +adapter = "node" +format = "cargo" +registries = ["zed", "github-packages"]"#, + ); + assert!(matches!( + Manifest::parse(&github_cargo), + Err(ManifestError::InvalidPublishRoute(_, reason)) + if reason.contains("github-packages") && reason.contains("cargo") + )); + + let bitbucket_pypi = CLIENTS.replace( + "[targets.nodejs]\ndir = \"clients/ts\"\nadapter = \"node\"", + r#"[targets.nodejs] +dir = "clients/ts" +adapter = "node" +format = "pypi" +registries = ["bitbucket-packages"]"#, + ); + assert!(matches!( + Manifest::parse(&bitbucket_pypi), + Err(ManifestError::InvalidPublishRoute(_, reason)) + if reason.contains("bitbucket-packages") && reason.contains("pypi") + )); +} + +#[test] +fn external_registries_require_a_format_and_safe_endpoint() { + let missing_format = format!( + "{SAMPLE}\n[publish.registry_urls]\ngithub-packages = \"https://npm.pkg.github.com\"\n" + ) + .replace( + "[publish]\n", + "[publish]\nregistries = [\"zed\", \"github-packages\"]\n", + ); + assert!(matches!( + Manifest::parse(&missing_format), + Err(ManifestError::InvalidPublishRoute(_, reason)) + if reason.contains("requires an explicit package format") + )); + + let credential_url = SAMPLE.replace( + "[publish]\n", + "[publish]\nformat = \"npm\"\nregistries = [\"native\"]\nregistry_urls = { native = \"not a URL\" }\n", + ); + assert!(matches!( + Manifest::parse(&credential_url), + Err(ManifestError::InvalidPublishRoute(_, reason)) + if reason.contains("registry URL") + )); +} From e6030f2b3027bc35fb15b83e2ba8b8711b19fb87 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:32:12 -0500 Subject: [PATCH 040/122] style: cargo fmt Co-Authored-By: Claude Opus 5 (1M context) --- src/language.rs | 32 ++++++++++++++++++++++++++++---- src/manifest.rs | 3 +-- tests/native_release.rs | 5 ++++- tests/roundtrip.rs | 11 ++++++++--- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/language.rs b/src/language.rs index d7b9fb9..11a6ee2 100644 --- a/src/language.rs +++ b/src/language.rs @@ -39,8 +39,20 @@ use serde::{Deserialize, Serialize}; /// 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)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[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 @@ -93,8 +105,20 @@ pub enum Language { /// 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)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[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. diff --git a/src/manifest.rs b/src/manifest.rs index 6a3acd2..4495bc0 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -738,8 +738,7 @@ impl Manifest { name.clone(), format!( "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ - set `ecosystem` on the target if the mirror is right" - , + set `ecosystem` on the target if the mirror is right", native.registry.as_str() ), )); diff --git a/tests/native_release.rs b/tests/native_release.rs index 1477e8a..82443fc 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -237,7 +237,10 @@ package = "{package}" .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)); + assert_eq!( + route.registry.ecosystem(), + parsed.targets[target].ecosystem_for(target) + ); } } diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index a2857b6..893c4fd 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -1,7 +1,7 @@ use zed_interfaces::ArtifactFormat; use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; -use zed_interfaces::lockfile::{LockedPackage, Lockfile}; 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; @@ -514,7 +514,9 @@ 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"); + let err = m + .target_subdir(Some("cobol")) + .expect_err("unknown language"); assert!(err.to_string().contains("cobol"), "{err}"); } @@ -566,7 +568,10 @@ 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 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"); From 40681c44f09752e6d6387d4f12262c0dfdab5f25 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 29 Jul 2026 23:37:11 -0500 Subject: [PATCH 041/122] feat: model native and forge package routes --- README.md | 33 +++++ schemas/manifest.json | 33 ++++- schemas/publish-meta.json | 33 ++++- src/language.rs | 32 ++++- src/manifest.rs | 281 ++++++++++++++++++++++++++++++++++---- tests/native_release.rs | 161 +++++++++++++++++++++- tests/roundtrip.rs | 11 +- 7 files changed, 549 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 0b5f556..b463b1d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ This crate is the contract everything else builds against: - **Filesystem layout** — `$HOME/.zed-pkg` store, `zed_modules/` symlink dir, archive structure (`paths`) - **VCS + artifact enums** — `git`/`hg`, `tar.gz`/`zip` (`vcs`, `artifact`) +- **Polyglot release routes** — language/ecosystem identity plus canonical + native registries and compatible GitHub/GitLab/Bitbucket package mirrors + (`language`, `manifest`) ## The model in one page @@ -40,6 +43,36 @@ stay self-contained across multi-stage builds. The lockfile pins `sha256`, `size`, `vcs_tag`, and `vcs_commit` per package: installs are reproducible and every artifact traces back to source. +For a polyglot repository, each `[targets.]` slice becomes its own +Zed artifact. An optional `[targets..native]` block declares the +same version for the ecosystem's canonical registry, and `forge` declares +additional package-registry copies: + +```toml +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +``` + +Forge compatibility is validated in the interface contract. A manifest cannot +claim Cargo support in GitHub Packages or PyPI support in Bitbucket Packages; +those combinations fail during manifest parsing, before any release job sees +credentials. + +A single-language package whose native manifest is at the repository root uses +the same shape under `[publish.native]`: + +```toml +[publish.native] +registry = "npm" +package = "r2g" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +``` + ## Registry API surface | Method | Path | Body / response | diff --git a/schemas/manifest.json b/schemas/manifest.json index f2b9ff0..7551469 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -155,6 +155,14 @@ } ] }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, "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", @@ -247,12 +255,24 @@ "npm", "crates-io", "pub.dev", - "pypi" + "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" }, @@ -350,6 +370,17 @@ "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" + } + ] + }, "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": [ diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index beffa91..27ddb20 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -116,6 +116,14 @@ } ] }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, "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", @@ -293,12 +301,24 @@ "npm", "crates-io", "pub.dev", - "pypi" + "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" }, @@ -396,6 +416,17 @@ "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" + } + ] + }, "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": [ diff --git a/src/language.rs b/src/language.rs index d7b9fb9..11a6ee2 100644 --- a/src/language.rs +++ b/src/language.rs @@ -39,8 +39,20 @@ use serde::{Deserialize, Serialize}; /// 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)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[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 @@ -93,8 +105,20 @@ pub enum Language { /// 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)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[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. diff --git a/src/manifest.rs b/src/manifest.rs index 6a3acd2..b31c894 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -173,6 +173,12 @@ pub struct PublishSection { /// 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, } impl Default for PublishSection { @@ -182,6 +188,7 @@ impl Default for PublishSection { include_readme: false, smoke_test: None, tag_format: "v{version}".to_string(), + native: None, } } } @@ -298,6 +305,11 @@ impl TargetSection { pub struct NativeReleaseSection { pub registry: NativeRegistry, pub package: String, + /// 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( @@ -311,6 +323,67 @@ pub enum NativeRegistry { 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 { @@ -320,6 +393,11 @@ impl NativeRegistry { 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", } } @@ -329,6 +407,11 @@ impl NativeRegistry { 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(()) @@ -343,6 +426,7 @@ impl NativeRegistry { fn canonical_package(self, package: &str) -> String { match self { Self::PyPi => normalize_pypi_package(package), + Self::NuGet => package.to_ascii_lowercase(), _ => package.to_string(), } } @@ -360,6 +444,11 @@ impl NativeRegistry { 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, } } } @@ -372,6 +461,15 @@ pub struct NativeReleaseRoute { pub package: 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, +} + fn is_valid_npm_component(value: &str) -> bool { !value.is_empty() && value.len() <= 214 @@ -447,6 +545,53 @@ fn is_valid_pubdev_package(value: &str) -> bool { .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, '.' | '/' | '-' | '_' | '~')) +} + /// 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 @@ -586,6 +731,53 @@ fn is_allowed_repo_url(url: &str) -> bool { || (url.contains('@') && url.contains(':') && !url.contains("://")) } +fn validate_native_release_section( + native: &NativeReleaseSection, + route_name: &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() + ), + )); + } + + 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 { @@ -641,6 +833,24 @@ impl Manifest { let mut target_dirs = BTreeMap::<&str, &str>::new(); let mut published_names = BTreeMap::::new(); let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::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", + ); + } for (name, target) in &self.targets { if !is_target_name(name) { return Err(ManifestError::InvalidTarget( @@ -710,10 +920,7 @@ impl Manifest { .to_string(), )); } - native - .registry - .validate_package(&native.package) - .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; + validate_native_release_section(native, name, 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()) { @@ -726,24 +933,6 @@ impl Manifest { ), )); } - // The outbound mirror and the inbound install gate describe the - // same ecosystem. If they disagree, one of them is wrong and the - // slice 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 inbound = target.ecosystem_for(name); - let outbound = native.registry.ecosystem(); - if !inbound.is_default() && inbound != outbound { - return Err(ManifestError::InvalidNativeRoute( - name.clone(), - format!( - "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ - set `ecosystem` on the target if the mirror is right" - , - native.registry.as_str() - ), - )); - } } } // A blank request means "no target", the same way a blank @@ -852,16 +1041,60 @@ impl Manifest { /// 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.targets + self.publish + .native .iter() - .filter_map(|(target, section)| { + .map(|native| NativeReleaseRoute { + target: "repository".to_string(), + dir: ".".to_string(), + registry: native.registry, + package: native.package.clone(), + }) + .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(), }) + })) + .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(), + }) }) + .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(), + }) + }) + })) .collect() } diff --git a/tests/native_release.rs b/tests/native_release.rs index 1477e8a..7cd1ed5 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -1,4 +1,4 @@ -use zed_interfaces::manifest::{Manifest, ManifestError, NativeRegistry}; +use zed_interfaces::manifest::{ForgeRegistry, Manifest, ManifestError, NativeRegistry}; fn manifest(targets: &str) -> String { format!( @@ -73,6 +73,61 @@ package = "Acme.Client" 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"); + + 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); + + 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( @@ -237,7 +292,10 @@ package = "{package}" .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)); + assert_eq!( + route.registry.ecosystem(), + parsed.targets[target].ecosystem_for(target) + ); } } @@ -280,3 +338,102 @@ 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); +} + +#[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 parsed = Manifest::parse(&manifest(&format!( + r#" +[targets.{target}] +dir = "clients/{target}" + +[targets.{target}.native] +registry = "{registry}" +package = "{package}" +"# + ))) + .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); + } +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index a2857b6..893c4fd 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -1,7 +1,7 @@ use zed_interfaces::ArtifactFormat; use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; -use zed_interfaces::lockfile::{LockedPackage, Lockfile}; 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; @@ -514,7 +514,9 @@ 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"); + let err = m + .target_subdir(Some("cobol")) + .expect_err("unknown language"); assert!(err.to_string().contains("cobol"), "{err}"); } @@ -566,7 +568,10 @@ 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 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"); From 3ceadc80ae1fb6db6bf94ceff4d44ac72bde88c9 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:39:24 -0500 Subject: [PATCH 042/122] excludes: include_readme keeps the CHANGELOG registries ask for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dart pub publish` fails a package outright for a missing CHANGELOG.md, and the default excludes stripped `CHANGELOG*` from every artifact. `publish.exclude` can only *add* patterns, so no repository could ship one — scintilla's dart slice was unpublishable for a file it had committed. A package that opted into shipping its README wants its changelog shipped too, so `include_readme` now un-excludes both. Everything else the defaults strip (tests, CI config, VCS metadata) is untouched. Co-Authored-By: Claude Opus 5 (1M context) --- src/excludes.rs | 20 +++++++++++++++----- tests/roundtrip.rs | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/excludes.rs b/src/excludes.rs index d174841..c9372cf 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -66,14 +66,24 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ /// Shipping license texts with artifacts is non-negotiable. pub const ALWAYS_INCLUDE: &[&str] = &["LICENSE*", "LICENCE*", "COPYING*", "NOTICE*", ".zpkg.toml"]; -/// The effective exclusion list for a package: built-in defaults (minus -/// README 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. +/// 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. pub fn effective_excludes(extra: &[String], include_readme: bool) -> Vec { let mut out: Vec = Vec::new(); for pattern in DEFAULT_EXCLUDES { - if include_readme && pattern.starts_with("README") { + if include_readme && REGISTRY_DOC_PATTERNS.iter().any(|d| pattern.starts_with(d)) { continue; } out.push((*pattern).to_string()); diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 893c4fd..0100d29 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -591,3 +591,26 @@ fn untagged_manifests_keep_their_meaning() { 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"))); +} From bef3b63dfc4e0784cdb41042843ea4d09f6cd58f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 29 Jul 2026 23:41:20 -0500 Subject: [PATCH 043/122] fix: retain release routes in target artifacts --- src/manifest.rs | 4 ++++ tests/native_release.rs | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index b31c894..d906425 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1127,6 +1127,10 @@ impl Manifest { 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.targets = BTreeMap::new(); derived.workspace = None; // The consumer-facing wiring for this ecosystem. diff --git a/tests/native_release.rs b/tests/native_release.rs index 7cd1ed5..45720cd 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -376,6 +376,15 @@ forge = ["gitlab-packages"] 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] From 9da6bb14bdebda896d1fa652fc99e2768e24c0e4 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:43:17 -0500 Subject: [PATCH 044/122] style: format native routing additions --- src/language.rs | 32 ++++++++++++++++++++++++++++---- src/manifest.rs | 3 +-- tests/native_release.rs | 5 ++++- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/language.rs b/src/language.rs index d7b9fb9..11a6ee2 100644 --- a/src/language.rs +++ b/src/language.rs @@ -39,8 +39,20 @@ use serde::{Deserialize, Serialize}; /// 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)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[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 @@ -93,8 +105,20 @@ pub enum Language { /// 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)] -#[derive(Serialize, Deserialize, JsonSchema)] +#[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. diff --git a/src/manifest.rs b/src/manifest.rs index 6a3acd2..4495bc0 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -738,8 +738,7 @@ impl Manifest { name.clone(), format!( "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ - set `ecosystem` on the target if the mirror is right" - , + set `ecosystem` on the target if the mirror is right", native.registry.as_str() ), )); diff --git a/tests/native_release.rs b/tests/native_release.rs index 1477e8a..82443fc 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -237,7 +237,10 @@ package = "{package}" .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)); + assert_eq!( + route.registry.ecosystem(), + parsed.targets[target].ecosystem_for(target) + ); } } From 1b42531e766a52a06f365aaec3240276315f78fb Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:43:21 -0500 Subject: [PATCH 045/122] fix(pack): retain changelogs as release metadata --- src/excludes.rs | 8 ++++---- tests/roundtrip.rs | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/excludes.rs b/src/excludes.rs index d174841..d7fc56b 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -1,8 +1,9 @@ //! Publish-time exclusion rules. //! -//! zed-pkg's core disk-space promise: published artifacts carry what runs, -//! not what develops. Tests, CI configuration, VCS metadata, and READMEs -//! are stripped by default; license files are always kept. +//! zed-pkg's core disk-space promise: published artifacts carry runtime and +//! release metadata, not development machinery. Tests, CI configuration, VCS +//! metadata, and READMEs are stripped by default; changelogs and license files +//! are kept. /// Glob patterns excluded from every published artifact by default. /// Matching is case-insensitive in the CLI. @@ -36,7 +37,6 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ ".travis.yml", "azure-pipelines.yml", "README*", - "CHANGELOG*", ".zedignore", ".zed/**", ".zed-pack/**", diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index a2857b6..ffd027e 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -1,7 +1,7 @@ use zed_interfaces::ArtifactFormat; use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; -use zed_interfaces::lockfile::{LockedPackage, Lockfile}; 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; @@ -133,6 +133,13 @@ fn excludes_respect_include_readme() { assert!(ALWAYS_INCLUDE.contains(&".zpkg.toml")); } +#[test] +fn changelogs_are_release_metadata_not_development_artifacts() { + let excludes = effective_excludes(&[], false); + assert!(!excludes.iter().any(|pattern| pattern == "CHANGELOG*")); + assert!(!DEFAULT_EXCLUDES.contains(&"CHANGELOG*")); +} + #[test] fn store_paths_are_sharded() { let sha = "abcdef0123".to_string() + &"0".repeat(54); @@ -514,7 +521,9 @@ 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"); + let err = m + .target_subdir(Some("cobol")) + .expect_err("unknown language"); assert!(err.to_string().contains("cobol"), "{err}"); } @@ -566,7 +575,10 @@ 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 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"); From c36e0e71bbdb6f3694e580d8df1d53a76920175e Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:44:19 -0500 Subject: [PATCH 046/122] merge: preserve opt-in changelog packaging semantics --- src/excludes.rs | 9 +++++---- tests/roundtrip.rs | 7 ------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/excludes.rs b/src/excludes.rs index 518f199..4bed4fb 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -1,9 +1,9 @@ //! Publish-time exclusion rules. //! -//! zed-pkg's core disk-space promise: published artifacts carry runtime and -//! release metadata, not development machinery. Tests, CI configuration, VCS -//! metadata, and READMEs are stripped by default; changelogs and license files -//! are kept. +//! 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. @@ -37,6 +37,7 @@ pub const DEFAULT_EXCLUDES: &[&str] = &[ ".travis.yml", "azure-pipelines.yml", "README*", + "CHANGELOG*", ".zedignore", ".zed/**", ".zed-pack/**", diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 97cdd95..0100d29 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -133,13 +133,6 @@ fn excludes_respect_include_readme() { assert!(ALWAYS_INCLUDE.contains(&".zpkg.toml")); } -#[test] -fn changelogs_are_release_metadata_not_development_artifacts() { - let excludes = effective_excludes(&[], false); - assert!(!excludes.iter().any(|pattern| pattern == "CHANGELOG*")); - assert!(!DEFAULT_EXCLUDES.contains(&"CHANGELOG*")); -} - #[test] fn store_paths_are_sharded() { let sha = "abcdef0123".to_string() + &"0".repeat(54); From dc0e0a0620b9462817950b552d3d334a184b1cb1 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 29 Jul 2026 23:44:34 -0500 Subject: [PATCH 047/122] feat: model native registry tag formats --- README.md | 5 +++ schemas/manifest.json | 7 ++++ schemas/publish-meta.json | 7 ++++ src/manifest.rs | 75 +++++++++++++++++++++++++++++++++++++-- tests/native_release.rs | 30 ++++++++++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b463b1d..28b1b91 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,11 @@ claim Cargo support in GitHub Packages or PyPI support in Bitbucket Packages; those combinations fail during manifest parsing, before any release job sees credentials. +Tag-resolved ecosystems can override the repository release tag. A Go module +below `clients/go`, for example, declares +`tag_format = "clients/go/v{version}"`; validation rejects a subdirectory Go +route without that prefix. + A single-language package whose native manifest is at the repository root uses the same shape under `[publish.native]`: diff --git a/schemas/manifest.json b/schemas/manifest.json index 7551469..bac3032 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -278,6 +278,13 @@ }, "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": [ diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index 27ddb20..c3cec05 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -324,6 +324,13 @@ }, "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": [ diff --git a/src/manifest.rs b/src/manifest.rs index d906425..c27c94a 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -305,6 +305,12 @@ impl TargetSection { 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. @@ -459,6 +465,7 @@ pub struct NativeReleaseRoute { pub dir: String, pub registry: NativeRegistry, pub package: String, + pub vcs_tag: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -468,6 +475,7 @@ pub struct ForgeReleaseRoute { pub registry: ForgeRegistry, pub format: NativeRegistry, pub package: String, + pub vcs_tag: String, } fn is_valid_npm_component(value: &str) -> bool { @@ -734,6 +742,7 @@ fn is_allowed_repo_url(url: &str) -> bool { fn validate_native_release_section( native: &NativeReleaseSection, route_name: &str, + route_dir: &str, inbound: Ecosystem, ) -> Result<(), ManifestError> { native @@ -757,6 +766,43 @@ fn validate_native_release_section( )); } + 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) { @@ -842,7 +888,7 @@ impl Manifest { .to_string(), )); } - validate_native_release_section(native, "repository", self.package.ecosystem())?; + validate_native_release_section(native, "repository", ".", self.package.ecosystem())?; native_routes.insert( ( native.registry, @@ -920,7 +966,12 @@ impl Manifest { .to_string(), )); } - validate_native_release_section(native, name, target.ecosystem_for(name))?; + 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()) { @@ -1049,6 +1100,11 @@ impl Manifest { 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 { @@ -1056,6 +1112,11 @@ impl Manifest { 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() @@ -1078,6 +1139,11 @@ impl Manifest { 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)| { @@ -1092,6 +1158,11 @@ impl Manifest { 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), }) }) })) diff --git a/tests/native_release.rs b/tests/native_release.rs index 45720cd..2f69ab3 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -91,6 +91,7 @@ forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] 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); @@ -99,6 +100,7 @@ forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] 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]")); @@ -431,6 +433,11 @@ fn major_native_registry_identities_and_ecosystems_validate() { ("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}] @@ -439,10 +446,33 @@ 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/")); } } From 8d81220bba8ef726ea4a13fb4baa884b6c13a75f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 29 Jul 2026 23:48:57 -0500 Subject: [PATCH 048/122] Model canonical and forge package release routes (#5) * feat: model native and forge package routes * fix: retain release routes in target artifacts * feat: model native registry tag formats --------- Co-authored-by: Claude Opus 5 (1M context) --- README.md | 38 ++++ schemas/manifest.json | 40 ++++- schemas/publish-meta.json | 40 ++++- src/manifest.rs | 355 +++++++++++++++++++++++++++++++++++--- tests/native_release.rs | 195 ++++++++++++++++++++- 5 files changed, 642 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 0b5f556..28b1b91 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ This crate is the contract everything else builds against: - **Filesystem layout** — `$HOME/.zed-pkg` store, `zed_modules/` symlink dir, archive structure (`paths`) - **VCS + artifact enums** — `git`/`hg`, `tar.gz`/`zip` (`vcs`, `artifact`) +- **Polyglot release routes** — language/ecosystem identity plus canonical + native registries and compatible GitHub/GitLab/Bitbucket package mirrors + (`language`, `manifest`) ## The model in one page @@ -40,6 +43,41 @@ stay self-contained across multi-stage builds. The lockfile pins `sha256`, `size`, `vcs_tag`, and `vcs_commit` per package: installs are reproducible and every artifact traces back to source. +For a polyglot repository, each `[targets.]` slice becomes its own +Zed artifact. An optional `[targets..native]` block declares the +same version for the ecosystem's canonical registry, and `forge` declares +additional package-registry copies: + +```toml +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +``` + +Forge compatibility is validated in the interface contract. A manifest cannot +claim Cargo support in GitHub Packages or PyPI support in Bitbucket Packages; +those combinations fail during manifest parsing, before any release job sees +credentials. + +Tag-resolved ecosystems can override the repository release tag. A Go module +below `clients/go`, for example, declares +`tag_format = "clients/go/v{version}"`; validation rejects a subdirectory Go +route without that prefix. + +A single-language package whose native manifest is at the repository root uses +the same shape under `[publish.native]`: + +```toml +[publish.native] +registry = "npm" +package = "r2g" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +``` + ## Registry API surface | Method | Path | Body / response | diff --git a/schemas/manifest.json b/schemas/manifest.json index f2b9ff0..bac3032 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -155,6 +155,14 @@ } ] }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, "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", @@ -247,17 +255,36 @@ "npm", "crates-io", "pub.dev", - "pypi" + "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": [ @@ -350,6 +377,17 @@ "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" + } + ] + }, "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": [ diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index beffa91..c3cec05 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -116,6 +116,14 @@ } ] }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, "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", @@ -293,17 +301,36 @@ "npm", "crates-io", "pub.dev", - "pypi" + "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": [ @@ -396,6 +423,17 @@ "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" + } + ] + }, "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": [ diff --git a/src/manifest.rs b/src/manifest.rs index 4495bc0..c27c94a 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -173,6 +173,12 @@ pub struct PublishSection { /// 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, } impl Default for PublishSection { @@ -182,6 +188,7 @@ impl Default for PublishSection { include_readme: false, smoke_test: None, tag_format: "v{version}".to_string(), + native: None, } } } @@ -298,6 +305,17 @@ impl TargetSection { 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( @@ -311,6 +329,67 @@ pub enum NativeRegistry { 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 { @@ -320,6 +399,11 @@ impl NativeRegistry { 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", } } @@ -329,6 +413,11 @@ impl NativeRegistry { 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(()) @@ -343,6 +432,7 @@ impl NativeRegistry { fn canonical_package(self, package: &str) -> String { match self { Self::PyPi => normalize_pypi_package(package), + Self::NuGet => package.to_ascii_lowercase(), _ => package.to_string(), } } @@ -360,6 +450,11 @@ impl NativeRegistry { 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, } } } @@ -370,6 +465,17 @@ pub struct NativeReleaseRoute { 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, } fn is_valid_npm_component(value: &str) -> bool { @@ -447,6 +553,53 @@ fn is_valid_pubdev_package(value: &str) -> bool { .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, '.' | '/' | '-' | '_' | '~')) +} + /// 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 @@ -586,6 +739,91 @@ fn is_allowed_repo_url(url: &str) -> bool { || (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 { @@ -641,6 +879,24 @@ impl Manifest { let mut target_dirs = BTreeMap::<&str, &str>::new(); let mut published_names = BTreeMap::::new(); let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::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", + ); + } for (name, target) in &self.targets { if !is_target_name(name) { return Err(ManifestError::InvalidTarget( @@ -710,10 +966,12 @@ impl Manifest { .to_string(), )); } - native - .registry - .validate_package(&native.package) - .map_err(|reason| ManifestError::InvalidNativeRoute(name.clone(), reason))?; + 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()) { @@ -726,23 +984,6 @@ impl Manifest { ), )); } - // The outbound mirror and the inbound install gate describe the - // same ecosystem. If they disagree, one of them is wrong and the - // slice 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 inbound = target.ecosystem_for(name); - let outbound = native.registry.ecosystem(); - if !inbound.is_default() && inbound != outbound { - return Err(ManifestError::InvalidNativeRoute( - name.clone(), - format!( - "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ - set `ecosystem` on the target if the mirror is right", - native.registry.as_str() - ), - )); - } } } // A blank request means "no target", the same way a blank @@ -851,16 +1092,80 @@ impl Manifest { /// 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.targets + self.publish + .native .iter() - .filter_map(|(target, section)| { + .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() } @@ -893,6 +1198,10 @@ impl Manifest { 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.targets = BTreeMap::new(); derived.workspace = None; // The consumer-facing wiring for this ecosystem. diff --git a/tests/native_release.rs b/tests/native_release.rs index 82443fc..2f69ab3 100644 --- a/tests/native_release.rs +++ b/tests/native_release.rs @@ -1,4 +1,4 @@ -use zed_interfaces::manifest::{Manifest, ManifestError, NativeRegistry}; +use zed_interfaces::manifest::{ForgeRegistry, Manifest, ManifestError, NativeRegistry}; fn manifest(targets: &str) -> String { format!( @@ -73,6 +73,63 @@ package = "Acme.Client" 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( @@ -283,3 +340,139 @@ 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/")); + } +} From 07d01604461d00e237c7d86ad3855464167574ec Mon Sep 17 00:00:00 2001 From: alex-mills Date: Wed, 29 Jul 2026 23:49:37 -0500 Subject: [PATCH 049/122] manifest: pick predictably when two targets share a language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repo can legitimately ship separate TypeScript and JavaScript clients, which makes two targets both `nodejs`. `resolve_target_key` took the first match in key order, so a consumer inferred as `node` silently got `javascript` — the wrong client, chosen by alphabetization. Among several same-language targets, prefer the one named after the language itself. An exact key match still wins first, and single-match repos are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- src/manifest.rs | 19 ++++++++++++++++++- tests/roundtrip.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/manifest.rs b/src/manifest.rs index c27c94a..9b26b81 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1232,9 +1232,26 @@ impl Manifest { 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| Language::from_token(key) == Some(wanted)) + .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) } diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index 0100d29..cb6bf9c 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -614,3 +614,39 @@ fn include_readme_also_keeps_the_changelog_registries_ask_for() { // 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")); +} From 3ffca44d2d93ed4caae5737fe4a38fb50e7f3f60 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Thu, 30 Jul 2026 19:04:52 -0500 Subject: [PATCH 050/122] fix: drop superseded publish-route schema left by the draft merge The multi-registry schema draft merge resolved its conflicting hunks to main, but the draft's non-conflicting hunks auto-merged and still referenced the design main replaced: a validate_publish_route block reading publish.format / publish.registries / publish.registry_urls and the same fields on TargetSection. None of those exist on main's route model, so the crate did not compile (11 errors). Removes that validation block and the now-unreferenced publish::PublishRegistry module it was the only consumer of. Routing is validated by the native/[targets.*.native] path that main already has. cargo check is clean and all 42 tests pass. --- src/lib.rs | 2 - src/manifest.rs | 19 -------- src/publish.rs | 110 --------------------------------------------- tests/roundtrip.rs | 1 - 4 files changed, 132 deletions(-) delete mode 100644 src/publish.rs diff --git a/src/lib.rs b/src/lib.rs index 51c1fd3..cdb283b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,6 @@ pub mod language; pub mod lockfile; pub mod manifest; pub mod paths; -pub mod publish; pub mod registry; pub mod sync; pub mod vcs; @@ -22,6 +21,5 @@ pub use artifact::ArtifactFormat; pub use language::{Ecosystem, Language, detect_ecosystems}; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; -pub use publish::PublishRegistry; pub use vcs::Vcs; pub use version::{Requirement, VersionScheme}; diff --git a/src/manifest.rs b/src/manifest.rs index 3587cb9..9b26b81 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -986,25 +986,6 @@ impl Manifest { } } } - if self.targets.is_empty() { - validate_publish_route( - "package", - self.publish.format.as_deref(), - self.publish.registries.as_deref(), - &self.publish.registry_urls, - )?; - } else { - for (name, target) in &self.targets { - let format = target.format.as_deref().or(self.publish.format.as_deref()); - let registries = target - .registries - .as_deref() - .or(self.publish.registries.as_deref()); - let mut urls = self.publish.registry_urls.clone(); - urls.extend(target.registry_urls.clone()); - validate_publish_route(name, format, registries, &urls)?; - } - } // 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() diff --git a/src/publish.rs b/src/publish.rs deleted file mode 100644 index 58615eb..0000000 --- a/src/publish.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::fmt; -use std::str::FromStr; - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -/// A registry family that can receive one package produced by `zed publish`. -/// -/// `zed` is zpkg.tech (or the CLI's configured Zed-compatible registry), -/// `native` is the ecosystem's canonical registry (npmjs, crates.io, PyPI, -/// and so on), and the remaining variants are package registries operated by -/// source forges. The source repository itself remains configured separately -/// under `[package.repository]`. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, -)] -#[serde(rename_all = "kebab-case")] -pub enum PublishRegistry { - Zed, - Native, - GithubPackages, - GitlabPackages, - BitbucketPackages, -} - -impl PublishRegistry { - pub const ALL: [Self; 5] = [ - Self::Zed, - Self::Native, - Self::GithubPackages, - Self::GitlabPackages, - Self::BitbucketPackages, - ]; - - pub fn as_str(self) -> &'static str { - match self { - Self::Zed => "zed", - Self::Native => "native", - Self::GithubPackages => "github-packages", - Self::GitlabPackages => "gitlab-packages", - Self::BitbucketPackages => "bitbucket-packages", - } - } - - /// Whether this registry family currently accepts the package-manager - /// format. Zed accepts every format because it stores the deterministic - /// zpkg artifact; forge registries intentionally fail closed to the - /// formats their public APIs document. - pub fn supports_format(self, format: &str) -> bool { - match self { - Self::Zed => true, - Self::Native => !matches!(format, "zpkg" | "generic"), - Self::GithubPackages => { - matches!(format, "npm" | "rubygems" | "maven" | "nuget" | "container") - } - Self::GitlabPackages => matches!( - format, - "composer" - | "conan" - | "debian" - | "generic" - | "go" - | "helm" - | "maven" - | "npm" - | "nuget" - | "pypi" - | "rubygems" - | "terraform" - ), - Self::BitbucketPackages => matches!(format, "npm" | "maven" | "container"), - } - } -} - -impl fmt::Display for PublishRegistry { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParsePublishRegistryError(pub String); - -impl fmt::Display for ParsePublishRegistryError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "unknown publish registry `{}` (expected zed, native, github-packages, gitlab-packages, or bitbucket-packages)", - self.0 - ) - } -} - -impl std::error::Error for ParsePublishRegistryError {} - -impl FromStr for PublishRegistry { - type Err = ParsePublishRegistryError; - - fn from_str(value: &str) -> Result { - match value { - "zed" => Ok(Self::Zed), - "native" => Ok(Self::Native), - "github-packages" | "github" => Ok(Self::GithubPackages), - "gitlab-packages" | "gitlab" => Ok(Self::GitlabPackages), - "bitbucket-packages" | "bitbucket" => Ok(Self::BitbucketPackages), - other => Err(ParsePublishRegistryError(other.to_string())), - } - } -} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index b14ca53..cb6bf9c 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -4,7 +4,6 @@ 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::publish::PublishRegistry; use zed_interfaces::vcs::Vcs; const SAMPLE: &str = r#" From cdbd901a0b375c5a68b9deefbd3e03437f5f88e8 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 31 Jul 2026 02:08:47 -0500 Subject: [PATCH 051/122] docs(DEN-132): add canonical interface agent instructions --- agents.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 agents.md diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..0a3eb6d --- /dev/null +++ b/agents.md @@ -0,0 +1,25 @@ +# 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. +- Run formatting, compilation, tests, doctests, and Clippy using the repository's pinned toolchain and lockfile. + +## Validation + +The pinned `agents policy` workflow validates this hierarchy and the three tool pointers. Run the repository checks documented in `README.md` and existing CI before requesting review. From a6166a9cee36654c87ef759dafc3ea69051a0fcf Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 31 Jul 2026 02:09:01 -0500 Subject: [PATCH 052/122] docs(DEN-132): add Claude pointer --- .claude/CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .claude/CLAUDE.md 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`. From da8d62a5970fa8dc94c277f65f7b1f1283d1cedb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 31 Jul 2026 02:09:09 -0500 Subject: [PATCH 053/122] docs(DEN-132): add Gemini pointer --- .gemini/GEMINI.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gemini/GEMINI.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`. From 40e2f6c27e7d8598fa46a6cd67296a0825fac73c Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 31 Jul 2026 02:10:33 -0500 Subject: [PATCH 054/122] docs(DEN-132): add OpenAI pointer --- .openai/AGENTS.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .openai/AGENTS.md 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`. From fa7ae5c1a5f57df979680e37dbb7029b1bab1cf4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 31 Jul 2026 02:11:41 -0500 Subject: [PATCH 055/122] ci(DEN-132): enforce pinned agents policy --- .github/workflows/agents-policy.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/agents-policy.yml 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 From 1bafce636425e78c344f5f6065d41863c62ac22b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:46:43 -0500 Subject: [PATCH 056/122] DEN-359 add pinned interface-contract Nix flake --- flake.nix | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 flake.nix diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..84867fd --- /dev/null +++ b/flake.nix @@ -0,0 +1,78 @@ +{ + 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; + }; + } + ); + }; +} From fc29130af4fb73c2f60748cc15c9f62075102628 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:46:53 -0500 Subject: [PATCH 057/122] DEN-359 pin interface Nix dependencies --- flake.lock | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 flake.lock 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 +} From 1c5fc2acab961ce32d4ff1dc28a62dde256440d6 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:47:02 -0500 Subject: [PATCH 058/122] DEN-359 add isolated Rust contract shell --- .nix/dev-shell.nix | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .nix/dev-shell.nix 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" + ''; +} From aa3be67b01a6b3ca9862fae94953fbce3a5357cb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:47:14 -0500 Subject: [PATCH 059/122] DEN-359 add non-interactive contract validation --- .nix/agent-check.sh | 64 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .nix/agent-check.sh diff --git a/.nix/agent-check.sh b/.nix/agent-check.sh new file mode 100644 index 0000000..524bf4c --- /dev/null +++ b/.nix/agent-check.sh @@ -0,0 +1,64 @@ +#!/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) + cargo clippy --locked --all-targets -- -D warnings + ;; + 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 From 1224b73e3d5d922f686320190b69b4cf97ccf548 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:47:31 -0500 Subject: [PATCH 060/122] DEN-359 add pinned interface Nix CI --- .github/workflows/nix.yml | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/nix.yml 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 From aef2a3dc961867c63a376385e26acc7ec2a4a6be Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:47:40 -0500 Subject: [PATCH 061/122] DEN-359 pin and harden interface CI --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b13cec..4825bdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,25 +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: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - 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 + components: rustfmt,clippy + - name: Format run: cargo fmt --check + + - name: Clippy + run: cargo clippy --locked --all-targets -- -D warnings + - name: Test - run: cargo test - # The generated JSON schemas are committed for the non-Rust SDKs to - # mirror; fail if they drift from the source types. + run: cargo test --locked + + - name: Doctest + run: cargo test --locked --doc + - name: Schemas are up to date run: | - cargo run --example generate_schemas + cargo run --locked --example generate_schemas git diff --exit-code schemas/ \ - || (echo "schemas/ is stale; run 'cargo run --example generate_schemas' and commit" && exit 1) + || (echo "schemas/ is stale; run 'cargo run --locked --example generate_schemas' and commit" && exit 1) From f219bae3b593205583860d89b7ff37c3572ca01d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:47:48 -0500 Subject: [PATCH 062/122] DEN-359 ignore local Nix agent caches --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ea8c4bf..909180f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.cache/ From a47504b677fa97933eb2950aec52f1c075b8281c Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:48:00 -0500 Subject: [PATCH 063/122] DEN-359 document pinned interface validation --- agents.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/agents.md b/agents.md index 0a3eb6d..6983aed 100644 --- a/agents.md +++ b/agents.md @@ -18,8 +18,29 @@ This crate is the shared contract boundary for Zed manifests, lockfiles, registr - 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. -- Run formatting, compilation, tests, doctests, and Clippy using the repository's pinned toolchain and lockfile. +- 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 the repository checks documented in `README.md` and existing CI before requesting review. +The pinned `agents policy` workflow validates this hierarchy and the three tool pointers. Run `nix develop -c agent-check` before requesting review. From 2d61dc6663b16d26018def595e80cfed7b3e5791 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:51:45 -0500 Subject: [PATCH 064/122] DEN-359 apply canonical Nix formatting --- flake.nix | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/flake.nix b/flake.nix index 84867fd..d6b7726 100644 --- a/flake.nix +++ b/flake.nix @@ -16,19 +16,22 @@ ]; 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 - ]; + toolchainFor = + pkgs: + with pkgs; + [ + actionlint + bash + cargo + clippy + git + nix + nixfmt + rustc + rustfmt + shellcheck + shfmt + ]; in { formatter = forAllSystems (system: (pkgsFor system).nixfmt); From db78d123525f708054e434ef35f91b51680b4397 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:52:31 -0500 Subject: [PATCH 065/122] DEN-359 preserve version precedence while satisfying Clippy --- src/version.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/version.rs b/src/version.rs index fbc6732..70a0f9f 100644 --- a/src/version.rs +++ b/src/version.rs @@ -182,25 +182,25 @@ 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') { - if let Ok(v) = Version::parse(stripped) { - 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) { - if let Ok(v) = Version::parse(&go) { - 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) { - if let Ok(v) = Version::parse(&cal) { - 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) { - if let Ok(v) = Version::parse(&pep) { - return Some(v); - } + if let Some(pep) = normalize_pep440(raw) + && let Ok(v) = Version::parse(&pep) + { + return Some(v); } None } From 997ab2de6bdcefbb247fd354516dbe4c97808a06 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:55:10 -0500 Subject: [PATCH 066/122] DEN-359 capture canonical Nix formatter output --- .github/workflows/nix.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 93a336d..7d1908f 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -54,5 +54,10 @@ jobs: extra_nix_config: | experimental-features = nix-command flakes - - name: Run pinned contract checks - run: nix develop --no-update-lock-file -c agent-check + - name: Capture canonical Nix formatting + run: | + nix develop --no-update-lock-file -c bash -lc ' + nixfmt flake.nix .nix/dev-shell.nix + git diff -- flake.nix .nix/dev-shell.nix + git diff --quiet -- flake.nix .nix/dev-shell.nix + ' From 49da7bbdff7da7b17f7c0f6714ea2a9234757614 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:56:31 -0500 Subject: [PATCH 067/122] DEN-359 apply exact canonical Nix formatting --- flake.nix | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index d6b7726..8af92a7 100644 --- a/flake.nix +++ b/flake.nix @@ -17,9 +17,7 @@ forAllSystems = nixpkgs.lib.genAttrs systems; pkgsFor = system: import nixpkgs { inherit system; }; toolchainFor = - pkgs: - with pkgs; - [ + pkgs: with pkgs; [ actionlint bash cargo From d6525acc21fdba999bc78a375e6c055a25560fe9 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 21:56:45 -0500 Subject: [PATCH 068/122] DEN-359 restore strict non-mutating Nix validation --- .github/workflows/nix.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 7d1908f..93a336d 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -54,10 +54,5 @@ jobs: extra_nix_config: | experimental-features = nix-command flakes - - name: Capture canonical Nix formatting - run: | - nix develop --no-update-lock-file -c bash -lc ' - nixfmt flake.nix .nix/dev-shell.nix - git diff -- flake.nix .nix/dev-shell.nix - git diff --quiet -- flake.nix .nix/dev-shell.nix - ' + - name: Run pinned contract checks + run: nix develop --no-update-lock-file -c agent-check From ff428c159dc1c835b0f7ad016589ae3d86db195b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 1 Aug 2026 22:00:59 -0500 Subject: [PATCH 069/122] DEN-359 align pinned and current Clippy policies --- .nix/agent-check.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.nix/agent-check.sh b/.nix/agent-check.sh index 524bf4c..e0c193f 100644 --- a/.nix/agent-check.sh +++ b/.nix/agent-check.sh @@ -30,7 +30,13 @@ run_stage() { cargo fmt --check ;; lint) - cargo clippy --locked --all-targets -- -D warnings + # 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 From f141e4cfae31a74c679b46d1d4bb0146b20555f6 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Sun, 2 Aug 2026 11:12:08 -0500 Subject: [PATCH 070/122] Audit contract: tamper-evidence, filtering, and generated schemas The audit log records who changed published state, but nothing made it tamper-evident: anyone with database access could delete or edit a row silently, which defeats a forensic trail. This adds the contract half of a hash chain, plus the query surface the log needs to be usable at scale. - AuditEntry gains seq/entry_hash/prev_hash. All are #[serde(default)] and the empties are skipped, so a body from a pre-chain server still deserializes and the serialized shape is unchanged when unset. - audit_chain_preimage() defines the exact bytes hashed, here in the shared contract rather than in the server, so a client can verify the chain itself. A tamper-evident log whose only checker is the party able to tamper is not much of a guarantee. - Every field is length-prefixed. A separator-joined encoding is forgeable: a token named `x|publish` could shift field boundaries and reproduce a different entry's digest. A test asserts distinct entries cannot collide and that each field participates. - The input is a named-field struct, not ten positional &str: transposing subject and actor_token_name would otherwise compile silently and change every digest. - audit_verify_path() + AuditIntegrityResponse report intactness, the first bad seq, the failure kind, and a head hash an operator can anchor externally so tail truncation is also detectable. - Both audit responses now generate JSON schemas like every other client-facing DTO; they had been missed. nix develop -c agent-check passes (fmt, clippy -D warnings, tests, doctests, schema generation, drift check). Co-Authored-By: Claude Opus 5 (1M context) --- examples/generate_schemas.rs | 4 + schemas/audit-integrity-response.json | 48 +++++++ schemas/audit-log-response.json | 117 ++++++++++++++++ src/registry.rs | 103 +++++++++++++- tests/roundtrip.rs | 184 ++++++++++++++++++++++++++ 5 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 schemas/audit-integrity-response.json create mode 100644 schemas/audit-log-response.json diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 0de25cb..905caf9 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -31,6 +31,10 @@ fn main() { 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. 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/src/registry.rs b/src/registry.rs index 61ce4ed..9a62c2b 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -80,12 +80,20 @@ pub fn orgs_path() -> String { format!("{API_V1}/orgs") } -/// `GET ?limit=` (bearer token, org `owner` or admin) — the org's audit log: -/// who changed published state, what, and when (zed-docs issue #7 governance). +/// `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() @@ -323,6 +331,76 @@ pub struct AuditEntry { /// 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)] @@ -332,6 +410,27 @@ pub struct AuditLogResponse { 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 { diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index cb6bf9c..63145be 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -650,3 +650,187 @@ dir = "clients/ts" 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 + }), + ); +} From 74905f7ed1521de90a32befd5e6a835243333909 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sun, 2 Aug 2026 23:05:56 -0500 Subject: [PATCH 071/122] feat(DEN-1417): add canonical Nix interop contract v1 Land the service-independent Nix interoperability contract defined by the merged RFC. Includes explicit systems/outputs, immutable Zed and Nix origin evidence, NAR and artifact identities, strict policy evidence, closure-free v1 imports, canonical JSON, fail-closed versioning, and public contract tests. Manifest/lock embedding and generated adapter schemas remain staged under DEN-1417. --- src/lib.rs | 6 + src/nix.rs | 1003 +++++++++++++++++++++++++++++++++ tests/nix_interop_contract.rs | 97 ++++ 3 files changed, 1106 insertions(+) create mode 100644 src/nix.rs create mode 100644 tests/nix_interop_contract.rs diff --git a/src/lib.rs b/src/lib.rs index cdb283b..72db9ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod excludes; pub mod language; pub mod lockfile; pub mod manifest; +pub mod nix; pub mod paths; pub mod registry; pub mod sync; @@ -21,5 +22,10 @@ pub use artifact::ArtifactFormat; pub use language::{Ecosystem, Language, detect_ecosystems}; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; +pub use nix::{ + NIX_ADAPTER_SCHEMA_V1, NixAdapterRecord, NixBuilderNetwork, NixExportMode, NixExportSection, + NixInteropArtifact, NixInteropError, NixOutputOrigin, NixPackageIdentity, NixPolicyEvidence, + NixPolicyProfile, NixRealizedOutput, NixStoreReference, ZedArtifactOrigin, +}; pub use vcs::Vcs; pub use version::{Requirement, VersionScheme}; diff --git a/src/nix.rs b/src/nix.rs new file mode 100644 index 0000000..6556c9d --- /dev/null +++ b/src/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/tests/nix_interop_contract.rs b/tests/nix_interop_contract.rs new file mode 100644 index 0000000..ac84955 --- /dev/null +++ b/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()); +} From 19e6d74d9f9ff92d549d2072793ebe1116a25d90 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sun, 2 Aug 2026 23:16:08 -0500 Subject: [PATCH 072/122] feat(DEN-1420): add universal environment-plan contract Add the manager-neutral environment contract for mise/asdf/Devbox/Flox/Nix adapters and native Zed environments, including frozen portability validation, immutable provenance, canonicalization, and comprehensive unit coverage. --- src/environment.rs | 900 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 7 + 2 files changed, 907 insertions(+) create mode 100644 src/environment.rs diff --git a/src/environment.rs b/src/environment.rs new file mode 100644 index 0000000..4c9063b --- /dev/null +++ b/src/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/lib.rs b/src/lib.rs index 72db9ee..865ec85 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ //! `schemas/`) by the non-Rust client libraries in `zed-clients`. pub mod artifact; +pub mod environment; pub mod excludes; pub mod language; pub mod lockfile; @@ -19,6 +20,12 @@ 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 language::{Ecosystem, Language, detect_ecosystems}; pub use lockfile::{LockedPackage, Lockfile}; pub use manifest::{Manifest, ManifestError}; From 625ba7bdf4339df7d0880f791a0715ca894558fe Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sun, 2 Aug 2026 23:18:53 -0500 Subject: [PATCH 073/122] feat(DEN-1417): embed Nix intent and provenance in file formats Make Nix interoperability a first-class, additive zed-pkg file-format contract. Adds validated single-package and per-target export intent, deterministic route planning, backward-compatible lock provenance with collision-safe upsert semantics, standalone JSON schemas, and compatibility/conformance tests. Exact-head Rust, pinned Nix, and agent-policy checks were green before merge. --- examples/generate_schemas.rs | 2 + schemas/lockfile.json | 368 +++++++++++++++++++++++++++++++ schemas/manifest.json | 62 ++++++ schemas/nix-adapter-record.json | 373 ++++++++++++++++++++++++++++++++ schemas/nix-export-section.json | 44 ++++ schemas/publish-meta.json | 62 ++++++ src/lib.rs | 4 +- src/lockfile.rs | 104 ++++++++- src/manifest.rs | 77 +++++++ tests/nix_manifest_lock.rs | 242 +++++++++++++++++++++ tests/nix_schema_contract.rs | 33 +++ 11 files changed, 1365 insertions(+), 6 deletions(-) create mode 100644 schemas/nix-adapter-record.json create mode 100644 schemas/nix-export-section.json create mode 100644 tests/nix_manifest_lock.rs create mode 100644 tests/nix_schema_contract.rs diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 905caf9..047a133 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -22,6 +22,8 @@ fn main() { write::(dir, "manifest"); write::(dir, "lockfile"); + write::(dir, "nix-export-section"); + write::(dir, "nix-adapter-record"); write::(dir, "package-metadata"); write::(dir, "version-metadata"); write::(dir, "publish-meta"); diff --git a/schemas/lockfile.json b/schemas/lockfile.json index b1b3d31..6627586 100644 --- a/schemas/lockfile.json +++ b/schemas/lockfile.json @@ -4,6 +4,13 @@ "description": "The `.zpkg.lock` file written next to `.zpkg.toml` after resolution.\n\nSerialized as TOML with one `[[package]]` table per locked package,\nCargo.lock-style. Every entry pins the exact artifact hash and the VCS\ntag it was published from, so installs are reproducible and every\nartifact is traceable back to source.", "type": "object", "properties": { + "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": { @@ -78,6 +85,367 @@ "vcs_tag", "source" ] + }, + "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 index bac3032..7d88fd8 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -292,6 +292,46 @@ "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", @@ -388,6 +428,17 @@ } ] }, + "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": [ @@ -468,6 +519,17 @@ "type": "null" } ] + }, + "nix": { + "description": "Optional deterministic Nix export intent for this isolated target.", + "anyOf": [ + { + "$ref": "#/$defs/NixExportSection" + }, + { + "type": "null" + } + ] } }, "required": [ 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-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/publish-meta.json b/schemas/publish-meta.json index c3cec05..d6cbe20 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -338,6 +338,46 @@ "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", @@ -434,6 +474,17 @@ } ] }, + "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": [ @@ -514,6 +565,17 @@ "type": "null" } ] + }, + "nix": { + "description": "Optional deterministic Nix export intent for this isolated target.", + "anyOf": [ + { + "$ref": "#/$defs/NixExportSection" + }, + { + "type": "null" + } + ] } }, "required": [ diff --git a/src/lib.rs b/src/lib.rs index 865ec85..3aa22e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,8 +27,8 @@ pub use environment::{ validate_semver_export, }; pub use language::{Ecosystem, Language, detect_ecosystems}; -pub use lockfile::{LockedPackage, Lockfile}; -pub use manifest::{Manifest, ManifestError}; +pub use lockfile::{LockedPackage, Lockfile, LockfileError}; +pub use manifest::{Manifest, ManifestError, NixExportRoute}; pub use nix::{ NIX_ADAPTER_SCHEMA_V1, NixAdapterRecord, NixBuilderNetwork, NixExportMode, NixExportSection, NixInteropArtifact, NixInteropError, NixOutputOrigin, NixPackageIdentity, NixPolicyEvidence, diff --git a/src/lockfile.rs b/src/lockfile.rs index 23a01ae..b129221 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -1,7 +1,12 @@ +use std::collections::BTreeSet; + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::artifact::ArtifactFormat; +use crate::nix::NixAdapterRecord; + +type NixAdapterKey = (String, String, String, Option, u8, String, String); /// The `.zpkg.lock` file written next to `.zpkg.toml` after resolution. /// @@ -14,6 +19,11 @@ pub struct Lockfile { pub version: u32, #[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")] pub packages: 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)] @@ -41,6 +51,10 @@ pub enum LockfileError { Toml(String), #[error("unsupported lockfile version {0} (this build supports {1})")] UnsupportedVersion(u32, u32), + #[error("invalid Nix adapter provenance: {0}")] + InvalidNixAdapter(String), + #[error("duplicate Nix adapter provenance key `{0}`")] + DuplicateNixAdapter(String), } impl Default for Lockfile { @@ -48,6 +62,7 @@ impl Default for Lockfile { Self { version: Self::CURRENT_VERSION, packages: Vec::new(), + nix_adapters: Vec::new(), } } } @@ -57,34 +72,69 @@ impl Lockfile { pub fn parse(input: &str) -> Result { let lockfile: Lockfile = - toml::from_str(input).map_err(|e| LockfileError::Toml(e.to_string()))?; + 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_nix_adapters()?; Ok(lockfile) } pub fn to_toml_string(&self) -> Result { - toml::to_string_pretty(self).map_err(|e| LockfileError::Toml(e.to_string())) + self.validate_nix_adapters()?; + let mut normalized = self.clone(); + 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(|p| p.org == org && p.name == name) + .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(|p| !(p.org == package.org && p.name == package.name)); + .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))); } + + /// 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 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 { @@ -92,3 +142,49 @@ impl LockedPackage { format!("{}/{}", self.org, self.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("-") + ) +} diff --git a/src/manifest.rs b/src/manifest.rs index 9b26b81..76b3b98 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -4,6 +4,7 @@ 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}; @@ -179,6 +180,11 @@ pub struct PublishSection { /// 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 { @@ -189,6 +195,7 @@ impl Default for PublishSection { smoke_test: None, tag_format: "v{version}".to_string(), native: None, + nix: None, } } } @@ -275,6 +282,9 @@ pub struct TargetSection { /// [`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, /// 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` @@ -478,6 +488,14 @@ pub struct ForgeReleaseRoute { 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 @@ -667,6 +685,8 @@ pub enum ManifestError { 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), } @@ -879,6 +899,7 @@ impl Manifest { 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( @@ -897,6 +918,19 @@ impl Manifest { "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"); + } for (name, target) in &self.targets { if !is_target_name(name) { return Err(ManifestError::InvalidTarget( @@ -947,6 +981,20 @@ impl Manifest { ), )); } + 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) { @@ -1089,6 +1137,34 @@ impl Manifest { }) } + /// 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 { @@ -1202,6 +1278,7 @@ impl Manifest { // 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.targets = BTreeMap::new(); derived.workspace = None; // The consumer-facing wiring for this ecosystem. diff --git a/tests/nix_manifest_lock.rs b/tests/nix_manifest_lock.rs new file mode 100644 index 0000000..1036929 --- /dev/null +++ b/tests/nix_manifest_lock.rs @@ -0,0 +1,242 @@ +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 +vcs_tag = "v1.2.3" +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/tests/nix_schema_contract.rs b/tests/nix_schema_contract.rs new file mode 100644 index 0000000..18ac1ec --- /dev/null +++ b/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")); +} From 2cab25b83306cd04045c315ee15c53863fd02264 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sun, 2 Aug 2026 23:55:02 -0500 Subject: [PATCH 074/122] feat: add OCI artifact interoperability contract --- src/oci.rs | 896 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 896 insertions(+) create mode 100644 src/oci.rs diff --git a/src/oci.rs b/src/oci.rs new file mode 100644 index 0000000..178c287 --- /dev/null +++ b/src/oci.rs @@ -0,0 +1,896 @@ +//! 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()); + } +} \ No newline at end of file From 3c63bb5e7847a8011cac2f7c484014146fe206a7 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sun, 2 Aug 2026 23:55:15 -0500 Subject: [PATCH 075/122] feat: export OCI interoperability types --- src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 3aa22e3..db025a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod language; pub mod lockfile; pub mod manifest; pub mod nix; +pub mod oci; pub mod paths; pub mod registry; pub mod sync; @@ -34,5 +35,13 @@ pub use nix::{ NixInteropArtifact, NixInteropError, NixOutputOrigin, NixPackageIdentity, NixPolicyEvidence, NixPolicyProfile, NixRealizedOutput, NixStoreReference, ZedArtifactOrigin, }; +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}; From fc17a1e14dbc1b6fbeb50e367bbdcb5ee52b2380 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 00:17:37 -0500 Subject: [PATCH 076/122] ci: materialize formatted OCI contract sources --- .github/workflows/oci-bootstrap.yml | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/oci-bootstrap.yml diff --git a/.github/workflows/oci-bootstrap.yml b/.github/workflows/oci-bootstrap.yml new file mode 100644 index 0000000..4cc021b --- /dev/null +++ b/.github/workflows/oci-bootstrap.yml @@ -0,0 +1,37 @@ +name: oci-contract-bootstrap + +on: + pull_request: + paths: + - src/oci.rs + - src/lib.rs + - .github/workflows/oci-bootstrap.yml + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Format + run: cargo fmt --all + - name: Check + run: cargo check --all-targets + - name: Test OCI contract + run: cargo test oci --all-targets + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Preserve formatted sources + if: always() + uses: actions/upload-artifact@v4 + with: + name: oci-contract-bootstrap + if-no-files-found: error + path: | + src/oci.rs + src/lib.rs From a3ce1c664d7b44f6c19bd180a499a930969c066d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 00:21:01 -0500 Subject: [PATCH 077/122] feat: generate OCI adapter contract schema --- examples/generate_schemas.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 047a133..9d875bd 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -24,6 +24,7 @@ fn main() { write::(dir, "lockfile"); write::(dir, "nix-export-section"); write::(dir, "nix-adapter-record"); + write::(dir, "oci-adapter-record"); write::(dir, "package-metadata"); write::(dir, "version-metadata"); write::(dir, "publish-meta"); @@ -50,4 +51,4 @@ fn main() { write::(dir, "semantic-search-request"); write::(dir, "semantic-search-response"); write::(dir, "embedding-upsert-request"); -} +} \ No newline at end of file From 5a9798fba3ae7bf35ab301c8f86c5921970e1ba4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 00:21:26 -0500 Subject: [PATCH 078/122] ci: materialize formatted OCI sources and schema --- .github/workflows/oci-bootstrap.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/oci-bootstrap.yml b/.github/workflows/oci-bootstrap.yml index 4cc021b..48b57fc 100644 --- a/.github/workflows/oci-bootstrap.yml +++ b/.github/workflows/oci-bootstrap.yml @@ -5,6 +5,8 @@ on: paths: - src/oci.rs - src/lib.rs + - examples/generate_schemas.rs + - schemas/oci-adapter-record.json - .github/workflows/oci-bootstrap.yml permissions: @@ -26,7 +28,9 @@ jobs: run: cargo test oci --all-targets - name: Clippy run: cargo clippy --all-targets -- -D warnings - - name: Preserve formatted sources + - name: Generate JSON schemas + run: cargo run --example generate_schemas + - name: Preserve formatted sources and schema if: always() uses: actions/upload-artifact@v4 with: @@ -35,3 +39,5 @@ jobs: path: | src/oci.rs src/lib.rs + examples/generate_schemas.rs + schemas/oci-adapter-record.json From 548aa10d5e015b441874870f789fdf77493ce66a Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 00:26:39 -0500 Subject: [PATCH 079/122] ci: self-materialize formatted OCI contract outputs --- .github/workflows/oci-bootstrap.yml | 34 +++++++++++++++++++---------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/workflows/oci-bootstrap.yml b/.github/workflows/oci-bootstrap.yml index 48b57fc..dc277b7 100644 --- a/.github/workflows/oci-bootstrap.yml +++ b/.github/workflows/oci-bootstrap.yml @@ -10,18 +10,32 @@ on: - .github/workflows/oci-bootstrap.yml permissions: - contents: read + contents: write jobs: validate: + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + persist-credentials: true - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - name: Format run: cargo fmt --all + - name: Commit formatted sources without force-pushing + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/oci.rs src/lib.rs examples/generate_schemas.rs + if ! git diff --cached --quiet; then + git commit -m "style: format OCI interoperability contract" + git push origin "HEAD:${GITHUB_HEAD_REF}" + fi - name: Check run: cargo check --all-targets - name: Test OCI contract @@ -30,14 +44,10 @@ jobs: run: cargo clippy --all-targets -- -D warnings - name: Generate JSON schemas run: cargo run --example generate_schemas - - name: Preserve formatted sources and schema - if: always() - uses: actions/upload-artifact@v4 - with: - name: oci-contract-bootstrap - if-no-files-found: error - path: | - src/oci.rs - src/lib.rs - examples/generate_schemas.rs - schemas/oci-adapter-record.json + - name: Commit generated OCI schema without force-pushing + run: | + git add schemas/oci-adapter-record.json + if ! git diff --cached --quiet; then + git commit -m "chore: generate OCI adapter JSON schema" + git push origin "HEAD:${GITHUB_HEAD_REF}" + fi From 0ca2c705ed9742e9baf47df5b58c2aaf2a6db394 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 00:27:40 -0500 Subject: [PATCH 080/122] ci: cancel superseded OCI bootstrap runs --- .github/workflows/oci-bootstrap.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/oci-bootstrap.yml b/.github/workflows/oci-bootstrap.yml index dc277b7..1da43d9 100644 --- a/.github/workflows/oci-bootstrap.yml +++ b/.github/workflows/oci-bootstrap.yml @@ -12,6 +12,10 @@ on: permissions: contents: write +concurrency: + group: oci-contract-bootstrap-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: validate: if: github.event.pull_request.head.repo.full_name == github.repository From d0ebd17f9dca5f33657271de867b3dd928a20c51 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:35:59 +0000 Subject: [PATCH 081/122] style: format OCI interoperability contract --- examples/generate_schemas.rs | 2 +- src/lib.rs | 6 +-- src/oci.rs | 82 ++++++++++++++++-------------------- 3 files changed, 40 insertions(+), 50 deletions(-) diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 9d875bd..b7710a7 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -51,4 +51,4 @@ fn main() { write::(dir, "semantic-search-request"); write::(dir, "semantic-search-response"); write::(dir, "embedding-upsert-request"); -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index db025a8..bb1301a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,9 +39,9 @@ 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, + 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/oci.rs b/src/oci.rs index 178c287..f02673a 100644 --- a/src/oci.rs +++ b/src/oci.rs @@ -18,26 +18,19 @@ use serde_json::Value; 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"; +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"; +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"; +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"; +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"; +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"; +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"; +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. @@ -84,8 +77,7 @@ impl OciDigest { .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(), + "SHA-256 digest payload must be 64 lowercase hexadecimal characters".to_string(), )); } Ok(()) @@ -155,8 +147,7 @@ impl OciReference { 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(), + "OCI reference must include both a registry and repository path".to_string(), )); }; let (repository, tag) = match repository_and_tag.rsplit_once(':') { @@ -269,9 +260,7 @@ impl OciPackageIdentity { } /// OCI platform selector for multi-platform package payloads. -#[derive( - Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, -)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] pub struct OciPlatform { pub os: String, pub architecture: String, @@ -497,8 +486,7 @@ impl OciAdapterRecord { } if !has_primary_package { return Err(OciInteropError::InvalidAdapter( - "a finalized OCI artifact requires a package archive or binary layer" - .to_string(), + "a finalized OCI artifact requires a package archive or binary layer".to_string(), )); } Ok(()) @@ -570,8 +558,7 @@ fn validate_registry(registry: &str) -> Result<(), OciInteropError> { 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(), + "contract v1 does not accept bracketed or ambiguous IPv6 registry literals".to_string(), )); } let (host, port) = match registry.split_once(':') { @@ -602,7 +589,11 @@ fn validate_registry(registry: &str) -> Result<(), OciInteropError> { 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()) + || 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" @@ -617,11 +608,11 @@ fn validate_repository(repository: &str) -> Result<(), OciInteropError> { || repository != repository.to_ascii_lowercase() || repository.split('/').any(|component| { component.is_empty() - || !component - .bytes() - .all(|byte| byte.is_ascii_lowercase() + || !component.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() - || matches!(byte, b'.' | b'_' | b'-')) + || matches!(byte, b'.' | b'_' | b'-') + }) || !component .as_bytes() .first() @@ -647,9 +638,9 @@ fn validate_tag(tag: &str) -> Result<(), OciInteropError> { }; if tag.len() > 128 || !first.is_ascii_alphanumeric() && *first != b'_' - || !tag.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | 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}}`" @@ -694,7 +685,10 @@ fn is_media_type(value: &str) -> bool { && 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'-') + || matches!( + byte, + b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-' + ) }) } @@ -725,9 +719,7 @@ fn is_platform_token(value: &str) -> bool { .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'-') + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-') }) } @@ -742,9 +734,7 @@ fn canonicalize_json(value: Value) -> Value { } Value::Object(sorted) } - Value::Array(values) => { - Value::Array(values.into_iter().map(canonicalize_json).collect()) - } + Value::Array(values) => Value::Array(values.into_iter().map(canonicalize_json).collect()), scalar => scalar, } } @@ -783,10 +773,7 @@ mod tests { fn record(layers: Vec) -> OciAdapterRecord { OciAdapterRecord::new( package(), - OciReference::parse(&format!( - "oci://ghcr.io/acme/tool:1.2.3@{DIGEST_A}" - )) - .unwrap(), + 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, @@ -811,7 +798,10 @@ mod tests { 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}")); + assert_eq!( + reference.to_string(), + format!("oci://registry.example:5000/team/tool:1.2.3@{DIGEST_A}") + ); } #[test] @@ -893,4 +883,4 @@ mod tests { }]); assert!(metadata_only.validate().is_err()); } -} \ No newline at end of file +} From 8fafcabfb2126a5b383cc2d8d5ecedcd53257e52 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:36:27 +0000 Subject: [PATCH 082/122] chore: generate OCI adapter JSON schema --- schemas/oci-adapter-record.json | 207 ++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 schemas/oci-adapter-record.json 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" + ] + } + } +} From 22dc84b509f99608d1297b210fcd3e72fa43b956 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 00:46:45 -0500 Subject: [PATCH 083/122] ci: remove temporary OCI contract bootstrap --- .github/workflows/oci-bootstrap.yml | 57 ----------------------------- 1 file changed, 57 deletions(-) delete mode 100644 .github/workflows/oci-bootstrap.yml diff --git a/.github/workflows/oci-bootstrap.yml b/.github/workflows/oci-bootstrap.yml deleted file mode 100644 index 1da43d9..0000000 --- a/.github/workflows/oci-bootstrap.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: oci-contract-bootstrap - -on: - pull_request: - paths: - - src/oci.rs - - src/lib.rs - - examples/generate_schemas.rs - - schemas/oci-adapter-record.json - - .github/workflows/oci-bootstrap.yml - -permissions: - contents: write - -concurrency: - group: oci-contract-bootstrap-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - validate: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - persist-credentials: true - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - name: Format - run: cargo fmt --all - - name: Commit formatted sources without force-pushing - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/oci.rs src/lib.rs examples/generate_schemas.rs - if ! git diff --cached --quiet; then - git commit -m "style: format OCI interoperability contract" - git push origin "HEAD:${GITHUB_HEAD_REF}" - fi - - name: Check - run: cargo check --all-targets - - name: Test OCI contract - run: cargo test oci --all-targets - - name: Clippy - run: cargo clippy --all-targets -- -D warnings - - name: Generate JSON schemas - run: cargo run --example generate_schemas - - name: Commit generated OCI schema without force-pushing - run: | - git add schemas/oci-adapter-record.json - if ! git diff --cached --quiet; then - git commit -m "chore: generate OCI adapter JSON schema" - git push origin "HEAD:${GITHUB_HEAD_REF}" - fi From 6e893ad0f28ccfbb7722f007d75e88548f1bcfdf Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 10:46:35 -0500 Subject: [PATCH 084/122] feat(DEN-1450): add EnvironmentPlan v2 for mise parity Add schema-v2 multi-version tool, typed environment, task graph, task-local tool, deterministic normalization, validation, and generated schema support while retaining the v1 contract for compatibility. --- examples/generate_schemas.rs | 2 + schemas/environment-plan-v1.json | 267 ++++++ schemas/environment-plan.json | 557 +++++++++++ src/environment_v2.rs | 1504 ++++++++++++++++++++++++++++++ src/lib.rs | 5 + 5 files changed, 2335 insertions(+) create mode 100644 schemas/environment-plan-v1.json create mode 100644 schemas/environment-plan.json create mode 100644 src/environment_v2.rs diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 047a133..6c497c1 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -22,6 +22,8 @@ fn main() { write::(dir, "manifest"); write::(dir, "lockfile"); + write::(dir, "environment-plan-v1"); + write::(dir, "environment-plan"); write::(dir, "nix-export-section"); write::(dir, "nix-adapter-record"); write::(dir, "package-metadata"); 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/src/environment_v2.rs b/src/environment_v2.rs new file mode 100644 index 0000000..61ee1a7 --- /dev/null +++ b/src/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/lib.rs b/src/lib.rs index 3aa22e3..6eecc89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod artifact; pub mod environment; +pub mod environment_v2; pub mod excludes; pub mod language; pub mod lockfile; @@ -26,6 +27,10 @@ pub use environment::{ SystemPackageRequirement, ToolRequirement, differ_only_in_build_metadata, validate_semver_export, }; +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::{Manifest, ManifestError, NixExportRoute}; From 4756605dcfe0d17465efe6593e111de1591af9cc Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:15:48 -0500 Subject: [PATCH 085/122] feat(DEN-1418): define the canonical Nix export plan contract --- src/nix_plan.rs | 466 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 src/nix_plan.rs diff --git a/src/nix_plan.rs b/src/nix_plan.rs new file mode 100644 index 0000000..7468461 --- /dev/null +++ b/src/nix_plan.rs @@ -0,0 +1,466 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, 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, skip_serializing_if = "BTreeMap::is_empty")] + pub bins: BTreeMap, + /// Contract v1 requires this list to be empty. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + 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 { + let path = Path::new(value); + !value.is_empty() + && !path.is_absolute() + && path.components().all(|component| { + matches!(component, Component::Normal(_)) + && component.as_os_str().to_str().is_some_and(|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 + && !value.starts_with(['-', '.']) + && !value.ends_with(['-', '.']) + && 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!(!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()); + } +} From 9ce90eaa0b5acde75fa63c903186aa7fd4dd2448 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:16:23 -0500 Subject: [PATCH 086/122] feat(DEN-1418): export the Nix plan wire types --- src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 6eecc89..b357baa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod language; pub mod lockfile; pub mod manifest; pub mod nix; +pub mod nix_plan; pub mod paths; pub mod registry; pub mod sync; @@ -39,5 +40,9 @@ pub use nix::{ 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 vcs::Vcs; pub use version::{Requirement, VersionScheme}; From 1e7c7a4395b7b06953bbfcb50240614b3585a12d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:16:53 -0500 Subject: [PATCH 087/122] feat(DEN-1418): generate the Nix export plan schema --- examples/generate_schemas.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 6c497c1..f40bec8 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -25,6 +25,7 @@ fn main() { write::(dir, "environment-plan-v1"); write::(dir, "environment-plan"); write::(dir, "nix-export-section"); + write::(dir, "nix-export-plan"); write::(dir, "nix-adapter-record"); write::(dir, "package-metadata"); write::(dir, "version-metadata"); From 2b5698a9e575234feb2c73344de2f1b159a14c5e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:17:11 -0500 Subject: [PATCH 088/122] ci(DEN-1418): generate and verify the Nix plan schema artifact --- .../agent-nix-plan-schema-generated.yml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/agent-nix-plan-schema-generated.yml diff --git a/.github/workflows/agent-nix-plan-schema-generated.yml b/.github/workflows/agent-nix-plan-schema-generated.yml new file mode 100644 index 0000000..5bd05c2 --- /dev/null +++ b/.github/workflows/agent-nix-plan-schema-generated.yml @@ -0,0 +1,48 @@ +name: agent-nix-plan-schema-generated + +on: + pull_request: + paths: + - ".github/workflows/agent-nix-plan-schema-generated.yml" + - "src/nix_plan.rs" + - "src/lib.rs" + - "examples/generate_schemas.rs" + - "schemas/nix-export-plan.json" + workflow_dispatch: + +permissions: + contents: read + +jobs: + generate: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + components: rustfmt,clippy + + - name: Format, lint, test, and generate schemas + run: | + set -euo pipefail + cargo fmt --all + cargo fmt --all --check + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked nix_plan::tests:: -- --test-threads=1 + cargo run --locked --example generate_schemas + test -s schemas/nix-export-plan.json + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: den-1418-nix-plan-schema + path: | + src/nix_plan.rs + src/lib.rs + examples/generate_schemas.rs + schemas/nix-export-plan.json + if-no-files-found: error + retention-days: 1 From 3f38a5eb9ecc113483873e764a034a8648254d27 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:17:39 -0500 Subject: [PATCH 089/122] docs(DEN-1418): document the shared export-plan wire contract --- docs/nix-export-plan.md | 53 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/nix-export-plan.md 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. From 16861aaeba97edecb0384dabc1f3eff7fd0881ab Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:20:19 -0500 Subject: [PATCH 090/122] fix(DEN-1418): normalize explicit fields and portable paths before schema generation --- .../agent-nix-plan-schema-generated.yml | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/agent-nix-plan-schema-generated.yml b/.github/workflows/agent-nix-plan-schema-generated.yml index 5bd05c2..0216169 100644 --- a/.github/workflows/agent-nix-plan-schema-generated.yml +++ b/.github/workflows/agent-nix-plan-schema-generated.yml @@ -26,6 +26,73 @@ jobs: with: components: rustfmt,clippy + - name: Normalize the reviewed wire-format corrections + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path('src/nix_plan.rs') + text = path.read_text() + replacements = [ + ( + '#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]\n pub bins:', + '#[serde(default)]\n pub bins:', + ), + ( + '#[serde(default, skip_serializing_if = "Vec::is_empty")]\n pub dependencies:', + '#[serde(default)]\n pub dependencies:', + ), + ( + '''fn is_safe_relative_path(value: &str) -> bool { + let path = Path::new(value); + !value.is_empty() + && !path.is_absolute() + && path.components().all(|component| { + matches!(component, Component::Normal(_)) + && component.as_os_str().to_str().is_some_and(|part| { + !part.is_empty() + && part != "." + && part != ".." + && !part.chars().any(char::is_control) + }) + }) + }''', + '''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) + }) + }''', + ), + ( + ''' && !value.starts_with(['-', '.']) + && !value.ends_with(['-', '.'])''', + ''' && !matches!(value.chars().next(), Some('-' | '.')) + && !matches!(value.chars().last(), Some('-' | '.'))''', + ), + ( + ''' assert_eq!(decoded["schema"], NIX_EXPORT_PLAN_SCHEMA_V1); + assert!(!encoded.contains("registry"));''', + ''' 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"));''', + ), + ] + for before, after in replacements: + if before not in text: + raise SystemExit(f'normalization anchor not found: {before[:80]!r}') + text = text.replace(before, after, 1) + path.write_text(text) + PY + - name: Format, lint, test, and generate schemas run: | set -euo pipefail From db82b86505e2f7345dfe07e70fafdc603a12c08a Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:21:00 -0500 Subject: [PATCH 091/122] fix(DEN-1418): remove the obsolete host-path import during generation --- .github/workflows/agent-nix-plan-schema-generated.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/agent-nix-plan-schema-generated.yml b/.github/workflows/agent-nix-plan-schema-generated.yml index 0216169..4db48d0 100644 --- a/.github/workflows/agent-nix-plan-schema-generated.yml +++ b/.github/workflows/agent-nix-plan-schema-generated.yml @@ -35,6 +35,10 @@ jobs: path = Path('src/nix_plan.rs') text = path.read_text() replacements = [ + ( + 'use std::path::{Component, Path};', + 'use std::path::Path;', + ), ( '#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]\n pub bins:', '#[serde(default)]\n pub bins:', From 30ab4ee88f48e3c9563b1f27ed25a24f5228cdec Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:27:46 -0500 Subject: [PATCH 092/122] ci(DEN-1418): apply generated plan schema and self-clean --- .../agent-nix-plan-schema-generated.yml | 51 ++++++++----------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/.github/workflows/agent-nix-plan-schema-generated.yml b/.github/workflows/agent-nix-plan-schema-generated.yml index 4db48d0..32e418c 100644 --- a/.github/workflows/agent-nix-plan-schema-generated.yml +++ b/.github/workflows/agent-nix-plan-schema-generated.yml @@ -1,32 +1,34 @@ name: agent-nix-plan-schema-generated on: - pull_request: + push: + branches: + - agent/den-1418-nix-export-plan-schema paths: - ".github/workflows/agent-nix-plan-schema-generated.yml" - - "src/nix_plan.rs" - - "src/lib.rs" - - "examples/generate_schemas.rs" - - "schemas/nix-export-plan.json" - workflow_dispatch: permissions: - contents: read + contents: write + +concurrency: + group: den-1418-schema-apply + cancel-in-progress: true jobs: - generate: + apply: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - persist-credentials: false + ref: agent/den-1418-nix-export-plan-schema + fetch-depth: 0 - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 with: components: rustfmt,clippy - - name: Normalize the reviewed wire-format corrections + - name: Normalize, validate, generate, commit, and remove this helper run: | set -euo pipefail python3 - <<'PY' @@ -35,10 +37,7 @@ jobs: path = Path('src/nix_plan.rs') text = path.read_text() replacements = [ - ( - 'use std::path::{Component, Path};', - 'use std::path::Path;', - ), + ('use std::path::{Component, Path};', 'use std::path::Path;'), ( '#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]\n pub bins:', '#[serde(default)]\n pub bins:', @@ -96,24 +95,16 @@ jobs: text = text.replace(before, after, 1) path.write_text(text) PY - - - name: Format, lint, test, and generate schemas - run: | - set -euo pipefail cargo fmt --all cargo fmt --all --check cargo clippy --locked --all-targets -- -D warnings cargo test --locked nix_plan::tests:: -- --test-threads=1 cargo run --locked --example generate_schemas test -s schemas/nix-export-plan.json - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: den-1418-nix-plan-schema - path: | - src/nix_plan.rs - src/lib.rs - examples/generate_schemas.rs - schemas/nix-export-plan.json - if-no-files-found: error - retention-days: 1 + git rm .github/workflows/agent-nix-plan-schema-generated.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/nix_plan.rs src/lib.rs examples/generate_schemas.rs schemas/nix-export-plan.json + git diff --cached --check + git commit -m "feat(DEN-1418): apply generated Nix export plan schema" + git push origin HEAD:agent/den-1418-nix-export-plan-schema From 9abc5c2da1f46d26de3af609c6bcb80af85e0506 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:35:40 +0000 Subject: [PATCH 093/122] feat(DEN-1418): apply generated Nix export plan schema --- .../agent-nix-plan-schema-generated.yml | 110 ------- schemas/nix-export-plan.json | 268 ++++++++++++++++++ src/nix_plan.rs | 36 ++- 3 files changed, 284 insertions(+), 130 deletions(-) delete mode 100644 .github/workflows/agent-nix-plan-schema-generated.yml create mode 100644 schemas/nix-export-plan.json diff --git a/.github/workflows/agent-nix-plan-schema-generated.yml b/.github/workflows/agent-nix-plan-schema-generated.yml deleted file mode 100644 index 32e418c..0000000 --- a/.github/workflows/agent-nix-plan-schema-generated.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: agent-nix-plan-schema-generated - -on: - push: - branches: - - agent/den-1418-nix-export-plan-schema - paths: - - ".github/workflows/agent-nix-plan-schema-generated.yml" - -permissions: - contents: write - -concurrency: - group: den-1418-schema-apply - cancel-in-progress: true - -jobs: - apply: - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: agent/den-1418-nix-export-plan-schema - fetch-depth: 0 - - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - components: rustfmt,clippy - - - name: Normalize, validate, generate, commit, and remove this helper - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - path = Path('src/nix_plan.rs') - text = path.read_text() - replacements = [ - ('use std::path::{Component, Path};', 'use std::path::Path;'), - ( - '#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]\n pub bins:', - '#[serde(default)]\n pub bins:', - ), - ( - '#[serde(default, skip_serializing_if = "Vec::is_empty")]\n pub dependencies:', - '#[serde(default)]\n pub dependencies:', - ), - ( - '''fn is_safe_relative_path(value: &str) -> bool { - let path = Path::new(value); - !value.is_empty() - && !path.is_absolute() - && path.components().all(|component| { - matches!(component, Component::Normal(_)) - && component.as_os_str().to_str().is_some_and(|part| { - !part.is_empty() - && part != "." - && part != ".." - && !part.chars().any(char::is_control) - }) - }) - }''', - '''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) - }) - }''', - ), - ( - ''' && !value.starts_with(['-', '.']) - && !value.ends_with(['-', '.'])''', - ''' && !matches!(value.chars().next(), Some('-' | '.')) - && !matches!(value.chars().last(), Some('-' | '.'))''', - ), - ( - ''' assert_eq!(decoded["schema"], NIX_EXPORT_PLAN_SCHEMA_V1); - assert!(!encoded.contains("registry"));''', - ''' 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"));''', - ), - ] - for before, after in replacements: - if before not in text: - raise SystemExit(f'normalization anchor not found: {before[:80]!r}') - text = text.replace(before, after, 1) - path.write_text(text) - PY - cargo fmt --all - cargo fmt --all --check - cargo clippy --locked --all-targets -- -D warnings - cargo test --locked nix_plan::tests:: -- --test-threads=1 - cargo run --locked --example generate_schemas - test -s schemas/nix-export-plan.json - git rm .github/workflows/agent-nix-plan-schema-generated.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/nix_plan.rs src/lib.rs examples/generate_schemas.rs schemas/nix-export-plan.json - git diff --cached --check - git commit -m "feat(DEN-1418): apply generated Nix export plan schema" - git push origin HEAD:agent/den-1418-nix-export-plan-schema 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/src/nix_plan.rs b/src/nix_plan.rs index 7468461..27da13f 100644 --- a/src/nix_plan.rs +++ b/src/nix_plan.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Component, Path}; +use std::path::Path; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -141,10 +141,10 @@ pub struct NixExportPlan { pub intent: ResolvedNixExportIntent, pub source: PlannedZedExportArtifact, /// Command name → artifact-relative executable path. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default)] pub bins: BTreeMap, /// Contract v1 requires this list to be empty. - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde(default)] pub dependencies: Vec, pub policy: NixPolicyEvidence, } @@ -240,8 +240,7 @@ impl NixExportPlan { let mut canonical = self.clone(); canonical.normalize(); canonical.validate()?; - serde_json::to_vec(&canonical) - .map_err(|error| NixExportPlanError::Json(error.to_string())) + serde_json::to_vec(&canonical).map_err(|error| NixExportPlanError::Json(error.to_string())) } pub fn canonical_json_string(&self) -> Result { @@ -288,28 +287,23 @@ fn is_safe_basename(value: &str) -> bool { } fn is_safe_relative_path(value: &str) -> bool { - let path = Path::new(value); !value.is_empty() - && !path.is_absolute() - && path.components().all(|component| { - matches!(component, Component::Normal(_)) - && component.as_os_str().to_str().is_some_and(|part| { - !part.is_empty() - && part != "." - && part != ".." - && !part.chars().any(char::is_control) - }) + && !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 - && !value.starts_with(['-', '.']) - && !value.ends_with(['-', '.']) - && value - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) + && !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> @@ -396,6 +390,8 @@ mod tests { 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/")); From c5344c39aeceabb7450925719ecac1c1a899d49f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:40:59 -0500 Subject: [PATCH 094/122] ci(DEN-1418): expose the self-cleaning schema generator on the PR event --- .../agent-nix-plan-schema-generated.yml | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .github/workflows/agent-nix-plan-schema-generated.yml diff --git a/.github/workflows/agent-nix-plan-schema-generated.yml b/.github/workflows/agent-nix-plan-schema-generated.yml new file mode 100644 index 0000000..ed9a587 --- /dev/null +++ b/.github/workflows/agent-nix-plan-schema-generated.yml @@ -0,0 +1,116 @@ +name: agent-nix-plan-schema-generated + +on: + push: + branches: + - agent/den-1418-nix-export-plan-schema + paths: + - ".github/workflows/agent-nix-plan-schema-generated.yml" + pull_request: + branches: + - main + paths: + - ".github/workflows/agent-nix-plan-schema-generated.yml" + +permissions: + contents: write + +concurrency: + group: den-1418-schema-apply + cancel-in-progress: true + +jobs: + apply: + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: agent/den-1418-nix-export-plan-schema + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + components: rustfmt,clippy + + - name: Normalize, validate, generate, commit, and remove this helper + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path('src/nix_plan.rs') + text = path.read_text() + replacements = [ + ('use std::path::{Component, Path};', 'use std::path::Path;'), + ( + '#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]\n pub bins:', + '#[serde(default)]\n pub bins:', + ), + ( + '#[serde(default, skip_serializing_if = "Vec::is_empty")]\n pub dependencies:', + '#[serde(default)]\n pub dependencies:', + ), + ( + '''fn is_safe_relative_path(value: &str) -> bool { + let path = Path::new(value); + !value.is_empty() + && !path.is_absolute() + && path.components().all(|component| { + matches!(component, Component::Normal(_)) + && component.as_os_str().to_str().is_some_and(|part| { + !part.is_empty() + && part != "." + && part != ".." + && !part.chars().any(char::is_control) + }) + }) + }''', + '''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) + }) + }''', + ), + ( + ''' && !value.starts_with(['-', '.']) + && !value.ends_with(['-', '.'])''', + ''' && !matches!(value.chars().next(), Some('-' | '.')) + && !matches!(value.chars().last(), Some('-' | '.'))''', + ), + ( + ''' assert_eq!(decoded["schema"], NIX_EXPORT_PLAN_SCHEMA_V1); + assert!(!encoded.contains("registry"));''', + ''' 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"));''', + ), + ] + for before, after in replacements: + if before not in text: + raise SystemExit(f'normalization anchor not found: {before[:80]!r}') + text = text.replace(before, after, 1) + path.write_text(text) + PY + cargo fmt --all + cargo fmt --all --check + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked nix_plan::tests:: -- --test-threads=1 + cargo run --locked --example generate_schemas + test -s schemas/nix-export-plan.json + git rm .github/workflows/agent-nix-plan-schema-generated.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/nix_plan.rs src/lib.rs examples/generate_schemas.rs schemas/nix-export-plan.json + git diff --cached --check + git commit -m "feat(DEN-1418): apply generated Nix export plan schema" + git push origin HEAD:agent/den-1418-nix-export-plan-schema From dc820f8c1fba40d53dc7523d00e468304bd4eddd Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:43:33 -0500 Subject: [PATCH 095/122] ci(DEN-1418): run one observable self-cleaning schema generator --- .github/workflows/agent-nix-plan-schema-generated.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/agent-nix-plan-schema-generated.yml b/.github/workflows/agent-nix-plan-schema-generated.yml index ed9a587..442fb04 100644 --- a/.github/workflows/agent-nix-plan-schema-generated.yml +++ b/.github/workflows/agent-nix-plan-schema-generated.yml @@ -1,11 +1,6 @@ name: agent-nix-plan-schema-generated on: - push: - branches: - - agent/den-1418-nix-export-plan-schema - paths: - - ".github/workflows/agent-nix-plan-schema-generated.yml" pull_request: branches: - main @@ -21,7 +16,7 @@ concurrency: jobs: apply: - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 25 steps: From bc7cf93c8b6cd51295a0379c2cbeb9f55ec45be6 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:55:55 -0500 Subject: [PATCH 096/122] fix(DEN-1464): require complete locked artifact metadata --- src/lockfile.rs | 180 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 177 insertions(+), 3 deletions(-) diff --git a/src/lockfile.rs b/src/lockfile.rs index b129221..fd2fb10 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -31,14 +31,19 @@ pub struct LockedPackage { pub org: String, pub name: String, pub version: String, - /// Hex sha256 of the artifact archive; also its store address. + /// Canonical lowercase hex sha256 of the artifact archive; also its store address. pub sha256: String, - /// Artifact size in bytes. + /// Artifact size in bytes. Zero-byte package artifacts are invalid. pub size: u64, - #[serde(default)] + /// 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`. pub vcs_tag: String, + /// Exact source revision associated with the published artifact. The + /// optional Rust representation preserves API compatibility for builders, + /// but parsing and serialization reject `None` so committed lockfiles are + /// always complete. #[serde(default, skip_serializing_if = "Option::is_none")] pub vcs_commit: Option, /// Base URL of the registry the artifact was resolved from. @@ -51,6 +56,10 @@ pub enum LockfileError { 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 Nix adapter provenance: {0}")] InvalidNixAdapter(String), #[error("duplicate Nix adapter provenance key `{0}`")] @@ -79,11 +88,13 @@ impl Lockfile { Self::CURRENT_VERSION, )); } + lockfile.validate_packages()?; lockfile.validate_nix_adapters()?; Ok(lockfile) } pub fn to_toml_string(&self) -> Result { + self.validate_packages()?; self.validate_nix_adapters()?; let mut normalized = self.clone(); normalized.nix_adapters.sort_by_key(nix_adapter_key); @@ -120,6 +131,54 @@ impl Lockfile { 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"); + } + if package + .vcs_commit + .as_deref() + .is_none_or(|commit| commit.trim().is_empty()) + { + return invalid_package( + &label, + "vcs_commit must be explicitly present and non-empty", + ); + } + if package.source.trim().is_empty() { + return invalid_package(&label, "source must not be empty"); + } + } + Ok(()) + } + fn validate_nix_adapters(&self) -> Result<(), LockfileError> { let mut seen = BTreeSet::new(); for adapter in &self.nix_adapters { @@ -143,6 +202,20 @@ impl LockedPackage { } } +fn invalid_package(package: &str, reason: &str) -> Result { + Err(LockfileError::InvalidPackageMetadata { + package: package.to_string(), + reason: reason.to_string(), + }) +} + +fn is_canonical_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + fn nix_adapter_key(adapter: &NixAdapterRecord) -> NixAdapterKey { match adapter { NixAdapterRecord::ZedToNix { package, .. } => ( @@ -188,3 +261,104 @@ fn nix_adapter_label(adapter: &NixAdapterRecord) -> String { 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 = "0123456789abcdef0123456789abcdef01234567" +source = "file:///tmp/registry" +"#; + + #[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( + "vcs_commit = \"0123456789abcdef0123456789abcdef01234567\"\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 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_refuses_to_emit_incomplete_provenance() { + let lock = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: vec![LockedPackage { + org: "zed-pkg".to_string(), + name: "fixture".to_string(), + version: "1.2.3".to_string(), + sha256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + .to_string(), + size: 42, + format: ArtifactFormat::TarGz, + vcs_tag: "v1.2.3".to_string(), + vcs_commit: None, + source: "file:///tmp/registry".to_string(), + }], + nix_adapters: Vec::new(), + }; + let error = lock.to_toml_string().unwrap_err().to_string(); + assert!(error.contains("vcs_commit")); + } +} From 26b12180d9dae9878c62a26ad6ef07e0abb45412 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:57:55 -0500 Subject: [PATCH 097/122] style: apply rustfmt to strict lock tests --- src/lockfile.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lockfile.rs b/src/lockfile.rs index fd2fb10..e743024 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -347,9 +347,8 @@ source = "file:///tmp/registry" org: "zed-pkg".to_string(), name: "fixture".to_string(), version: "1.2.3".to_string(), - sha256: - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - .to_string(), + sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + .to_string(), size: 42, format: ArtifactFormat::TarGz, vcs_tag: "v1.2.3".to_string(), From 2d397c60cdaa8fe0d331e98caba5202e61eaca4f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 11:59:44 -0500 Subject: [PATCH 098/122] test: keep no-Nix compatibility fixture cryptographically complete --- tests/nix_manifest_lock.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/nix_manifest_lock.rs b/tests/nix_manifest_lock.rs index 1036929..4211f28 100644 --- a/tests/nix_manifest_lock.rs +++ b/tests/nix_manifest_lock.rs @@ -204,7 +204,9 @@ 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" "#, ) From 980fbd78c4d2bcd27a03e00b67132ffff1bb9247 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:03:45 -0500 Subject: [PATCH 099/122] chore(DEN-1464): one-shot lock schema refresh --- .../den-1464-refresh-lock-schema.yml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/den-1464-refresh-lock-schema.yml diff --git a/.github/workflows/den-1464-refresh-lock-schema.yml b/.github/workflows/den-1464-refresh-lock-schema.yml new file mode 100644 index 0000000..64d80ee --- /dev/null +++ b/.github/workflows/den-1464-refresh-lock-schema.yml @@ -0,0 +1,48 @@ +name: DEN-1464 refresh lock schema + +on: + push: + branches: + - agent/den-1464-strict-frozen-lock-metadata + paths: + - '.github/workflows/den-1464-refresh-lock-schema.yml' + +permissions: + contents: write + +concurrency: + group: den-1464-refresh-lock-schema + cancel-in-progress: false + +jobs: + refresh: + if: github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: agent/den-1464-strict-frozen-lock-metadata + persist-credentials: true + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + - name: Regenerate the public schemas + run: cargo run --locked --example generate_schemas + - name: Require a lock-schema-only generated change + shell: bash + run: | + set -euo pipefail + mapfile -t changed < <(git diff --name-only) + printf '%s\n' "${changed[@]}" + test "${#changed[@]}" -eq 1 + test "${changed[0]}" = schemas/lockfile.json + git diff --check + - name: Commit the generated schema + run: | + git config user.name 'ORESoftware' + git config user.email '11139560+ORESoftware@users.noreply.github.com' + git add schemas/lockfile.json + git commit -m 'docs(DEN-1464): regenerate strict lockfile schema' + git push origin HEAD:agent/den-1464-strict-frozen-lock-metadata From fcc4780c5bd8c0ebb5d391024d4039a0383fe2dd Mon Sep 17 00:00:00 2001 From: ORESoftware <11139560+ORESoftware@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:04:10 +0000 Subject: [PATCH 100/122] docs(DEN-1464): regenerate strict lockfile schema --- schemas/lockfile.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/schemas/lockfile.json b/schemas/lockfile.json index 6627586..4135eca 100644 --- a/schemas/lockfile.json +++ b/schemas/lockfile.json @@ -39,8 +39,8 @@ "type": "object", "properties": { "format": { - "$ref": "#/$defs/ArtifactFormat", - "default": "tar.gz" + "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" @@ -49,11 +49,11 @@ "type": "string" }, "sha256": { - "description": "Hex sha256 of the artifact archive; also its store address.", + "description": "Canonical lowercase hex sha256 of the artifact archive; also its store address.", "type": "string" }, "size": { - "description": "Artifact size in bytes.", + "description": "Artifact size in bytes. Zero-byte package artifacts are invalid.", "type": "integer", "format": "uint64", "minimum": 0 @@ -63,6 +63,7 @@ "type": "string" }, "vcs_commit": { + "description": "Exact source revision associated with the published artifact. The\noptional Rust representation preserves API compatibility for builders,\nbut parsing and serialization reject `None` so committed lockfiles are\nalways complete.", "type": [ "string", "null" @@ -82,6 +83,7 @@ "version", "sha256", "size", + "format", "vcs_tag", "source" ] From 50ff18ed934c9871920373403fff69b1dc1a0e0a Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:05:56 -0500 Subject: [PATCH 101/122] ci(DEN-1464): remove one-shot schema generator --- .../den-1464-refresh-lock-schema.yml | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 .github/workflows/den-1464-refresh-lock-schema.yml diff --git a/.github/workflows/den-1464-refresh-lock-schema.yml b/.github/workflows/den-1464-refresh-lock-schema.yml deleted file mode 100644 index 64d80ee..0000000 --- a/.github/workflows/den-1464-refresh-lock-schema.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: DEN-1464 refresh lock schema - -on: - push: - branches: - - agent/den-1464-strict-frozen-lock-metadata - paths: - - '.github/workflows/den-1464-refresh-lock-schema.yml' - -permissions: - contents: write - -concurrency: - group: den-1464-refresh-lock-schema - cancel-in-progress: false - -jobs: - refresh: - if: github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - ref: agent/den-1464-strict-frozen-lock-metadata - persist-credentials: true - fetch-depth: 0 - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - - name: Regenerate the public schemas - run: cargo run --locked --example generate_schemas - - name: Require a lock-schema-only generated change - shell: bash - run: | - set -euo pipefail - mapfile -t changed < <(git diff --name-only) - printf '%s\n' "${changed[@]}" - test "${#changed[@]}" -eq 1 - test "${changed[0]}" = schemas/lockfile.json - git diff --check - - name: Commit the generated schema - run: | - git config user.name 'ORESoftware' - git config user.email '11139560+ORESoftware@users.noreply.github.com' - git add schemas/lockfile.json - git commit -m 'docs(DEN-1464): regenerate strict lockfile schema' - git push origin HEAD:agent/den-1464-strict-frozen-lock-metadata From ce6a424bdfc06aae277fa13ac9f685281e7dd3d0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:08:52 -0500 Subject: [PATCH 102/122] test(DEN-1464): require immutable revisions in strict lockfiles --- src/lockfile.rs | 90 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/src/lockfile.rs b/src/lockfile.rs index e743024..2d9bfde 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -40,11 +40,12 @@ pub struct LockedPackage { pub format: ArtifactFormat, /// VCS tag the version was published from, e.g. `v1.2.0`. pub vcs_tag: String, - /// Exact source revision associated with the published artifact. The - /// optional Rust representation preserves API compatibility for builders, - /// but parsing and serialization reject `None` so committed lockfiles are - /// always complete. + /// Exact immutable source revision associated with the published artifact. + /// The optional Rust representation preserves API compatibility for + /// builders, but lockfile parsing, schema generation, and serialization + /// all require this value to be explicitly present. #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(required)] pub vcs_commit: Option, /// Base URL of the registry the artifact was resolved from. pub source: String, @@ -162,14 +163,13 @@ impl Lockfile { if package.vcs_tag.trim().is_empty() { return invalid_package(&label, "vcs_tag must not be empty"); } - if package - .vcs_commit - .as_deref() - .is_none_or(|commit| commit.trim().is_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 explicitly present and non-empty", + "vcs_commit must be a bounded immutable revision, not a mutable ref", ); } if package.source.trim().is_empty() { @@ -216,6 +216,28 @@ fn is_canonical_sha256(value: &str) -> bool { .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. +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 nix_adapter_key(adapter: &NixAdapterRecord) -> NixAdapterKey { match adapter { NixAdapterRecord::ZedToNix { package, .. } => ( @@ -331,6 +353,40 @@ source = "file:///tmp/registry" } } + #[test] + fn mutable_malformed_and_zero_vcs_revisions_are_rejected() { + let original = "0123456789abcdef0123456789abcdef01234567"; + for replacement in [ + "main", + "refs/heads/main", + "latest", + "0000000", + "short", + "revision with spaces", + "revision@host", + ] { + let input = VALID_LOCK.replacen(original, replacement, 1); + let error = Lockfile::parse(&input).unwrap_err().to_string(); + assert!(error.contains("vcs_commit"), "unexpected error: {error}"); + } + } + + #[test] + fn non_git_immutable_revisions_remain_supported() { + for revision in [ + "fossil:0123456789abcdef", + "hg/0123456789abcdef0123456789abcdef01234567", + "pijul+ABCdef0123456789_-", + ] { + let input = VALID_LOCK.replacen( + "0123456789abcdef0123456789abcdef01234567", + revision, + 1, + ); + 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; @@ -360,4 +416,18 @@ source = "file:///tmp/registry" let error = lock.to_toml_string().unwrap_err().to_string(); assert!(error.contains("vcs_commit")); } + + #[test] + fn public_schema_requires_format_and_vcs_commit() { + let schema = schemars::schema_for!(Lockfile); + let value = serde_json::to_value(schema).unwrap(); + let required = value["$defs"]["LockedPackage"]["required"] + .as_array() + .unwrap(); + let names = required.iter().filter_map(|item| item.as_str()).collect::>(); + assert!(names.contains("format")); + assert!(names.contains("vcs_commit")); + let commit_type = &value["$defs"]["LockedPackage"]["properties"]["vcs_commit"]["type"]; + assert_eq!(commit_type, "string"); + } } From d7275e7f30e76c1b633f3878bb639aa448a62cdc Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:10:18 -0500 Subject: [PATCH 103/122] docs(DEN-1464): encode strict lock metadata in the public schema --- src/lockfile.rs | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/lockfile.rs b/src/lockfile.rs index 2d9bfde..b2d9d5d 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -28,26 +28,37 @@ pub struct Lockfile { #[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, but lockfile parsing, schema generation, and serialization /// all require this value to be explicitly present. #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(required)] + #[schemars( + 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, } @@ -418,16 +429,22 @@ source = "file:///tmp/registry" } #[test] - fn public_schema_requires_format_and_vcs_commit() { + fn public_schema_requires_complete_package_provenance() { let schema = schemars::schema_for!(Lockfile); let value = serde_json::to_value(schema).unwrap(); - let required = value["$defs"]["LockedPackage"]["required"] - .as_array() - .unwrap(); - let names = required.iter().filter_map(|item| item.as_str()).collect::>(); + 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")); - let commit_type = &value["$defs"]["LockedPackage"]["properties"]["vcs_commit"]["type"]; - assert_eq!(commit_type, "string"); + 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); } } From 7b173f7dad8d5a76ca41056f6dfdaf53001f5e72 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:10:53 -0500 Subject: [PATCH 104/122] chore(DEN-1464): one-shot formatter and schema refresh --- .github/workflows/den-1464-finalize.yml | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/den-1464-finalize.yml diff --git a/.github/workflows/den-1464-finalize.yml b/.github/workflows/den-1464-finalize.yml new file mode 100644 index 0000000..a8c82c6 --- /dev/null +++ b/.github/workflows/den-1464-finalize.yml @@ -0,0 +1,52 @@ +name: DEN-1464 finalize strict lock contract + +on: + push: + branches: + - agent/den-1464-strict-frozen-lock-metadata + paths: + - '.github/workflows/den-1464-finalize.yml' + +permissions: + contents: write + +concurrency: + group: den-1464-finalize + cancel-in-progress: true + +jobs: + finalize: + if: github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: agent/den-1464-strict-frozen-lock-metadata + persist-credentials: true + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt + - name: Apply native formatting and regenerate schemas + run: | + cargo fmt + cargo run --locked --example generate_schemas + - name: Require only semantic generated outputs + shell: bash + run: | + set -euo pipefail + mapfile -t changed < <(git diff --name-only | sort) + printf '%s\n' "${changed[@]}" + test "${#changed[@]}" -eq 2 + test "${changed[0]}" = schemas/lockfile.json + test "${changed[1]}" = src/lockfile.rs + git diff --check + - name: Commit the finalized contract + run: | + git config user.name 'ORESoftware' + git config user.email '11139560+ORESoftware@users.noreply.github.com' + git add src/lockfile.rs schemas/lockfile.json + git commit -m 'style(DEN-1464): finalize immutable revision contract' + git push origin HEAD:agent/den-1464-strict-frozen-lock-metadata From daa59775206f712c9e4ff509c202b657dff7f82b Mon Sep 17 00:00:00 2001 From: ORESoftware <11139560+ORESoftware@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:11:25 +0000 Subject: [PATCH 105/122] style(DEN-1464): finalize immutable revision contract --- schemas/lockfile.json | 32 ++++++++++++++++++++------------ src/lockfile.rs | 16 +++++++++------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/schemas/lockfile.json b/schemas/lockfile.json index 4135eca..f3469e7 100644 --- a/schemas/lockfile.json +++ b/schemas/lockfile.json @@ -43,38 +43,46 @@ "$ref": "#/$defs/ArtifactFormat" }, "name": { - "type": "string" + "type": "string", + "minLength": 1 }, "org": { - "type": "string" + "type": "string", + "minLength": 1 }, "sha256": { "description": "Canonical lowercase hex sha256 of the artifact archive; also its store address.", - "type": "string" + "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": 0 + "minimum": 1 }, "source": { "description": "Base URL of the registry the artifact was resolved from.", - "type": "string" + "type": "string", + "minLength": 1 }, "vcs_commit": { - "description": "Exact source revision associated with the published artifact. The\noptional Rust representation preserves API compatibility for builders,\nbut parsing and serialization reject `None` so committed lockfiles are\nalways complete.", - "type": [ - "string", - "null" - ] + "description": "Exact immutable source revision associated with the published artifact.\nThe optional Rust representation preserves API compatibility for\nbuilders, but lockfile parsing, schema generation, and serialization\nall require this value to be explicitly present.", + "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" + "type": "string", + "minLength": 1 }, "version": { - "type": "string" + "type": "string", + "minLength": 1 } }, "required": [ diff --git a/src/lockfile.rs b/src/lockfile.rs index b2d9d5d..aef95c5 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -244,8 +244,10 @@ fn is_immutable_vcs_revision(value: &str) -> bool { return false; } let lower = value.to_ascii_lowercase(); - !matches!(lower.as_str(), "head" | "main" | "master" | "trunk" | "latest") - && !lower.starts_with("refs/heads/") + !matches!( + lower.as_str(), + "head" | "main" | "master" | "trunk" | "latest" + ) && !lower.starts_with("refs/heads/") && !lower.starts_with("heads/") } @@ -389,12 +391,12 @@ source = "file:///tmp/registry" "hg/0123456789abcdef0123456789abcdef01234567", "pijul+ABCdef0123456789_-", ] { - let input = VALID_LOCK.replacen( - "0123456789abcdef0123456789abcdef01234567", - revision, - 1, + let input = + VALID_LOCK.replacen("0123456789abcdef0123456789abcdef01234567", revision, 1); + assert!( + Lockfile::parse(&input).is_ok(), + "revision rejected: {revision}" ); - assert!(Lockfile::parse(&input).is_ok(), "revision rejected: {revision}"); } } From 992e1cfc5def6c15ae69d7ee4f8fc15f618c88ec Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:12:39 -0500 Subject: [PATCH 106/122] chore(DEN-1464): remove branch-only finalizer --- .github/workflows/den-1464-finalize.yml | 52 ------------------------- 1 file changed, 52 deletions(-) delete mode 100644 .github/workflows/den-1464-finalize.yml diff --git a/.github/workflows/den-1464-finalize.yml b/.github/workflows/den-1464-finalize.yml deleted file mode 100644 index a8c82c6..0000000 --- a/.github/workflows/den-1464-finalize.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: DEN-1464 finalize strict lock contract - -on: - push: - branches: - - agent/den-1464-strict-frozen-lock-metadata - paths: - - '.github/workflows/den-1464-finalize.yml' - -permissions: - contents: write - -concurrency: - group: den-1464-finalize - cancel-in-progress: true - -jobs: - finalize: - if: github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - ref: agent/den-1464-strict-frozen-lock-metadata - persist-credentials: true - fetch-depth: 0 - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - name: Apply native formatting and regenerate schemas - run: | - cargo fmt - cargo run --locked --example generate_schemas - - name: Require only semantic generated outputs - shell: bash - run: | - set -euo pipefail - mapfile -t changed < <(git diff --name-only | sort) - printf '%s\n' "${changed[@]}" - test "${#changed[@]}" -eq 2 - test "${changed[0]}" = schemas/lockfile.json - test "${changed[1]}" = src/lockfile.rs - git diff --check - - name: Commit the finalized contract - run: | - git config user.name 'ORESoftware' - git config user.email '11139560+ORESoftware@users.noreply.github.com' - git add src/lockfile.rs schemas/lockfile.json - git commit -m 'style(DEN-1464): finalize immutable revision contract' - git push origin HEAD:agent/den-1464-strict-frozen-lock-metadata From a120c2c0ea72dddcfe5ab996ac0a0974ca7c18c8 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:19:35 -0500 Subject: [PATCH 107/122] chore(DEN-1464): one-shot schema and fixture repair --- .../workflows/den-1464-fix-schema-tests.yml | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/den-1464-fix-schema-tests.yml diff --git a/.github/workflows/den-1464-fix-schema-tests.yml b/.github/workflows/den-1464-fix-schema-tests.yml new file mode 100644 index 0000000..042460c --- /dev/null +++ b/.github/workflows/den-1464-fix-schema-tests.yml @@ -0,0 +1,115 @@ +name: DEN-1464 fix strict lock schema tests + +on: + push: + branches: + - agent/den-1464-strict-frozen-lock-metadata + paths: + - '.github/workflows/den-1464-fix-schema-tests.yml' + +permissions: + contents: write + +concurrency: + group: den-1464-fix-schema-tests + cancel-in-progress: true + +jobs: + fix: + if: github.actor == 'ORESoftware' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: agent/den-1464-strict-frozen-lock-metadata + persist-credentials: true + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: 1.97.1 + components: rustfmt, clippy + + - name: Fix schema requiredness and VCS fixtures + shell: python + run: | + from pathlib import Path + + path = Path('src/lockfile.rs') + text = path.read_text() + + old_attr = ''' #[schemars( + required, + length(min = 7, max = 128), + regex(pattern = r"^[A-Za-z0-9._+:/-]+$") + )]'''.replace(' ', ' ') + new_attr = ''' #[schemars( + !default, + required, + length(min = 7, max = 128), + regex(pattern = r"^[A-Za-z0-9._+:/-]+$") + )]'''.replace(' ', ' ') + if text.count(old_attr) != 1: + raise SystemExit(f'expected one schemars attribute, found {text.count(old_attr)}') + text = text.replace(old_attr, new_attr, 1) + + old_mutable = ''' let original = "0123456789abcdef0123456789abcdef01234567"; + for replacement in [ + "main", + "refs/heads/main", + "latest", + "0000000", + "short", + "revision with spaces", + "revision@host", + ] { + let input = VALID_LOCK.replacen(original, replacement, 1);'''.replace(' ', ' ') + new_mutable = ''' for replacement in [ + "main", + "refs/heads/main", + "latest", + "0000000", + "short", + "revision with spaces", + "revision@host", + ] { + let input = VALID_LOCK.replacen( + "vcs_commit = \\"0123456789abcdef0123456789abcdef01234567\\"", + &format!("vcs_commit = \\"{replacement}\\""), + 1, + );'''.replace(' ', ' ') + if text.count(old_mutable) != 1: + raise SystemExit(f'expected one mutable fixture block, found {text.count(old_mutable)}') + text = text.replace(old_mutable, new_mutable, 1) + + old_non_git = ''' let input = + VALID_LOCK.replacen("0123456789abcdef0123456789abcdef01234567", revision, 1);'''.replace(' ', ' ') + new_non_git = ''' let input = VALID_LOCK.replacen( + "vcs_commit = \\"0123456789abcdef0123456789abcdef01234567\\"", + &format!("vcs_commit = \\"{revision}\\""), + 1, + );'''.replace(' ', ' ') + if text.count(old_non_git) != 1: + raise SystemExit(f'expected one non-git fixture block, found {text.count(old_non_git)}') + text = text.replace(old_non_git, new_non_git, 1) + + path.write_text(text) + + - name: Format and verify the complete contract + run: | + cargo fmt + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked + cargo run --locked --example generate_schemas + cargo test --locked lockfile::tests::public_schema_requires_complete_package_provenance + git diff --check + + - name: Remove the one-shot workflow and commit + run: | + git rm .github/workflows/den-1464-fix-schema-tests.yml + git config user.name 'ORESoftware' + git config user.email '11139560+ORESoftware@users.noreply.github.com' + git add src/lockfile.rs schemas/lockfile.json + git diff --cached --check + git commit -m 'fix(DEN-1464): require VCS commit in schema and test exact field' + git push origin HEAD:agent/den-1464-strict-frozen-lock-metadata From 9810e4d16fea17ef0aebcb914bdc9ead6a7a3446 Mon Sep 17 00:00:00 2001 From: ORESoftware <11139560+ORESoftware@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:20:28 +0000 Subject: [PATCH 108/122] fix(DEN-1464): require VCS commit in schema and test exact field --- .../workflows/den-1464-fix-schema-tests.yml | 115 ------------------ schemas/lockfile.json | 1 + src/lockfile.rs | 15 ++- 3 files changed, 12 insertions(+), 119 deletions(-) delete mode 100644 .github/workflows/den-1464-fix-schema-tests.yml diff --git a/.github/workflows/den-1464-fix-schema-tests.yml b/.github/workflows/den-1464-fix-schema-tests.yml deleted file mode 100644 index 042460c..0000000 --- a/.github/workflows/den-1464-fix-schema-tests.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: DEN-1464 fix strict lock schema tests - -on: - push: - branches: - - agent/den-1464-strict-frozen-lock-metadata - paths: - - '.github/workflows/den-1464-fix-schema-tests.yml' - -permissions: - contents: write - -concurrency: - group: den-1464-fix-schema-tests - cancel-in-progress: true - -jobs: - fix: - if: github.actor == 'ORESoftware' - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - ref: agent/den-1464-strict-frozen-lock-metadata - persist-credentials: true - fetch-depth: 0 - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: 1.97.1 - components: rustfmt, clippy - - - name: Fix schema requiredness and VCS fixtures - shell: python - run: | - from pathlib import Path - - path = Path('src/lockfile.rs') - text = path.read_text() - - old_attr = ''' #[schemars( - required, - length(min = 7, max = 128), - regex(pattern = r"^[A-Za-z0-9._+:/-]+$") - )]'''.replace(' ', ' ') - new_attr = ''' #[schemars( - !default, - required, - length(min = 7, max = 128), - regex(pattern = r"^[A-Za-z0-9._+:/-]+$") - )]'''.replace(' ', ' ') - if text.count(old_attr) != 1: - raise SystemExit(f'expected one schemars attribute, found {text.count(old_attr)}') - text = text.replace(old_attr, new_attr, 1) - - old_mutable = ''' let original = "0123456789abcdef0123456789abcdef01234567"; - for replacement in [ - "main", - "refs/heads/main", - "latest", - "0000000", - "short", - "revision with spaces", - "revision@host", - ] { - let input = VALID_LOCK.replacen(original, replacement, 1);'''.replace(' ', ' ') - new_mutable = ''' for replacement in [ - "main", - "refs/heads/main", - "latest", - "0000000", - "short", - "revision with spaces", - "revision@host", - ] { - let input = VALID_LOCK.replacen( - "vcs_commit = \\"0123456789abcdef0123456789abcdef01234567\\"", - &format!("vcs_commit = \\"{replacement}\\""), - 1, - );'''.replace(' ', ' ') - if text.count(old_mutable) != 1: - raise SystemExit(f'expected one mutable fixture block, found {text.count(old_mutable)}') - text = text.replace(old_mutable, new_mutable, 1) - - old_non_git = ''' let input = - VALID_LOCK.replacen("0123456789abcdef0123456789abcdef01234567", revision, 1);'''.replace(' ', ' ') - new_non_git = ''' let input = VALID_LOCK.replacen( - "vcs_commit = \\"0123456789abcdef0123456789abcdef01234567\\"", - &format!("vcs_commit = \\"{revision}\\""), - 1, - );'''.replace(' ', ' ') - if text.count(old_non_git) != 1: - raise SystemExit(f'expected one non-git fixture block, found {text.count(old_non_git)}') - text = text.replace(old_non_git, new_non_git, 1) - - path.write_text(text) - - - name: Format and verify the complete contract - run: | - cargo fmt - cargo clippy --locked --all-targets -- -D warnings - cargo test --locked - cargo run --locked --example generate_schemas - cargo test --locked lockfile::tests::public_schema_requires_complete_package_provenance - git diff --check - - - name: Remove the one-shot workflow and commit - run: | - git rm .github/workflows/den-1464-fix-schema-tests.yml - git config user.name 'ORESoftware' - git config user.email '11139560+ORESoftware@users.noreply.github.com' - git add src/lockfile.rs schemas/lockfile.json - git diff --cached --check - git commit -m 'fix(DEN-1464): require VCS commit in schema and test exact field' - git push origin HEAD:agent/den-1464-strict-frozen-lock-metadata diff --git a/schemas/lockfile.json b/schemas/lockfile.json index f3469e7..344efa2 100644 --- a/schemas/lockfile.json +++ b/schemas/lockfile.json @@ -93,6 +93,7 @@ "size", "format", "vcs_tag", + "vcs_commit", "source" ] }, diff --git a/src/lockfile.rs b/src/lockfile.rs index aef95c5..14b301d 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -52,6 +52,7 @@ pub struct LockedPackage { /// all require this value to be explicitly present. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars( + !default, required, length(min = 7, max = 128), regex(pattern = r"^[A-Za-z0-9._+:/-]+$") @@ -368,7 +369,6 @@ source = "file:///tmp/registry" #[test] fn mutable_malformed_and_zero_vcs_revisions_are_rejected() { - let original = "0123456789abcdef0123456789abcdef01234567"; for replacement in [ "main", "refs/heads/main", @@ -378,7 +378,11 @@ source = "file:///tmp/registry" "revision with spaces", "revision@host", ] { - let input = VALID_LOCK.replacen(original, replacement, 1); + let input = VALID_LOCK.replacen( + "vcs_commit = \"0123456789abcdef0123456789abcdef01234567\"", + &format!("vcs_commit = \"{replacement}\""), + 1, + ); let error = Lockfile::parse(&input).unwrap_err().to_string(); assert!(error.contains("vcs_commit"), "unexpected error: {error}"); } @@ -391,8 +395,11 @@ source = "file:///tmp/registry" "hg/0123456789abcdef0123456789abcdef01234567", "pijul+ABCdef0123456789_-", ] { - let input = - VALID_LOCK.replacen("0123456789abcdef0123456789abcdef01234567", revision, 1); + let input = VALID_LOCK.replacen( + "vcs_commit = \"0123456789abcdef0123456789abcdef01234567\"", + &format!("vcs_commit = \"{revision}\""), + 1, + ); assert!( Lockfile::parse(&input).is_ok(), "revision rejected: {revision}" From 85eab02b4abf8dc9001729c980a8946812efa6a3 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:22:06 -0500 Subject: [PATCH 109/122] docs(DEN-1464): document frozen lock integrity and migration --- docs/frozen-lock-integrity.md | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/frozen-lock-integrity.md diff --git a/docs/frozen-lock-integrity.md b/docs/frozen-lock-integrity.md new file mode 100644 index 0000000..ae7b096 --- /dev/null +++ b/docs/frozen-lock-integrity.md @@ -0,0 +1,45 @@ +# 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 can be +migrated without an immediate source break. Parsing, JSON Schema validation, +and serialization still require the value: `None` is a construction-time +intermediate state, not a valid committed lockfile. + +## 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 and immutable source revision. +Do not repair a frozen lock by guessing an archive format, size, checksum, or +revision. + +Consumers should validate with `Lockfile::parse` before beginning any install +transaction and should emit committed lockfiles only through +`Lockfile::to_toml_string`. From fab1910a5cf2ade3f3dcc5d5c8ffec5e57a7754f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:23:23 -0500 Subject: [PATCH 110/122] fix(DEN-1464): target VCS fixtures and override schema defaults --- src/lockfile.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/lockfile.rs b/src/lockfile.rs index 14b301d..90b4e5a 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -312,10 +312,21 @@ sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" size = 42 format = "tar.gz" vcs_tag = "v1.2.3" -vcs_commit = "0123456789abcdef0123456789abcdef01234567" +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, + ) + } + #[test] fn complete_package_metadata_round_trips() { let lock = Lockfile::parse(VALID_LOCK).unwrap(); @@ -332,10 +343,7 @@ source = "file:///tmp/registry" #[test] fn missing_vcs_commit_is_rejected() { - let input = VALID_LOCK.replace( - "vcs_commit = \"0123456789abcdef0123456789abcdef01234567\"\n", - "", - ); + 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}"); } @@ -369,7 +377,7 @@ source = "file:///tmp/registry" #[test] fn mutable_malformed_and_zero_vcs_revisions_are_rejected() { - for replacement in [ + for revision in [ "main", "refs/heads/main", "latest", @@ -378,11 +386,7 @@ source = "file:///tmp/registry" "revision with spaces", "revision@host", ] { - let input = VALID_LOCK.replacen( - "vcs_commit = \"0123456789abcdef0123456789abcdef01234567\"", - &format!("vcs_commit = \"{replacement}\""), - 1, - ); + let input = lock_with_commit(revision); let error = Lockfile::parse(&input).unwrap_err().to_string(); assert!(error.contains("vcs_commit"), "unexpected error: {error}"); } @@ -395,11 +399,7 @@ source = "file:///tmp/registry" "hg/0123456789abcdef0123456789abcdef01234567", "pijul+ABCdef0123456789_-", ] { - let input = VALID_LOCK.replacen( - "vcs_commit = \"0123456789abcdef0123456789abcdef01234567\"", - &format!("vcs_commit = \"{revision}\""), - 1, - ); + let input = lock_with_commit(revision); assert!( Lockfile::parse(&input).is_ok(), "revision rejected: {revision}" From f572b3b9c88fe94f142f20cbf8aa37b7c516705b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:23:43 -0500 Subject: [PATCH 111/122] chore(DEN-1464): regenerate strict lock schema on the feature branch --- .../workflows/den-1464-regenerate-schema.yml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/den-1464-regenerate-schema.yml diff --git a/.github/workflows/den-1464-regenerate-schema.yml b/.github/workflows/den-1464-regenerate-schema.yml new file mode 100644 index 0000000..e7d5de7 --- /dev/null +++ b/.github/workflows/den-1464-regenerate-schema.yml @@ -0,0 +1,60 @@ +name: DEN-1464 regenerate strict lock schema + +on: + push: + branches: + - agent/den-1464-strict-frozen-lock-metadata + paths: + - '.github/workflows/den-1464-regenerate-schema.yml' + +permissions: + contents: write + +concurrency: + group: den-1464-regenerate-schema + cancel-in-progress: true + +jobs: + regenerate: + if: github.actor == 'ORESoftware' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: agent/den-1464-strict-frozen-lock-metadata + persist-credentials: true + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt + - name: Format and regenerate checked-in schemas + shell: bash + run: | + set -euo pipefail + cargo fmt + cargo run --locked --example generate_schemas + - name: Require only generated lock-contract outputs + shell: bash + run: | + set -euo pipefail + mapfile -t changed < <(git diff --name-only | sort) + printf '%s\n' "${changed[@]}" + test "${#changed[@]}" -ge 1 + for path in "${changed[@]}"; do + case "$path" in + schemas/lockfile.json|src/lockfile.rs) ;; + *) echo "unexpected generated change: $path" >&2; exit 1 ;; + esac + done + git diff --check + - name: Commit generated schema and formatting + shell: bash + run: | + set -euo pipefail + git config user.name 'ORESoftware' + git config user.email '11139560+ORESoftware@users.noreply.github.com' + git add src/lockfile.rs schemas/lockfile.json + git commit -m 'style(DEN-1464): regenerate strict lock schema' + git push origin HEAD:agent/den-1464-strict-frozen-lock-metadata From 73a5a251d43e3583fa63944f1187806cc5557705 Mon Sep 17 00:00:00 2001 From: ORESoftware <11139560+ORESoftware@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:24:04 +0000 Subject: [PATCH 112/122] style(DEN-1464): regenerate strict lock schema --- src/lockfile.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lockfile.rs b/src/lockfile.rs index 90b4e5a..0a2f59e 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -316,8 +316,7 @@ vcs_commit = "fedcba9876543210fedcba9876543210fedcba98" source = "file:///tmp/registry" "#; - const VALID_COMMIT_LINE: &str = - "vcs_commit = \"fedcba9876543210fedcba9876543210fedcba98\""; + const VALID_COMMIT_LINE: &str = "vcs_commit = \"fedcba9876543210fedcba9876543210fedcba98\""; fn lock_with_commit(revision: &str) -> String { VALID_LOCK.replacen( From 31caf94d89ef04b9e4fc589fa945d028d0c8e0a0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:25:38 -0500 Subject: [PATCH 113/122] docs(DEN-1464): clarify required JSON Schema contract --- docs/frozen-lock-integrity.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/frozen-lock-integrity.md b/docs/frozen-lock-integrity.md index ae7b096..f78aa87 100644 --- a/docs/frozen-lock-integrity.md +++ b/docs/frozen-lock-integrity.md @@ -27,6 +27,13 @@ migrated without an immediate source break. Parsing, JSON Schema validation, and serialization still require the value: `None` is a construction-time intermediate state, not a valid committed lockfile. +## 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 From 40146d69632c23405e87c3462db286412bc02091 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:28:31 -0500 Subject: [PATCH 114/122] chore(DEN-1464): remove one-time schema regeneration workflow --- .../workflows/den-1464-regenerate-schema.yml | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 .github/workflows/den-1464-regenerate-schema.yml diff --git a/.github/workflows/den-1464-regenerate-schema.yml b/.github/workflows/den-1464-regenerate-schema.yml deleted file mode 100644 index e7d5de7..0000000 --- a/.github/workflows/den-1464-regenerate-schema.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: DEN-1464 regenerate strict lock schema - -on: - push: - branches: - - agent/den-1464-strict-frozen-lock-metadata - paths: - - '.github/workflows/den-1464-regenerate-schema.yml' - -permissions: - contents: write - -concurrency: - group: den-1464-regenerate-schema - cancel-in-progress: true - -jobs: - regenerate: - if: github.actor == 'ORESoftware' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - ref: agent/den-1464-strict-frozen-lock-metadata - persist-credentials: true - fetch-depth: 0 - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - name: Format and regenerate checked-in schemas - shell: bash - run: | - set -euo pipefail - cargo fmt - cargo run --locked --example generate_schemas - - name: Require only generated lock-contract outputs - shell: bash - run: | - set -euo pipefail - mapfile -t changed < <(git diff --name-only | sort) - printf '%s\n' "${changed[@]}" - test "${#changed[@]}" -ge 1 - for path in "${changed[@]}"; do - case "$path" in - schemas/lockfile.json|src/lockfile.rs) ;; - *) echo "unexpected generated change: $path" >&2; exit 1 ;; - esac - done - git diff --check - - name: Commit generated schema and formatting - shell: bash - run: | - set -euo pipefail - git config user.name 'ORESoftware' - git config user.email '11139560+ORESoftware@users.noreply.github.com' - git add src/lockfile.rs schemas/lockfile.json - git commit -m 'style(DEN-1464): regenerate strict lock schema' - git push origin HEAD:agent/den-1464-strict-frozen-lock-metadata From 35985cece2a58e708b72f6dfc883b0c803cfd94d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 13:11:38 -0500 Subject: [PATCH 115/122] DEN-1464 normalize legacy in-memory provenance to exact artifact identity --- src/lockfile.rs | 104 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/src/lockfile.rs b/src/lockfile.rs index 0a2f59e..704f846 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -8,6 +8,8 @@ use crate::nix::NixAdapterRecord; 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 package, @@ -48,8 +50,9 @@ pub struct LockedPackage { pub vcs_tag: String, /// Exact immutable source revision associated with the published artifact. /// The optional Rust representation preserves API compatibility for - /// builders, but lockfile parsing, schema generation, and serialization - /// all require this value to be explicitly present. + /// 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, @@ -107,9 +110,10 @@ impl Lockfile { } pub fn to_toml_string(&self) -> Result { - self.validate_packages()?; - self.validate_nix_adapters()?; let mut normalized = self.clone(); + normalized.normalize_missing_package_revisions()?; + normalized.validate_packages()?; + normalized.validate_nix_adapters()?; normalized.nix_adapters.sort_by_key(nix_adapter_key); toml::to_string_pretty(&normalized).map_err(|error| LockfileError::Toml(error.to_string())) } @@ -144,6 +148,29 @@ impl Lockfile { 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 { @@ -221,6 +248,10 @@ fn invalid_package(package: &str, reason: &str) -> Result { }) } +fn artifact_revision(sha256: &str) -> String { + format!("{ARTIFACT_REVISION_PREFIX}{sha256}") +} + fn is_canonical_sha256(value: &str) -> bool { value.len() == 64 && value @@ -231,7 +262,9 @@ fn is_canonical_sha256(value: &str) -> bool { /// 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. +/// 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; @@ -326,6 +359,20 @@ source = "file:///tmp/registry" ) } + 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(); @@ -392,11 +439,12 @@ source = "file:///tmp/registry" } #[test] - fn non_git_immutable_revisions_remain_supported() { + 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!( @@ -415,25 +463,39 @@ source = "file:///tmp/registry" } #[test] - fn writer_refuses_to_emit_incomplete_provenance() { + fn writer_normalizes_missing_commit_to_exact_artifact_revision() { + let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; let lock = Lockfile { version: Lockfile::CURRENT_VERSION, - packages: vec![LockedPackage { - org: "zed-pkg".to_string(), - name: "fixture".to_string(), - version: "1.2.3".to_string(), - sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - .to_string(), - size: 42, - format: ArtifactFormat::TarGz, - vcs_tag: "v1.2.3".to_string(), - vcs_commit: None, - source: "file:///tmp/registry".to_string(), - }], + packages: vec![package_without_commit(digest)], nix_adapters: Vec::new(), }; - let error = lock.to_toml_string().unwrap_err().to_string(); - assert!(error.contains("vcs_commit")); + 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)], + nix_adapters: Vec::new(), + }; + let error = lock.to_toml_string().unwrap_err().to_string(); + assert!(error.contains("sha256"), "unexpected error: {error}"); + } } #[test] From 8843701e61f60d7e66a20396ac75e6d6f9df4607 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 13:12:07 -0500 Subject: [PATCH 116/122] DEN-1464 document content-addressed legacy provenance normalization --- docs/frozen-lock-integrity.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/frozen-lock-integrity.md b/docs/frozen-lock-integrity.md index f78aa87..229f649 100644 --- a/docs/frozen-lock-integrity.md +++ b/docs/frozen-lock-integrity.md @@ -22,10 +22,23 @@ 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 can be -migrated without an immediate source break. Parsing, JSON Schema validation, -and serialization still require the value: `None` is a construction-time -intermediate state, not a valid committed lockfile. +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 @@ -43,9 +56,10 @@ 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 and immutable source revision. -Do not repair a frozen lock by guessing an archive format, size, checksum, or -revision. +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 From 5d1e936f3bd8b7e06f3cf95ffcf95ddee4e40263 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 13:12:35 -0500 Subject: [PATCH 117/122] DEN-1464 add public content-addressed provenance integration tests --- .../lockfile_content_addressed_provenance.rs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/lockfile_content_addressed_provenance.rs diff --git a/tests/lockfile_content_addressed_provenance.rs b/tests/lockfile_content_addressed_provenance.rs new file mode 100644 index 0000000..4b41ec9 --- /dev/null +++ b/tests/lockfile_content_addressed_provenance.rs @@ -0,0 +1,101 @@ +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)], + 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))], + 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() { + for digest in ["bad", "0".repeat(64).as_str()] { + let mut bad = package(None); + bad.sha256 = digest.to_string(); + let lock = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: vec![bad], + nix_adapters: Vec::new(), + }; + let error = lock.to_toml_string().unwrap_err().to_string(); + assert!(error.contains("sha256"), "unexpected error: {error}"); + } +} From 212648981adad6453e4651d59c9ac9d98fb793c2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 13:13:02 -0500 Subject: [PATCH 118/122] DEN-1464 make public fallback tests lifetime-safe --- tests/lockfile_content_addressed_provenance.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/lockfile_content_addressed_provenance.rs b/tests/lockfile_content_addressed_provenance.rs index 4b41ec9..2da1f34 100644 --- a/tests/lockfile_content_addressed_provenance.rs +++ b/tests/lockfile_content_addressed_provenance.rs @@ -33,7 +33,10 @@ fn canonical_writer_upgrades_legacy_none_without_mutating_the_builder() { 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())); + assert_eq!( + parsed.packages[0].vcs_commit.as_deref(), + Some(expected.as_str()) + ); } #[test] @@ -87,9 +90,10 @@ fn public_schema_keeps_revision_required_despite_the_builder_fallback() { #[test] fn fallback_derivation_rejects_malformed_or_zero_hashes() { - for digest in ["bad", "0".repeat(64).as_str()] { + let digests = ["bad".to_string(), "0".repeat(64)]; + for digest in digests { let mut bad = package(None); - bad.sha256 = digest.to_string(); + bad.sha256 = digest; let lock = Lockfile { version: Lockfile::CURRENT_VERSION, packages: vec![bad], From cd49c068ea2534b5685a4d68c85258dcc4c0af57 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 13:16:54 -0500 Subject: [PATCH 119/122] DEN-1464 apply canonical Rust formatting --- src/lockfile.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lockfile.rs b/src/lockfile.rs index 704f846..8eb5488 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -471,9 +471,7 @@ source = "file:///tmp/registry" nix_adapters: Vec::new(), }; let serialized = lock.to_toml_string().unwrap(); - assert!(serialized.contains(&format!( - "vcs_commit = \"artifact-sha256:{digest}\"" - ))); + 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!( From cae63e189ba77235d830e12ed859d94183a1721a Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 13:20:59 -0500 Subject: [PATCH 120/122] DEN-1464 commit the regenerated lockfile schema description --- schemas/lockfile.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/lockfile.json b/schemas/lockfile.json index 344efa2..2608dc1 100644 --- a/schemas/lockfile.json +++ b/schemas/lockfile.json @@ -69,7 +69,7 @@ "minLength": 1 }, "vcs_commit": { - "description": "Exact immutable source revision associated with the published artifact.\nThe optional Rust representation preserves API compatibility for\nbuilders, but lockfile parsing, schema generation, and serialization\nall require this value to be explicitly present.", + "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, From 131d6a5bec14535f0bee9b247c1df6d39c5c682b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 22:43:47 -0500 Subject: [PATCH 121/122] feat(DEN-1452): add hardened EnvironmentLock v1 Add the exact manager-neutral environment/tool lock on current main, including immutable source and artifact identity, portable install layouts, credential-safe locators, source/artifact consistency, Windows command collision detection, recursive extension validation, deterministic schema generation, and cross-platform contract tests. --- .../workflows/environment-lock-contract.yml | 113 ++ Cargo.lock | 91 ++ Cargo.toml | 2 + examples/generate_schemas.rs | 1 + schemas/environment-lock-v1.json | 326 +++++ src/environment_lock.rs | 1301 +++++++++++++++++ src/lib.rs | 6 + 7 files changed, 1840 insertions(+) create mode 100644 .github/workflows/environment-lock-contract.yml create mode 100644 schemas/environment-lock-v1.json create mode 100644 src/environment_lock.rs 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/Cargo.lock b/Cargo.lock index 4037a86..d4c2c62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,50 @@ # 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" @@ -14,12 +58,28 @@ 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" @@ -36,6 +96,12 @@ 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" @@ -178,6 +244,17 @@ 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" @@ -259,12 +336,24 @@ 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" @@ -275,10 +364,12 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" name = "zed-interfaces" version = "0.1.0" dependencies = [ + "hex", "schemars", "semver", "serde", "serde_json", + "sha2", "thiserror", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index ab18f0a..8dfa1b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,11 @@ license = "MIT" repository = "https://github.com/zed-pkg/zed-interfaces" [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/examples/generate_schemas.rs b/examples/generate_schemas.rs index 6c497c1..7df9992 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -24,6 +24,7 @@ fn main() { 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-adapter-record"); write::(dir, "package-metadata"); 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/src/environment_lock.rs b/src/environment_lock.rs new file mode 100644 index 0000000..b4ab7fd --- /dev/null +++ b/src/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/lib.rs b/src/lib.rs index 6eecc89..4810b4f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod artifact; pub mod environment; +pub mod environment_lock; pub mod environment_v2; pub mod excludes; pub mod language; @@ -27,6 +28,11 @@ pub use environment::{ 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, From 1ceb67ba11c5566d6cbe619f8b8d4f08667ee0db Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 06:29:55 -0500 Subject: [PATCH 122/122] ci(DEN-1418): remove obsolete self-cleaning schema workflow --- .../agent-nix-plan-schema-generated.yml | 111 ------------------ 1 file changed, 111 deletions(-) delete mode 100644 .github/workflows/agent-nix-plan-schema-generated.yml diff --git a/.github/workflows/agent-nix-plan-schema-generated.yml b/.github/workflows/agent-nix-plan-schema-generated.yml deleted file mode 100644 index 442fb04..0000000 --- a/.github/workflows/agent-nix-plan-schema-generated.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: agent-nix-plan-schema-generated - -on: - pull_request: - branches: - - main - paths: - - ".github/workflows/agent-nix-plan-schema-generated.yml" - -permissions: - contents: write - -concurrency: - group: den-1418-schema-apply - cancel-in-progress: true - -jobs: - apply: - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: agent/den-1418-nix-export-plan-schema - fetch-depth: 0 - - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - components: rustfmt,clippy - - - name: Normalize, validate, generate, commit, and remove this helper - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - path = Path('src/nix_plan.rs') - text = path.read_text() - replacements = [ - ('use std::path::{Component, Path};', 'use std::path::Path;'), - ( - '#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]\n pub bins:', - '#[serde(default)]\n pub bins:', - ), - ( - '#[serde(default, skip_serializing_if = "Vec::is_empty")]\n pub dependencies:', - '#[serde(default)]\n pub dependencies:', - ), - ( - '''fn is_safe_relative_path(value: &str) -> bool { - let path = Path::new(value); - !value.is_empty() - && !path.is_absolute() - && path.components().all(|component| { - matches!(component, Component::Normal(_)) - && component.as_os_str().to_str().is_some_and(|part| { - !part.is_empty() - && part != "." - && part != ".." - && !part.chars().any(char::is_control) - }) - }) - }''', - '''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) - }) - }''', - ), - ( - ''' && !value.starts_with(['-', '.']) - && !value.ends_with(['-', '.'])''', - ''' && !matches!(value.chars().next(), Some('-' | '.')) - && !matches!(value.chars().last(), Some('-' | '.'))''', - ), - ( - ''' assert_eq!(decoded["schema"], NIX_EXPORT_PLAN_SCHEMA_V1); - assert!(!encoded.contains("registry"));''', - ''' 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"));''', - ), - ] - for before, after in replacements: - if before not in text: - raise SystemExit(f'normalization anchor not found: {before[:80]!r}') - text = text.replace(before, after, 1) - path.write_text(text) - PY - cargo fmt --all - cargo fmt --all --check - cargo clippy --locked --all-targets -- -D warnings - cargo test --locked nix_plan::tests:: -- --test-threads=1 - cargo run --locked --example generate_schemas - test -s schemas/nix-export-plan.json - git rm .github/workflows/agent-nix-plan-schema-generated.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/nix_plan.rs src/lib.rs examples/generate_schemas.rs schemas/nix-export-plan.json - git diff --cached --check - git commit -m "feat(DEN-1418): apply generated Nix export plan schema" - git push origin HEAD:agent/den-1418-nix-export-plan-schema