From 0eac5a9dd8e98ebaeb035b6806a7520a07b6a301 Mon Sep 17 00:00:00 2001 From: alex-mills Date: Thu, 23 Jul 2026 14:22:29 -0500 Subject: [PATCH 001/191] 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/191] 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 41cc43f6063a75dda912213a30062d3cc93cff23 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:56:28 -0500 Subject: [PATCH 003/191] claude autosave 2026-07-24T14:56:28Z --- src/manifest.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 425a0cd..ccdeff1 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)] From 57941fcf424c3dad3872bfd20147e98347ab8bba Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:56:36 -0500 Subject: [PATCH 004/191] claude autosave 2026-07-24T14:56:36Z --- src/manifest.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index ccdeff1..44ad310 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -130,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]")] From b41135140f4eefa3dd68b503f10e3540d718a285 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:56:42 -0500 Subject: [PATCH 005/191] claude autosave 2026-07-24T14:56:42Z --- src/manifest.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 44ad310..9d49c89 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -180,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), } From 5cade8080c7d5578188210adaa6d6527b343de34 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:56:51 -0500 Subject: [PATCH 006/191] claude autosave 2026-07-24T14:56:51Z --- src/manifest.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 9d49c89..3d06611 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -199,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 { From 1aa0f72094dcf4d294b62b610bd1b502edb07140 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:57:07 -0500 Subject: [PATCH 007/191] claude autosave 2026-07-24T14:57:07Z --- src/manifest.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/src/manifest.rs b/src/manifest.rs index 3d06611..cf928a3 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -256,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(), @@ -271,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(()) } From 1c6b6fe731caa7e9684c5ca788625d5490d75ba3 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:57:20 -0500 Subject: [PATCH 008/191] claude autosave 2026-07-24T14:57:20Z --- src/version.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 cc19a5f105fdb914427ca372427de704aa776ee0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:57:36 -0500 Subject: [PATCH 009/191] claude autosave 2026-07-24T14:57:36Z --- src/paths.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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) +} From d67b44508813cf3a26e2434aae2064046f1b949a Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:57:45 -0500 Subject: [PATCH 010/191] claude autosave 2026-07-24T14:57:45Z --- src/lockfile.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From cbeebb79c1a995c8c91d728b8e1ccaac260e1d92 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:57:46 -0500 Subject: [PATCH 011/191] claude autosave 2026-07-24T14:57:46Z --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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`. From 8e28c94647e211349796d9ed1825774d544379a2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:57:59 -0500 Subject: [PATCH 012/191] claude autosave 2026-07-24T14:57:59Z --- src/registry.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/registry.rs b/src/registry.rs index 44662b3..f0a4efc 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") From e88fe276c8f45e3bbc7fe591a23d4c8d73457908 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:58:13 -0500 Subject: [PATCH 013/191] claude autosave 2026-07-24T14:58:13Z --- src/registry.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/registry.rs b/src/registry.rs index f0a4efc..d70c6f3 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -141,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, From ced8ac5b245a19b1113c7a6f3991b2694d4ca48d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 09:58:25 -0500 Subject: [PATCH 014/191] claude autosave 2026-07-24T14:58:25Z --- examples/generate_schemas.rs | 3 +++ 1 file changed, 3 insertions(+) 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"); } From 56dc77a9937f8fb1214542102633fbcdb49eda92 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 10:32:52 -0500 Subject: [PATCH 015/191] 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 1172f1280ede76c7cd96521c82822f91854441a2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 10:37:29 -0500 Subject: [PATCH 016/191] claude autosave 2026-07-24T15:37:29Z --- .github/workflows/ci.yml | 25 ++++++++ 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 ++++++++ 7 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml 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/.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) 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" + ] +} From 0bdc44e8e9c05b529043bc72cfd52ba626aa30e5 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 24 Jul 2026 10:37:39 -0500 Subject: [PATCH 017/191] 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 018/191] 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 5f2baa77e9a6a9705bd1839a11ed2a2715c9fa1b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:36:42 -0500 Subject: [PATCH 019/191] claude autosave 2026-07-25T15:36:42Z --- rust-toolchain.toml | 5 ++ schemas/manifest.json | 18 +++--- schemas/publish-meta.json | 18 +++--- src/manifest.rs | 128 +++++++++++++++++++++++++++----------- 4 files changed, 116 insertions(+), 53 deletions(-) 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/schemas/manifest.json b/schemas/manifest.json index 9da7bd4..5428d7e 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -5,14 +5,14 @@ "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 `.", + "description": "Executables this package exposes, keyed by command name, valued by a\npath relative to the package root. On install they are hoisted into\n`zed_modules/.bin/` and runnable via `zed run ` (zed-docs\nissue #7).", "type": "object", "additionalProperties": { "type": "string" } }, "build": { - "description": "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).", + "description": "This package's own post-extract build step (compiled extensions,\ncodegen), run when the package ships source that needs compiling.\nBuilds run in an isolated staging copy — never inside the immutable\nsource store — and results are cached per (sha256, target, command).", "anyOf": [ { "$ref": "#/$defs/BuildSection" @@ -22,8 +22,8 @@ } ] }, - "build_dependencies": { - "description": "Dependencies needed only while running this package's `[build]`\ncommand. Never linked into a consumer's zed_modules/.", + "build-dependencies": { + "description": "Tools needed only while running this package's `[build]` command\n(compilers, codegen). They are made available in the build sandbox and\nnever linked into a consumer's `zed_modules/`. See [`BuildSection`]\nand zed-docs issue #5. Canonical TOML key is Cargo-style\n`[build-dependencies]`; the snake_case spelling is accepted on read.", "type": "object", "additionalProperties": { "type": "string" @@ -37,7 +37,7 @@ } }, "overrides": { - "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing build command without waiting on upstream).", + "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing `[build]` step without waiting on upstream).", "$ref": "#/$defs/OverridesSection" }, "package": { @@ -59,7 +59,7 @@ } }, "workspace": { - "description": "Monorepo workspace configuration; only meaningful in a workspace\nroot manifest.", + "description": "Monorepo workspace declaration (zed-docs issue #7); only meaningful in\na workspace root manifest. When present, `zed install` at this root\nresolves every member against one store and writes one `.zpkg.lock`;\nmember→member dependencies link by path instead of going through the\nregistry.", "anyOf": [ { "$ref": "#/$defs/WorkspaceSection" @@ -75,7 +75,7 @@ ], "$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).", + "description": "A post-extract build step. Because compiled output is OS/arch-specific,\nzed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the\nsource and caches the result in a build cache keyed by\n`(source sha256, target triple, command)` — separate from the universal,\nplatform-independent source store (zed-docs issue #5).", "type": "object", "properties": { "command": { @@ -172,7 +172,7 @@ "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.", + "description": "Command run by `zed r2g` (alias `zed test-local`) inside a throwaway\nconsumer project that has this package installed the same way a real\nconsumer would.", "type": [ "string", "null" @@ -248,7 +248,7 @@ ] }, "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.", + "description": "Monorepo workspace membership. `members` are glob patterns (relative to\nthe workspace root) selecting directories that each contain a `.zpkg.toml`,\ne.g. `[\"packages/*\", \"apps/*\"]`. Dependencies that resolve to a member are\nlinked straight to the member's source directory instead of going through\nthe registry.", "type": "object", "properties": { "members": { diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index ef0c909..f63d3ed 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -47,7 +47,7 @@ ] }, "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).", + "description": "A post-extract build step. Because compiled output is OS/arch-specific,\nzed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the\nsource and caches the result in a build cache keyed by\n`(source sha256, target triple, command)` — separate from the universal,\nplatform-independent source store (zed-docs issue #5).", "type": "object", "properties": { "command": { @@ -71,14 +71,14 @@ "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 `.", + "description": "Executables this package exposes, keyed by command name, valued by a\npath relative to the package root. On install they are hoisted into\n`zed_modules/.bin/` and runnable via `zed run ` (zed-docs\nissue #7).", "type": "object", "additionalProperties": { "type": "string" } }, "build": { - "description": "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).", + "description": "This package's own post-extract build step (compiled extensions,\ncodegen), run when the package ships source that needs compiling.\nBuilds run in an isolated staging copy — never inside the immutable\nsource store — and results are cached per (sha256, target, command).", "anyOf": [ { "$ref": "#/$defs/BuildSection" @@ -88,8 +88,8 @@ } ] }, - "build_dependencies": { - "description": "Dependencies needed only while running this package's `[build]`\ncommand. Never linked into a consumer's zed_modules/.", + "build-dependencies": { + "description": "Tools needed only while running this package's `[build]` command\n(compilers, codegen). They are made available in the build sandbox and\nnever linked into a consumer's `zed_modules/`. See [`BuildSection`]\nand zed-docs issue #5. Canonical TOML key is Cargo-style\n`[build-dependencies]`; the snake_case spelling is accepted on read.", "type": "object", "additionalProperties": { "type": "string" @@ -103,7 +103,7 @@ } }, "overrides": { - "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing build command without waiting on upstream).", + "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing `[build]` step without waiting on upstream).", "$ref": "#/$defs/OverridesSection" }, "package": { @@ -125,7 +125,7 @@ } }, "workspace": { - "description": "Monorepo workspace configuration; only meaningful in a workspace\nroot manifest.", + "description": "Monorepo workspace declaration (zed-docs issue #7); only meaningful in\na workspace root manifest. When present, `zed install` at this root\nresolves every member against one store and writes one `.zpkg.lock`;\nmember→member dependencies link by path instead of going through the\nregistry.", "anyOf": [ { "$ref": "#/$defs/WorkspaceSection" @@ -218,7 +218,7 @@ "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.", + "description": "Command run by `zed r2g` (alias `zed test-local`) inside a throwaway\nconsumer project that has this package installed the same way a real\nconsumer would.", "type": [ "string", "null" @@ -294,7 +294,7 @@ ] }, "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.", + "description": "Monorepo workspace membership. `members` are glob patterns (relative to\nthe workspace root) selecting directories that each contain a `.zpkg.toml`,\ne.g. `[\"packages/*\", \"apps/*\"]`. Dependencies that resolve to a member are\nlinked straight to the member's source directory instead of going through\nthe registry.", "type": "object", "properties": { "members": { diff --git a/src/manifest.rs b/src/manifest.rs index cf928a3..c5171ab 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -31,35 +31,56 @@ 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); only meaningful in + /// a workspace root manifest. When present, `zed install` at this root + /// resolves every member against one store and writes one `.zpkg.lock`; + /// member→member dependencies link by path instead of going through the + /// registry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, /// Dependencies keyed by `org/name`, valued by a semver requirement. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub dependencies: BTreeMap, - /// 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")] + /// Tools needed only while running this package's `[build]` command + /// (compilers, codegen). They are made available in the build sandbox and + /// never linked into a consumer's `zed_modules/`. See [`BuildSection`] + /// and zed-docs issue #5. Canonical TOML key is Cargo-style + /// `[build-dependencies]`; the snake_case spelling is accepted on read. + #[serde( + default, + rename = "build-dependencies", + alias = "build_dependencies", + skip_serializing_if = "BTreeMap::is_empty" + )] pub build_dependencies: BTreeMap, - #[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). + /// This package's own post-extract build step (compiled extensions, + /// codegen), run when the package ships source that needs compiling. /// Builds run in an isolated staging copy — never inside the immutable - /// source store — and results are cached per (sha256, platform). + /// source store — and results are cached per (sha256, target, command). #[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). + /// broken or missing `[build]` step without waiting on upstream). #[serde(default, skip_serializing_if = "OverridesSection::is_empty")] pub overrides: OverridesSection, + /// Executables this package exposes, keyed by command name, valued by a + /// path relative to the package root. On install they are hoisted into + /// `zed_modules/.bin/` and runnable via `zed run ` (zed-docs + /// issue #7). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bin: BTreeMap, + #[serde(default)] + pub publish: PublishSection, + #[serde(default)] + pub scripts: ScriptsSection, + /// Where zed materializes the (few, hand-picked) dependencies it sources — + /// zed complements npm/maven/etc. rather than replacing them, so this dir + /// sits alongside the native one and the ecosystem adapter wires it into + /// the toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir` + /// defaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or + /// `.deps/.zed`. + #[serde(default, skip_serializing_if = "InstallSection::is_empty")] + pub install: InstallSection, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -103,8 +124,9 @@ pub struct PublishSection { 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. + /// Command run by `zed r2g` (alias `zed test-local`) inside a throwaway + /// consumer project that has this package installed the same way a real + /// consumer would. pub smoke_test: Option, /// VCS tag template that must exist and point at the published commit. /// `{version}` is substituted with `package.version`. @@ -130,9 +152,11 @@ 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). +/// A post-extract build step. Because compiled output is OS/arch-specific, +/// zed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the +/// source and caches the result in a build cache keyed by +/// `(source sha256, target triple, command)` — separate from the universal, +/// platform-independent source store (zed-docs issue #5). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct BuildSection { /// Command executed after extraction, e.g. `make` or `cargo build --release`. @@ -143,10 +167,11 @@ pub struct BuildSection { 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. +/// Monorepo workspace membership. `members` are glob patterns (relative to +/// the workspace root) selecting directories that each contain a `.zpkg.toml`, +/// e.g. `["packages/*", "apps/*"]`. Dependencies that resolve to a member are +/// linked straight to the member's source directory instead of going through +/// the registry. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct WorkspaceSection { @@ -186,6 +211,8 @@ pub enum ManifestError { InvalidBin(String, String), #[error("invalid build section: {0}")] InvalidBuild(String), + #[error("invalid workspace member pattern `{0}`")] + InvalidWorkspaceMember(String), #[error("manifest toml error: {0}")] Toml(String), } @@ -199,6 +226,15 @@ pub fn is_slug(s: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } +/// True for a well-formed `org/name` dependency key. +pub fn is_dependency_key(key: &str) -> bool { + let mut parts = key.splitn(2, '/'); + match (parts.next(), parts.next()) { + (Some(org), Some(name)) => is_slug(org) && is_slug(name), + _ => false, + } +} + /// True for a 64-character lowercase-hex sha256 digest. Registry responses /// and lockfiles feed digests into filesystem paths, so anything else is /// rejected before it can reach the disk layer. @@ -210,6 +246,7 @@ pub fn is_sha256_hex(s: &str) -> bool { /// True when `path` is a relative path with no `..` components — the only /// shape allowed for manifest-declared paths (bin targets, build outputs). +/// Keeps hoisted bins and build outputs contained inside the package. pub fn is_safe_relative_path(path: &str) -> bool { !path.is_empty() && !path.starts_with('/') @@ -263,9 +300,7 @@ impl Manifest { )); } 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) { + if !is_dependency_key(key) { return Err(ManifestError::InvalidDependencyKey(key.clone())); } // A requirement is either a semver range or an exact (opaque) tag. @@ -320,16 +355,39 @@ impl Manifest { } } } - 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) { + for key in self.overrides.build.keys() { + if !is_dependency_key(key) { return Err(ManifestError::InvalidDependencyKey(key.clone())); } } + if let Some(ws) = &self.workspace { + for pat in &ws.members { + if pat.trim().is_empty() { + return Err(ManifestError::InvalidWorkspaceMember(pat.clone())); + } + } + } 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 + /// `[overrides.build]` entry if present, else the dependency's own + /// `[build]`. Returns `None` when neither declares one. + pub fn effective_build( + &self, + dep_key: &str, + dep_build: Option<&BuildSection>, + ) -> Option { + self.overrides.build.get(dep_key).or(dep_build).cloned() + } + /// `org/name`, the canonical package identifier. pub fn full_name(&self) -> String { format!("{}/{}", self.package.org, self.package.name) From 5eaea89ce1006a4d8fab7911e8bb20fc262e2e19 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:37:11 -0500 Subject: [PATCH 020/191] claude autosave 2026-07-25T15:37:11Z --- src/manifest.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index c5171ab..746fe68 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -152,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 From 0c4b852a524960cc8478438fa6b444c7aa2f0df7 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:37:22 -0500 Subject: [PATCH 021/191] claude autosave 2026-07-25T15:37:22Z --- src/manifest.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 746fe68..cc0c253 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -389,9 +389,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 From 1e1e0a52f6d65d94b48934a701b836fc7e8e3fc3 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:37:40 -0500 Subject: [PATCH 022/191] claude autosave 2026-07-25T15:37:40Z --- src/manifest.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index cc0c253..7c5a389 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -235,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), } From e51ef397e0cac4eacea732d23dc4d2dc8b4f2659 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:41:47 -0500 Subject: [PATCH 023/191] claude autosave 2026-07-25T15:41:47Z --- tests/roundtrip.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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 92fbd60f3f45a42108888bb35fe36fffc34551eb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 10:42:13 -0500 Subject: [PATCH 024/191] =?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 025/191] 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 026/191] 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 027/191] 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 ad8b6c09ee53df27f6da4a60de8b1e72abdba442 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 16:53:48 -0500 Subject: [PATCH 028/191] claude autosave 2026-07-25T21:53:48Z --- schemas/manifest.json | 24 ++++++++++++++++++++++++ schemas/publish-meta.json | 24 ++++++++++++++++++++++++ src/manifest.rs | 9 +++++++++ tests/roundtrip.rs | 10 ++++++++-- 4 files changed, 65 insertions(+), 2 deletions(-) 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" diff --git a/src/manifest.rs b/src/manifest.rs index 7c5a389..b5240d0 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)] 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 93e7cbf0b9558bced330d322a32361908a3a6b89 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 16:53:51 -0500 Subject: [PATCH 029/191] claude autosave 2026-07-25T21:53:51Z --- src/manifest.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/manifest.rs b/src/manifest.rs index b5240d0..f418d76 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -179,10 +179,19 @@ pub struct InstallSection { 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. +#[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, +} + /// 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 From c7e98629bf5502fd8a571df552897c75cfdf43a2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 16:53:59 -0500 Subject: [PATCH 030/191] claude autosave 2026-07-25T21:53:59Z --- src/manifest.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index f418d76..9ecda0e 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -175,6 +175,13 @@ 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 { From 1d32c15a492ccbcf4774ebd5a4b2016676a91145 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 16:54:19 -0500 Subject: [PATCH 031/191] claude autosave 2026-07-25T21:54:19Z --- src/manifest.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 9ecda0e..1d03fda 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -262,6 +262,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), } From b1ebd7666a28d909c59c9bbf6ff18353db801c80 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 16:54:22 -0500 Subject: [PATCH 032/191] claude autosave 2026-07-25T21:54:22Z --- src/manifest.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 1d03fda..f24737b 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -372,6 +372,28 @@ 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), + )); + } + } + if let Some(requested) = &self.install.target + && !is_target_name(requested) + { + return Err(ManifestError::InvalidTarget( + requested.clone(), + "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('.') From 1aa3bbf878e4c8c7f0f3bafaed5fd74bd497e7b2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 16:54:30 -0500 Subject: [PATCH 033/191] claude autosave 2026-07-25T21:54:30Z --- src/manifest.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index f24737b..1700cee 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -277,6 +277,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, '/'); From 871bd8d1b0a2e7864bcae2fcc50ebfe455f3cd6f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 16:54:48 -0500 Subject: [PATCH 034/191] claude autosave 2026-07-25T21:54:48Z --- src/manifest.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 1700cee..a17a31e 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -468,6 +468,56 @@ 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() + } + + /// 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 From 79b941f0b12bfffff253192fde147c8533eb60f4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 16:55:44 -0500 Subject: [PATCH 035/191] claude autosave 2026-07-25T21:55:44Z --- src/manifest.rs | 6 ++- tests/roundtrip.rs | 117 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/manifest.rs b/src/manifest.rs index a17a31e..b3c12ed 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -392,11 +392,13 @@ impl Manifest { )); } } - if let Some(requested) = &self.install.target + // 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.clone(), + requested.to_string(), "target names use [a-z0-9][a-z0-9-]*".to_string(), )); } diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs index f4aefcc..9d09217 100644 --- a/tests/roundtrip.rs +++ b/tests/roundtrip.rs @@ -133,3 +133,120 @@ 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); +} From aeb9111a4cbd88c5a757f56654e136ac6a143fdf Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 17:00:48 -0500 Subject: [PATCH 036/191] claude autosave 2026-07-25T22:00:48Z --- src/manifest.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/manifest.rs b/src/manifest.rs index b3c12ed..24752e0 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -190,13 +190,39 @@ impl InstallSection { } } -/// One ecosystem's slice of a polyglot package. +/// 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, From 9076c578b43cca7c0888336797393c025f4f1708 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 17:01:02 -0500 Subject: [PATCH 037/191] claude autosave 2026-07-25T22:01:02Z --- src/manifest.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 24752e0..3abbcf2 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -510,6 +510,54 @@ impl Manifest { !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. /// From 5491be699ff31b00d9c1b50f3455900f236da47d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 25 Jul 2026 17:01:46 -0500 Subject: [PATCH 038/191] =?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 039/191] 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 040/191] 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 041/191] 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 042/191] 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 043/191] 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 044/191] 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 045/191] 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 046/191] 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 047/191] 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 048/191] 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 049/191] 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 050/191] 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 051/191] 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 052/191] 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 053/191] 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 054/191] 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 055/191] 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 056/191] 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 057/191] 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 058/191] 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 059/191] 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 060/191] 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 061/191] 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 062/191] 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 063/191] 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 064/191] 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 065/191] 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 066/191] 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 067/191] 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 068/191] 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 069/191] 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 070/191] 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 071/191] 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 072/191] 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 073/191] 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 074/191] 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 075/191] 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 076/191] 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 077/191] 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 078/191] 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 079/191] 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 080/191] 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 081/191] 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 082/191] 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 083/191] 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 084/191] 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 085/191] 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 086/191] 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 087/191] 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 088/191] 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 089/191] 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 090/191] 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 091/191] 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 092/191] 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 093/191] 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 094/191] 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 095/191] 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 096/191] 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 097/191] 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 098/191] 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 099/191] 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 100/191] 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 101/191] 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 102/191] 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 103/191] 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 104/191] 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 105/191] 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 106/191] 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 107/191] 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 108/191] 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 109/191] 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 110/191] 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 111/191] 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 73280b80db08ce5d7c20cb142c52f0e3f9cd8014 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 01:13:59 -0500 Subject: [PATCH 112/191] feat(DEN-1420): add native registry SemVer contract --- src/native_registry.rs | 839 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 839 insertions(+) create mode 100644 src/native_registry.rs diff --git a/src/native_registry.rs b/src/native_registry.rs new file mode 100644 index 0000000..e8a4df4 --- /dev/null +++ b/src/native_registry.rs @@ -0,0 +1,839 @@ +//! Immutable publication contracts for npm, Cargo, and future native registries. +//! +//! Zed may resolve versions using its broader polyglot version model, but a +//! publication crossing a strict native-registry boundary must use one exact +//! Semantic Versioning 2.0.0 identity. Platform identity is represented by +//! explicit operating-system, architecture, and libc selectors. It is never +//! encoded in SemVer build metadata, because build metadata does not +//! participate in version precedence and some registries treat versions that +//! differ only there as the same release. + +use std::collections::{BTreeMap, BTreeSet}; + +use schemars::JsonSchema; +use semver::{BuildMetadata, Version}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::ArtifactFormat; + +/// Current immutable native-registry adapter-record schema. +pub const NATIVE_REGISTRY_ADAPTER_SCHEMA_V1: &str = "zed.native-registry-adapter/v1"; + +/// Native registries with a first-class publication identity contract. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NativeRegistry { + Npm, + Cargo, +} + +/// Role of one package in a native-registry publication family. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum NativePublicationKind { + /// One package whose payload is portable across all supported platforms. + Portable, + /// A generic wrapper package that selects platform-specific packages. + Meta, + /// One package containing bytes for exactly one explicit platform. + Platform, +} + +/// Platform identity kept outside the package version. +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +pub struct NativePlatform { + pub os: String, + pub arch: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub libc: Option, +} + +impl NativePlatform { + /// Stable human-readable selector used in diagnostics and deterministic + /// ordering. It is not a package name and does not affect version + /// precedence. + pub fn selector(&self) -> String { + match &self.libc { + Some(libc) => format!("{}-{}-{libc}", self.os, self.arch), + None => format!("{}-{}", self.os, self.arch), + } + } + + fn validate(&self, field: &str) -> Result<(), NativeRegistryError> { + validate_lower_token(&format!("{field}.os"), &self.os)?; + validate_lower_token(&format!("{field}.arch"), &self.arch)?; + if let Some(libc) = &self.libc { + validate_lower_token(&format!("{field}.libc"), libc)?; + } + Ok(()) + } +} + +/// Strict SemVer source identity in the Zed registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ZedNativePackageIdentity { + pub org: String, + pub name: String, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +impl ZedNativePackageIdentity { + fn validate(&self) -> Result<(), NativeRegistryError> { + validate_identity_component("source.org", &self.org)?; + validate_identity_component("source.name", &self.name)?; + if let Some(target) = &self.target { + validate_identity_component("source.target", target)?; + } + validate_native_version("source.version", &self.version).map(|_| ()) + } +} + +/// Public identity selected in npm or a Cargo-compatible registry. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct NativePackageIdentity { + pub name: String, + pub version: String, +} + +impl NativePackageIdentity { + fn validate( + &self, + registry: NativeRegistry, + field: &str, + ) -> Result<(), NativeRegistryError> { + validate_native_package_name(registry, &self.name)?; + validate_native_version(&format!("{field}.version"), &self.version).map(|_| ()) + } +} + +/// Exact immutable bytes published under one native package identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeArtifact { + /// Lowercase hexadecimal SHA-256 of the exact uploaded archive bytes. + pub sha256: String, + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, +} + +impl NativeArtifact { + fn validate(&self, field: &str) -> Result<(), NativeRegistryError> { + if self.sha256.len() != 64 + || !self + .sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(NativeRegistryError::InvalidSha256 { + field: format!("{field}.sha256"), + value: self.sha256.clone(), + }); + } + if self.size == 0 { + return Err(NativeRegistryError::EmptyArtifact { + field: field.to_string(), + }); + } + Ok(()) + } +} + +/// One platform-to-package edge emitted by a generic wrapper package. +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +pub struct NativePlatformPackage { + pub platform: NativePlatform, + pub package: String, +} + +impl NativePlatformPackage { + fn validate( + &self, + registry: NativeRegistry, + field: &str, + ) -> Result<(), NativeRegistryError> { + self.platform.validate(&format!("{field}.platform"))?; + validate_native_package_name(registry, &self.package) + } +} + +/// One uploaded package within a publication family. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativePublication { + pub package: NativePackageIdentity, + pub kind: NativePublicationKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform: Option, + /// Only a `meta` publication may contain selector edges. Each edge must + /// reference a `platform` publication in the same adapter record. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub platform_packages: Vec, + pub artifact: NativeArtifact, +} + +impl NativePublication { + fn normalized(&self) -> Self { + let mut publication = self.clone(); + publication.platform_packages.sort(); + publication + } + + fn validate( + &self, + registry: NativeRegistry, + source_version: &str, + index: usize, + ) -> Result<(), NativeRegistryError> { + let field = format!("publications[{index}]"); + self.package.validate(registry, &format!("{field}.package"))?; + if self.package.version != source_version { + return Err(NativeRegistryError::VersionDrift { + package: self.package.name.clone(), + source_version: source_version.to_string(), + publication_version: self.package.version.clone(), + }); + } + + match self.kind { + NativePublicationKind::Platform => { + let platform = self.platform.as_ref().ok_or_else(|| { + NativeRegistryError::PlatformRequired { + package: self.package.name.clone(), + } + })?; + platform.validate(&format!("{field}.platform"))?; + if !self.platform_packages.is_empty() { + return Err(NativeRegistryError::PlatformPackagesNotAllowed { + package: self.package.name.clone(), + kind: self.kind, + }); + } + } + NativePublicationKind::Portable | NativePublicationKind::Meta => { + if self.platform.is_some() { + return Err(NativeRegistryError::UnexpectedPlatform { + package: self.package.name.clone(), + kind: self.kind, + }); + } + if self.kind == NativePublicationKind::Portable + && !self.platform_packages.is_empty() + { + return Err(NativeRegistryError::PlatformPackagesNotAllowed { + package: self.package.name.clone(), + kind: self.kind, + }); + } + } + } + + let mut selected_platforms = BTreeSet::new(); + for (selection_index, selection) in self.platform_packages.iter().enumerate() { + selection.validate( + registry, + &format!("{field}.platform-packages[{selection_index}]"), + )?; + if !selected_platforms.insert(selection.platform.clone()) { + return Err(NativeRegistryError::DuplicateMetaPlatform { + package: self.package.name.clone(), + platform: selection.platform.selector(), + }); + } + } + self.artifact.validate(&format!("{field}.artifact")) + } +} + +/// Final immutable mapping from one Zed source target to a family of native +/// registry packages. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeRegistryAdapterRecord { + pub schema: String, + pub registry: NativeRegistry, + pub source: ZedNativePackageIdentity, + pub publications: Vec, +} + +impl NativeRegistryAdapterRecord { + pub const SCHEMA_V1: &'static str = NATIVE_REGISTRY_ADAPTER_SCHEMA_V1; + + /// Presentation-independent ordering for hashing, signing, lock + /// provenance, and generated client fixtures. + pub fn normalized(&self) -> Self { + let mut record = self.clone(); + record.publications = record + .publications + .iter() + .map(NativePublication::normalized) + .collect(); + record.publications.sort_by(|left, right| { + ( + &left.package, + left.kind, + &left.platform, + &left.platform_packages, + &left.artifact.sha256, + ) + .cmp(&( + &right.package, + right.kind, + &right.platform, + &right.platform_packages, + &right.artifact.sha256, + )) + }); + record + } + + /// Canonical compact JSON bytes. Validation runs first so invalid identity + /// cannot be made acceptable merely by normalization. + pub fn canonical_json_bytes(&self) -> Result, NativeRegistryError> { + self.validate()?; + serde_json::to_vec(&self.normalized()) + .map_err(|error| NativeRegistryError::Serialization(error.to_string())) + } + + pub fn validate(&self) -> Result<(), NativeRegistryError> { + if self.schema != Self::SCHEMA_V1 { + return Err(NativeRegistryError::UnsupportedSchema { + found: self.schema.clone(), + supported: Self::SCHEMA_V1.to_string(), + }); + } + self.source.validate()?; + if self.publications.is_empty() { + return Err(NativeRegistryError::NoPublications); + } + + let mut package_identities = BTreeSet::new(); + let mut platform_publications = BTreeMap::new(); + let mut meta_count = 0usize; + + for (index, publication) in self.publications.iter().enumerate() { + publication.validate(self.registry, &self.source.version, index)?; + let identity = ( + publication.package.name.clone(), + semver_precedence_identity(&publication.package.version)?, + ); + if !package_identities.insert(identity.clone()) { + return Err(NativeRegistryError::DuplicatePackageVersion { + package: identity.0, + version: identity.1, + }); + } + + match publication.kind { + NativePublicationKind::Meta => { + meta_count += 1; + if meta_count > 1 { + return Err(NativeRegistryError::MultipleMetaPackages); + } + } + NativePublicationKind::Platform => { + let platform = publication + .platform + .as_ref() + .expect("platform publication validated above") + .clone(); + if let Some(existing) = platform_publications.insert( + platform.clone(), + publication.package.name.clone(), + ) { + return Err(NativeRegistryError::DuplicatePlatformPublication { + platform: platform.selector(), + first: existing, + second: publication.package.name.clone(), + }); + } + } + NativePublicationKind::Portable => {} + } + } + + for publication in self + .publications + .iter() + .filter(|publication| publication.kind == NativePublicationKind::Meta) + { + for selection in &publication.platform_packages { + match platform_publications.get(&selection.platform) { + Some(package) if package == &selection.package => {} + Some(package) => { + return Err(NativeRegistryError::PlatformPackageMismatch { + meta_package: publication.package.name.clone(), + platform: selection.platform.selector(), + expected: package.clone(), + selected: selection.package.clone(), + }); + } + None => { + return Err(NativeRegistryError::MissingPlatformPublication { + meta_package: publication.package.name.clone(), + platform: selection.platform.selector(), + selected: selection.package.clone(), + }); + } + } + } + } + + Ok(()) + } +} + +/// SemVer precedence identity with build metadata removed. +/// +/// Native adapters can use this before planning a publication set to detect +/// collisions such as `1.0.0+linux` and `1.0.0+darwin`. +pub fn semver_precedence_identity(version: &str) -> Result { + let mut version = Version::parse(version).map_err(|error| NativeRegistryError::InvalidSemver { + field: "version".to_string(), + version: version.to_string(), + detail: error.to_string(), + })?; + version.build = BuildMetadata::EMPTY; + Ok(version.to_string()) +} + +/// Whether two valid SemVer strings identify the same native-registry version +/// after build metadata is ignored. +pub fn native_versions_collide( + left: &str, + right: &str, +) -> Result { + Ok(semver_precedence_identity(left)? == semver_precedence_identity(right)?) +} + +fn validate_native_version(field: &str, version: &str) -> Result { + let parsed = Version::parse(version).map_err(|error| NativeRegistryError::InvalidSemver { + field: field.to_string(), + version: version.to_string(), + detail: error.to_string(), + })?; + if parsed.build != BuildMetadata::EMPTY { + return Err(NativeRegistryError::BuildMetadataNotAllowed { + field: field.to_string(), + version: version.to_string(), + }); + } + Ok(parsed) +} + +fn validate_native_package_name( + registry: NativeRegistry, + name: &str, +) -> Result<(), NativeRegistryError> { + match registry { + NativeRegistry::Cargo => validate_cargo_name(name), + NativeRegistry::Npm => validate_npm_name(name), + } +} + +fn validate_cargo_name(name: &str) -> Result<(), NativeRegistryError> { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Cargo, + name: name.to_string(), + detail: "expected 1-64 ASCII alphanumeric, `-`, or `_` characters".to_string(), + }); + } + Ok(()) +} + +fn validate_npm_name(name: &str) -> Result<(), NativeRegistryError> { + if name.is_empty() || name.len() > 214 || name.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "expected a lowercase npm name no longer than 214 bytes".to_string(), + }); + } + + let components: Vec<&str> = if let Some(scoped) = name.strip_prefix('@') { + let mut parts = scoped.split('/'); + let scope = parts.next().unwrap_or_default(); + let package = parts.next().unwrap_or_default(); + if scope.is_empty() || package.is_empty() || parts.next().is_some() { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "scoped names must use exactly `@scope/package`".to_string(), + }); + } + vec![scope, package] + } else { + if name.contains('/') { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "unscoped names may not contain `/`".to_string(), + }); + } + vec![name] + }; + + for component in components { + let mut bytes = component.bytes(); + let first = bytes + .next() + .ok_or_else(|| NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must not be empty".to_string(), + })?; + if !first.is_ascii_lowercase() && !first.is_ascii_digit() { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must start with a lowercase letter or digit".to_string(), + }); + } + if !component.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'-' | b'_' | b'.') + }) { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components may contain lowercase letters, digits, `-`, `_`, or `." + .to_string(), + }); + } + } + Ok(()) +} + +fn validate_identity_component(field: &str, value: &str) -> Result<(), NativeRegistryError> { + if value.is_empty() + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') + }) + { + return Err(NativeRegistryError::InvalidIdentityComponent { + field: field.to_string(), + value: value.to_string(), + }); + } + Ok(()) +} + +fn validate_lower_token(field: &str, value: &str) -> Result<(), NativeRegistryError> { + if value.is_empty() + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'-' | b'_' | b'.') + }) + { + return Err(NativeRegistryError::InvalidPlatformToken { + field: field.to_string(), + value: value.to_string(), + }); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum NativeRegistryError { + #[error("unsupported native-registry adapter schema `{found}`; expected `{supported}`")] + UnsupportedSchema { found: String, supported: String }, + #[error("native-registry adapter record must contain at least one publication")] + NoPublications, + #[error("invalid native package name `{name}` for {registry:?}: {detail}")] + InvalidPackageName { + registry: NativeRegistry, + name: String, + detail: String, + }, + #[error("invalid strict SemVer `{version}` at `{field}`: {detail}")] + InvalidSemver { + field: String, + version: String, + detail: String, + }, + #[error( + "SemVer build metadata is not allowed at `{field}` (`{version}`); encode platform identity outside the version" + )] + BuildMetadataNotAllowed { field: String, version: String }, + #[error( + "native publication `{package}` uses version `{publication_version}`, but the Zed source uses `{source_version}`" + )] + VersionDrift { + package: String, + source_version: String, + publication_version: String, + }, + #[error("invalid Zed package identity component `{value}` at `{field}`")] + InvalidIdentityComponent { field: String, value: String }, + #[error("invalid lowercase platform token `{value}` at `{field}`")] + InvalidPlatformToken { field: String, value: String }, + #[error("invalid lowercase SHA-256 `{value}` at `{field}`")] + InvalidSha256 { field: String, value: String }, + #[error("artifact at `{field}` must contain at least one byte")] + EmptyArtifact { field: String }, + #[error("platform publication `{package}` requires an explicit platform selector")] + PlatformRequired { package: String }, + #[error("{kind:?} publication `{package}` may not contain a platform selector")] + UnexpectedPlatform { + package: String, + kind: NativePublicationKind, + }, + #[error("{kind:?} publication `{package}` may not select platform packages")] + PlatformPackagesNotAllowed { + package: String, + kind: NativePublicationKind, + }, + #[error("meta publication `{package}` selects platform `{platform}` more than once")] + DuplicateMetaPlatform { package: String, platform: String }, + #[error("duplicate native package identity `{package}@{version}`")] + DuplicatePackageVersion { package: String, version: String }, + #[error("one adapter record may contain at most one meta package")] + MultipleMetaPackages, + #[error("platform `{platform}` is published twice by `{first}` and `{second}`")] + DuplicatePlatformPublication { + platform: String, + first: String, + second: String, + }, + #[error( + "meta package `{meta_package}` selects `{selected}` for `{platform}`, but the record publishes `{expected}`" + )] + PlatformPackageMismatch { + meta_package: String, + platform: String, + expected: String, + selected: String, + }, + #[error( + "meta package `{meta_package}` selects missing platform package `{selected}` for `{platform}`" + )] + MissingPlatformPublication { + meta_package: String, + platform: String, + selected: String, + }, + #[error("failed to serialize native-registry adapter record: {0}")] + Serialization(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn platform(os: &str, arch: &str, libc: Option<&str>) -> NativePlatform { + NativePlatform { + os: os.to_string(), + arch: arch.to_string(), + libc: libc.map(str::to_string), + } + } + + fn artifact(byte: char) -> NativeArtifact { + NativeArtifact { + sha256: std::iter::repeat_n(byte, 64).collect(), + size: 128, + format: ArtifactFormat::TarGz, + } + } + + fn publication( + name: &str, + kind: NativePublicationKind, + platform: Option, + byte: char, + ) -> NativePublication { + NativePublication { + package: NativePackageIdentity { + name: name.to_string(), + version: "1.2.3".to_string(), + }, + kind, + platform, + platform_packages: Vec::new(), + artifact: artifact(byte), + } + } + + fn record() -> NativeRegistryAdapterRecord { + let linux = platform("linux", "arm64", Some("musl")); + let darwin = platform("darwin", "arm64", None); + let mut meta = publication( + "@fiducia/core", + NativePublicationKind::Meta, + None, + 'a', + ); + meta.platform_packages = vec![ + NativePlatformPackage { + platform: linux.clone(), + package: "@fiducia/core-linux-arm64-musl".to_string(), + }, + NativePlatformPackage { + platform: darwin.clone(), + package: "@fiducia/core-darwin-arm64".to_string(), + }, + ]; + NativeRegistryAdapterRecord { + schema: NATIVE_REGISTRY_ADAPTER_SCHEMA_V1.to_string(), + registry: NativeRegistry::Npm, + source: ZedNativePackageIdentity { + org: "fiducia".to_string(), + name: "core".to_string(), + version: "1.2.3".to_string(), + target: Some("node".to_string()), + }, + publications: vec![ + publication( + "@fiducia/core-linux-arm64-musl", + NativePublicationKind::Platform, + Some(linux), + 'b', + ), + meta, + publication( + "@fiducia/core-darwin-arm64", + NativePublicationKind::Platform, + Some(darwin), + 'c', + ), + ], + } + } + + #[test] + fn valid_multi_platform_record_is_canonical() { + let mut reversed = record(); + reversed.publications.reverse(); + for publication in &mut reversed.publications { + publication.platform_packages.reverse(); + } + + assert!(record().validate().is_ok()); + assert_eq!( + record().canonical_json_bytes().unwrap(), + reversed.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn build_metadata_collides_and_is_rejected_for_publication() { + assert!(native_versions_collide("1.2.3+linux", "1.2.3+darwin").unwrap()); + assert!(!native_versions_collide("1.2.3-rc.1", "1.2.3").unwrap()); + + let mut record = record(); + record.source.version = "1.2.3+linux".to_string(); + for publication in &mut record.publications { + publication.package.version = "1.2.3+linux".to_string(); + } + assert!(matches!( + record.validate(), + Err(NativeRegistryError::BuildMetadataNotAllowed { .. }) + )); + } + + #[test] + fn platform_identity_cannot_be_smuggled_into_a_portable_publication() { + let mut record = record(); + let meta = record + .publications + .iter_mut() + .find(|publication| publication.kind == NativePublicationKind::Meta) + .unwrap(); + meta.platform = Some(platform("linux", "x64", Some("gnu"))); + assert!(matches!( + record.validate(), + Err(NativeRegistryError::UnexpectedPlatform { .. }) + )); + } + + #[test] + fn meta_edges_must_reference_the_exact_platform_publication() { + let mut record = record(); + let meta = record + .publications + .iter_mut() + .find(|publication| publication.kind == NativePublicationKind::Meta) + .unwrap(); + meta.platform_packages[0].package = "@fiducia/not-published".to_string(); + assert!(matches!( + record.validate(), + Err(NativeRegistryError::PlatformPackageMismatch { .. }) + )); + } + + #[test] + fn duplicate_platforms_and_precedence_identities_fail_closed() { + let mut duplicate_platform = record(); + let first_platform = duplicate_platform + .publications + .iter() + .find(|publication| publication.kind == NativePublicationKind::Platform) + .unwrap() + .platform + .clone(); + let mut extra = publication( + "@fiducia/core-another", + NativePublicationKind::Platform, + first_platform, + 'd', + ); + extra.package.version = "1.2.3".to_string(); + duplicate_platform.publications.push(extra); + assert!(matches!( + duplicate_platform.validate(), + Err(NativeRegistryError::DuplicatePlatformPublication { .. }) + )); + + let mut duplicate_package = record(); + let duplicate = duplicate_package.publications[0].clone(); + duplicate_package.publications.push(duplicate); + assert!(matches!( + duplicate_package.validate(), + Err(NativeRegistryError::DuplicatePackageVersion { .. }) + )); + } + + #[test] + fn cargo_and_npm_names_use_conservative_portable_subsets() { + assert!(validate_native_package_name(NativeRegistry::Cargo, "fiducia_core-sys").is_ok()); + assert!(validate_native_package_name(NativeRegistry::Cargo, "bad/name").is_err()); + assert!( + validate_native_package_name(NativeRegistry::Npm, "@fiducia/core-linux-x64").is_ok() + ); + assert!(validate_native_package_name(NativeRegistry::Npm, "@Fiducia/core").is_err()); + assert!(validate_native_package_name(NativeRegistry::Npm, "../core").is_err()); + } + + #[test] + fn artifact_and_version_drift_are_rejected() { + let mut record = record(); + record.publications[0].artifact.sha256 = "A".repeat(64); + assert!(matches!( + record.validate(), + Err(NativeRegistryError::InvalidSha256 { .. }) + )); + + let mut record = record(); + record.publications[0].package.version = "1.2.4".to_string(); + assert!(matches!( + record.validate(), + Err(NativeRegistryError::VersionDrift { .. }) + )); + } +} From a05267d599eb8f3906ba331a3e80d697564de856 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 01:14:27 -0500 Subject: [PATCH 113/191] feat(DEN-1420): export native registry contract --- src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 3aa22e3..0bd6d1f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod excludes; pub mod language; pub mod lockfile; pub mod manifest; +pub mod native_registry; pub mod nix; pub mod paths; pub mod registry; @@ -29,6 +30,12 @@ pub use environment::{ pub use language::{Ecosystem, Language, detect_ecosystems}; pub use lockfile::{LockedPackage, Lockfile, LockfileError}; pub use manifest::{Manifest, ManifestError, NixExportRoute}; +pub use native_registry::{ + NATIVE_REGISTRY_ADAPTER_SCHEMA_V1, NativeArtifact, NativePackageIdentity, NativePlatform, + NativePlatformPackage, NativePublication, NativePublicationKind, NativeRegistry, + NativeRegistryAdapterRecord, NativeRegistryError, ZedNativePackageIdentity, + native_versions_collide, semver_precedence_identity, +}; pub use nix::{ NIX_ADAPTER_SCHEMA_V1, NixAdapterRecord, NixBuilderNetwork, NixExportMode, NixExportSection, NixInteropArtifact, NixInteropError, NixOutputOrigin, NixPackageIdentity, NixPolicyEvidence, From eda28a1a612d435e40a341a2fee6f5337c73c4e1 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 01:14:50 -0500 Subject: [PATCH 114/191] feat(DEN-1420): generate native registry schema --- examples/generate_schemas.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 047a133..9ca93b6 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -24,6 +24,10 @@ fn main() { write::(dir, "lockfile"); write::(dir, "nix-export-section"); write::(dir, "nix-adapter-record"); + write::( + dir, + "native-registry-adapter-record", + ); write::(dir, "package-metadata"); write::(dir, "version-metadata"); write::(dir, "publish-meta"); From a207c5bbf464391c53ecbc6a2978e86c1d0846a4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 01:15:14 -0500 Subject: [PATCH 115/191] test(DEN-1420): pin native registry schema contract --- tests/native_registry_schema_contract.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/native_registry_schema_contract.rs diff --git a/tests/native_registry_schema_contract.rs b/tests/native_registry_schema_contract.rs new file mode 100644 index 0000000..2ca57a6 --- /dev/null +++ b/tests/native_registry_schema_contract.rs @@ -0,0 +1,21 @@ +use schemars::schema_for; +use serde_json::Value; +use zed_interfaces::NativeRegistryAdapterRecord; + +const NATIVE_REGISTRY_ADAPTER_SCHEMA: &str = + include_str!("../schemas/native-registry-adapter-record.json"); + +#[test] +fn checked_in_native_registry_schema_matches_the_public_contract() { + let checked_in: Value = serde_json::from_str(NATIVE_REGISTRY_ADAPTER_SCHEMA) + .expect("checked-in native-registry schema must parse"); + let generated = serde_json::to_value(schema_for!(NativeRegistryAdapterRecord)).unwrap(); + assert_eq!(checked_in, generated); + + let text = NATIVE_REGISTRY_ADAPTER_SCHEMA; + assert!(text.contains("zed.native-registry-adapter/v1")); + assert!(text.contains("platform_packages")); + assert!(text.contains("sha256")); + assert!(text.contains("npm")); + assert!(text.contains("cargo")); +} From 004eeccdb4112e5c9d4e3b0cb7bb6ee1f7587a82 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 01:15:27 -0500 Subject: [PATCH 116/191] docs(DEN-1420): document native registry publication identity --- docs/native-registry-contract.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 docs/native-registry-contract.md diff --git a/docs/native-registry-contract.md b/docs/native-registry-contract.md new file mode 100644 index 0000000..4a4cfaf --- /dev/null +++ b/docs/native-registry-contract.md @@ -0,0 +1,30 @@ +# Native registry publication contract + +`NativeRegistryAdapterRecord` binds one strict-SemVer Zed package target to the +exact immutable archives published to npm or a Cargo-compatible registry. + +The contract deliberately separates three identities: + +- **API compatibility:** `MAJOR.MINOR.PATCH[-prerelease]`; +- **platform:** explicit `os`, `arch`, and optional `libc` selectors; and +- **bytes:** lowercase SHA-256 plus archive size and format. + +SemVer build metadata is rejected at this boundary. It does not participate in +SemVer precedence, and Cargo registry indexes explicitly treat versions that +differ only in build metadata as one version. Architecture-specific artifacts +therefore use distinct package names or manager-native platform selectors while +sharing one strict version. + +A publication family may contain: + +- one portable package; +- one generic meta package whose platform edges reference packages in the same + record; and +- one package for each unique platform selector. + +Validation fails closed on version drift, duplicate package identities, +duplicate platforms, dangling or mismatched meta-package edges, malformed +native package names, zero-byte artifacts, uppercase or malformed digests, and +unsupported schema versions. `canonical_json_bytes()` validates first and then +sorts publications and platform edges for deterministic signing and lockfile +provenance. From 3f00233cab35b177afaaf70ab7a8d8394c95c4c5 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 01:16:01 -0500 Subject: [PATCH 117/191] ci(DEN-1420): validate and materialize native registry contract --- .../bootstrap-native-registry-contract.yml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/bootstrap-native-registry-contract.yml diff --git a/.github/workflows/bootstrap-native-registry-contract.yml b/.github/workflows/bootstrap-native-registry-contract.yml new file mode 100644 index 0000000..6ae3360 --- /dev/null +++ b/.github/workflows/bootstrap-native-registry-contract.yml @@ -0,0 +1,60 @@ +name: Bootstrap native registry contract + +on: + push: + branches: + - agent/native-registry-semver-contract + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: bootstrap-native-registry-contract + cancel-in-progress: false + +jobs: + validate-and-materialize: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Check out exact branch head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Install stable Rust with formatter and Clippy + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Generate checked-in schemas + run: | + set -euo pipefail + cargo run --locked --example generate_schemas + + - name: Format and validate the complete crate + run: | + set -euo pipefail + cargo fmt --all + cargo test --locked --all-targets + cargo clippy --locked --all-targets --all-features -- -D warnings + git diff --check + + - name: Commit only the reviewed product result + run: | + set -euo pipefail + git rm .github/workflows/bootstrap-native-registry-contract.yml + git add src/native_registry.rs src/lib.rs examples/generate_schemas.rs \ + schemas/native-registry-adapter-record.json \ + tests/native_registry_schema_contract.rs \ + docs/native-registry-contract.md + git diff --cached --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "feat(DEN-1420): materialize native registry contract" + git push origin HEAD:agent/native-registry-semver-contract From 1305eaf7c176d2468364162871a111ffa41cacf0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 01:18:37 -0500 Subject: [PATCH 118/191] test(DEN-1420): assert emitted native registry schema structure --- tests/native_registry_schema_contract.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/native_registry_schema_contract.rs b/tests/native_registry_schema_contract.rs index 2ca57a6..4266928 100644 --- a/tests/native_registry_schema_contract.rs +++ b/tests/native_registry_schema_contract.rs @@ -13,7 +13,6 @@ fn checked_in_native_registry_schema_matches_the_public_contract() { assert_eq!(checked_in, generated); let text = NATIVE_REGISTRY_ADAPTER_SCHEMA; - assert!(text.contains("zed.native-registry-adapter/v1")); assert!(text.contains("platform_packages")); assert!(text.contains("sha256")); assert!(text.contains("npm")); From 8a9b51f63e682edaf8f709db4a679a3a493fe334 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 01:19:41 -0500 Subject: [PATCH 119/191] ci(DEN-1420): expose bootstrap on pull requests --- .../workflows/bootstrap-native-registry-contract.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bootstrap-native-registry-contract.yml b/.github/workflows/bootstrap-native-registry-contract.yml index 6ae3360..d055252 100644 --- a/.github/workflows/bootstrap-native-registry-contract.yml +++ b/.github/workflows/bootstrap-native-registry-contract.yml @@ -4,6 +4,13 @@ on: push: branches: - agent/native-registry-semver-contract + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened workflow_dispatch: permissions: @@ -11,7 +18,7 @@ permissions: concurrency: group: bootstrap-native-registry-contract - cancel-in-progress: false + cancel-in-progress: true jobs: validate-and-materialize: @@ -21,6 +28,7 @@ jobs: - name: Check out exact branch head uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: agent/native-registry-semver-contract fetch-depth: 0 persist-credentials: true From 70cb3d4eac8b7d365948183b7572da0e96a94db4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 01:43:09 -0500 Subject: [PATCH 120/191] ci(DEN-1420): fix and materialize native registry contract --- .../bootstrap-native-registry-contract.yml | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bootstrap-native-registry-contract.yml b/.github/workflows/bootstrap-native-registry-contract.yml index d055252..715b6b1 100644 --- a/.github/workflows/bootstrap-native-registry-contract.yml +++ b/.github/workflows/bootstrap-native-registry-contract.yml @@ -22,7 +22,7 @@ concurrency: jobs: validate-and-materialize: - runs-on: ubuntu-24.04 + runs-on: ubuntu-22.04 timeout-minutes: 30 steps: - name: Check out exact branch head @@ -40,6 +40,53 @@ jobs: rustc --version --verbose cargo --version --verbose + - name: Apply exact test and diagnostic corrections + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path("src/native_registry.rs") + source = path.read_text(encoding="utf-8") + + old_test = ''' let mut record = record(); + record.publications[0].artifact.sha256 = "A".repeat(64); + assert!(matches!( + record.validate(), + Err(NativeRegistryError::InvalidSha256 { .. }) + )); + + let mut record = record(); + record.publications[0].package.version = "1.2.4".to_string(); + assert!(matches!( + record.validate(), + Err(NativeRegistryError::VersionDrift { .. }) + ));''' + new_test = ''' let mut invalid_artifact = record(); + invalid_artifact.publications[0].artifact.sha256 = "A".repeat(64); + assert!(matches!( + invalid_artifact.validate(), + Err(NativeRegistryError::InvalidSha256 { .. }) + )); + + let mut version_drift = record(); + version_drift.publications[0].package.version = "1.2.4".to_string(); + assert!(matches!( + version_drift.validate(), + Err(NativeRegistryError::VersionDrift { .. }) + ));''' + if old_test not in source: + raise SystemExit("expected shadowing test block was not found") + source = source.replace(old_test, new_test, 1) + + old_detail = 'detail: "name components may contain lowercase letters, digits, `-`, `_`, or `."\n .to_string(),' + new_detail = 'detail: "name components may contain lowercase letters, digits, `-`, `_`, or `.`"\n .to_string(),' + if old_detail not in source: + raise SystemExit("expected npm diagnostic was not found") + source = source.replace(old_detail, new_detail, 1) + path.write_text(source, encoding="utf-8") + PY + - name: Generate checked-in schemas run: | set -euo pipefail From 8bcbcbe9377760c9a8843b75daff54e92c1bf6d2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:44:32 +0000 Subject: [PATCH 121/191] feat(DEN-1420): materialize native registry contract --- .../bootstrap-native-registry-contract.yml | 115 ---------- examples/generate_schemas.rs | 5 +- schemas/native-registry-adapter-record.json | 207 ++++++++++++++++++ src/native_registry.rs | 77 +++---- 4 files changed, 236 insertions(+), 168 deletions(-) delete mode 100644 .github/workflows/bootstrap-native-registry-contract.yml create mode 100644 schemas/native-registry-adapter-record.json diff --git a/.github/workflows/bootstrap-native-registry-contract.yml b/.github/workflows/bootstrap-native-registry-contract.yml deleted file mode 100644 index 715b6b1..0000000 --- a/.github/workflows/bootstrap-native-registry-contract.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Bootstrap native registry contract - -on: - push: - branches: - - agent/native-registry-semver-contract - pull_request: - branches: - - main - types: - - opened - - synchronize - - reopened - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: bootstrap-native-registry-contract - cancel-in-progress: true - -jobs: - validate-and-materialize: - runs-on: ubuntu-22.04 - timeout-minutes: 30 - steps: - - name: Check out exact branch head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/native-registry-semver-contract - fetch-depth: 0 - persist-credentials: true - - - name: Install stable Rust with formatter and Clippy - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - rustc --version --verbose - cargo --version --verbose - - - name: Apply exact test and diagnostic corrections - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - path = Path("src/native_registry.rs") - source = path.read_text(encoding="utf-8") - - old_test = ''' let mut record = record(); - record.publications[0].artifact.sha256 = "A".repeat(64); - assert!(matches!( - record.validate(), - Err(NativeRegistryError::InvalidSha256 { .. }) - )); - - let mut record = record(); - record.publications[0].package.version = "1.2.4".to_string(); - assert!(matches!( - record.validate(), - Err(NativeRegistryError::VersionDrift { .. }) - ));''' - new_test = ''' let mut invalid_artifact = record(); - invalid_artifact.publications[0].artifact.sha256 = "A".repeat(64); - assert!(matches!( - invalid_artifact.validate(), - Err(NativeRegistryError::InvalidSha256 { .. }) - )); - - let mut version_drift = record(); - version_drift.publications[0].package.version = "1.2.4".to_string(); - assert!(matches!( - version_drift.validate(), - Err(NativeRegistryError::VersionDrift { .. }) - ));''' - if old_test not in source: - raise SystemExit("expected shadowing test block was not found") - source = source.replace(old_test, new_test, 1) - - old_detail = 'detail: "name components may contain lowercase letters, digits, `-`, `_`, or `."\n .to_string(),' - new_detail = 'detail: "name components may contain lowercase letters, digits, `-`, `_`, or `.`"\n .to_string(),' - if old_detail not in source: - raise SystemExit("expected npm diagnostic was not found") - source = source.replace(old_detail, new_detail, 1) - path.write_text(source, encoding="utf-8") - PY - - - name: Generate checked-in schemas - run: | - set -euo pipefail - cargo run --locked --example generate_schemas - - - name: Format and validate the complete crate - run: | - set -euo pipefail - cargo fmt --all - cargo test --locked --all-targets - cargo clippy --locked --all-targets --all-features -- -D warnings - git diff --check - - - name: Commit only the reviewed product result - run: | - set -euo pipefail - git rm .github/workflows/bootstrap-native-registry-contract.yml - git add src/native_registry.rs src/lib.rs examples/generate_schemas.rs \ - schemas/native-registry-adapter-record.json \ - tests/native_registry_schema_contract.rs \ - docs/native-registry-contract.md - git diff --cached --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "feat(DEN-1420): materialize native registry contract" - git push origin HEAD:agent/native-registry-semver-contract diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 9ca93b6..72993a1 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -24,10 +24,7 @@ fn main() { write::(dir, "lockfile"); write::(dir, "nix-export-section"); write::(dir, "nix-adapter-record"); - write::( - dir, - "native-registry-adapter-record", - ); + write::(dir, "native-registry-adapter-record"); write::(dir, "package-metadata"); write::(dir, "version-metadata"); write::(dir, "publish-meta"); diff --git a/schemas/native-registry-adapter-record.json b/schemas/native-registry-adapter-record.json new file mode 100644 index 0000000..aebd126 --- /dev/null +++ b/schemas/native-registry-adapter-record.json @@ -0,0 +1,207 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "NativeRegistryAdapterRecord", + "description": "Final immutable mapping from one Zed source target to a family of native\nregistry packages.", + "type": "object", + "properties": { + "publications": { + "type": "array", + "items": { + "$ref": "#/$defs/NativePublication" + } + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + }, + "schema": { + "type": "string" + }, + "source": { + "$ref": "#/$defs/ZedNativePackageIdentity" + } + }, + "required": [ + "schema", + "registry", + "source", + "publications" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "NativeArtifact": { + "description": "Exact immutable bytes published under one native package identity.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "sha256": { + "description": "Lowercase hexadecimal SHA-256 of the exact uploaded archive bytes.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size" + ] + }, + "NativePackageIdentity": { + "description": "Public identity selected in npm or a Cargo-compatible registry.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ] + }, + "NativePlatform": { + "description": "Platform identity kept outside the package version.", + "type": "object", + "properties": { + "arch": { + "type": "string" + }, + "libc": { + "type": [ + "string", + "null" + ] + }, + "os": { + "type": "string" + } + }, + "required": [ + "os", + "arch" + ] + }, + "NativePlatformPackage": { + "description": "One platform-to-package edge emitted by a generic wrapper package.", + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "platform": { + "$ref": "#/$defs/NativePlatform" + } + }, + "required": [ + "platform", + "package" + ] + }, + "NativePublication": { + "description": "One uploaded package within a publication family.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NativeArtifact" + }, + "kind": { + "$ref": "#/$defs/NativePublicationKind" + }, + "package": { + "$ref": "#/$defs/NativePackageIdentity" + }, + "platform": { + "anyOf": [ + { + "$ref": "#/$defs/NativePlatform" + }, + { + "type": "null" + } + ] + }, + "platform_packages": { + "description": "Only a `meta` publication may contain selector edges. Each edge must\nreference a `platform` publication in the same adapter record.", + "type": "array", + "items": { + "$ref": "#/$defs/NativePlatformPackage" + } + } + }, + "required": [ + "package", + "kind", + "artifact" + ] + }, + "NativePublicationKind": { + "description": "Role of one package in a native-registry publication family.", + "oneOf": [ + { + "description": "One package whose payload is portable across all supported platforms.", + "type": "string", + "const": "portable" + }, + { + "description": "A generic wrapper package that selects platform-specific packages.", + "type": "string", + "const": "meta" + }, + { + "description": "One package containing bytes for exactly one explicit platform.", + "type": "string", + "const": "platform" + } + ] + }, + "NativeRegistry": { + "description": "Native registries with a first-class publication identity contract.", + "type": "string", + "enum": [ + "npm", + "cargo" + ] + }, + "ZedNativePackageIdentity": { + "description": "Strict SemVer source identity in the Zed registry.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version" + ] + } + } +} diff --git a/src/native_registry.rs b/src/native_registry.rs index e8a4df4..78aaa01 100644 --- a/src/native_registry.rs +++ b/src/native_registry.rs @@ -105,11 +105,7 @@ pub struct NativePackageIdentity { } impl NativePackageIdentity { - fn validate( - &self, - registry: NativeRegistry, - field: &str, - ) -> Result<(), NativeRegistryError> { + fn validate(&self, registry: NativeRegistry, field: &str) -> Result<(), NativeRegistryError> { validate_native_package_name(registry, &self.name)?; validate_native_version(&format!("{field}.version"), &self.version).map(|_| ()) } @@ -148,20 +144,14 @@ impl NativeArtifact { } /// One platform-to-package edge emitted by a generic wrapper package. -#[derive( - Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, -)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] pub struct NativePlatformPackage { pub platform: NativePlatform, pub package: String, } impl NativePlatformPackage { - fn validate( - &self, - registry: NativeRegistry, - field: &str, - ) -> Result<(), NativeRegistryError> { + fn validate(&self, registry: NativeRegistry, field: &str) -> Result<(), NativeRegistryError> { self.platform.validate(&format!("{field}.platform"))?; validate_native_package_name(registry, &self.package) } @@ -195,7 +185,8 @@ impl NativePublication { index: usize, ) -> Result<(), NativeRegistryError> { let field = format!("publications[{index}]"); - self.package.validate(registry, &format!("{field}.package"))?; + self.package + .validate(registry, &format!("{field}.package"))?; if self.package.version != source_version { return Err(NativeRegistryError::VersionDrift { package: self.package.name.clone(), @@ -345,10 +336,9 @@ impl NativeRegistryAdapterRecord { .as_ref() .expect("platform publication validated above") .clone(); - if let Some(existing) = platform_publications.insert( - platform.clone(), - publication.package.name.clone(), - ) { + if let Some(existing) = platform_publications + .insert(platform.clone(), publication.package.name.clone()) + { return Err(NativeRegistryError::DuplicatePlatformPublication { platform: platform.selector(), first: existing, @@ -396,21 +386,19 @@ impl NativeRegistryAdapterRecord { /// Native adapters can use this before planning a publication set to detect /// collisions such as `1.0.0+linux` and `1.0.0+darwin`. pub fn semver_precedence_identity(version: &str) -> Result { - let mut version = Version::parse(version).map_err(|error| NativeRegistryError::InvalidSemver { - field: "version".to_string(), - version: version.to_string(), - detail: error.to_string(), - })?; + let mut version = + Version::parse(version).map_err(|error| NativeRegistryError::InvalidSemver { + field: "version".to_string(), + version: version.to_string(), + detail: error.to_string(), + })?; version.build = BuildMetadata::EMPTY; Ok(version.to_string()) } /// Whether two valid SemVer strings identify the same native-registry version /// after build metadata is ignored. -pub fn native_versions_collide( - left: &str, - right: &str, -) -> Result { +pub fn native_versions_collide(left: &str, right: &str) -> Result { Ok(semver_precedence_identity(left)? == semver_precedence_identity(right)?) } @@ -504,14 +492,12 @@ fn validate_npm_name(name: &str) -> Result<(), NativeRegistryError> { }); } if !component.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'.') }) { return Err(NativeRegistryError::InvalidPackageName { registry: NativeRegistry::Npm, name: name.to_string(), - detail: "name components may contain lowercase letters, digits, `-`, `_`, or `." + detail: "name components may contain lowercase letters, digits, `-`, `_`, or `.`" .to_string(), }); } @@ -521,9 +507,9 @@ fn validate_npm_name(name: &str) -> Result<(), NativeRegistryError> { fn validate_identity_component(field: &str, value: &str) -> Result<(), NativeRegistryError> { if value.is_empty() - || !value.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') - }) + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) { return Err(NativeRegistryError::InvalidIdentityComponent { field: field.to_string(), @@ -536,9 +522,7 @@ fn validate_identity_component(field: &str, value: &str) -> Result<(), NativeReg fn validate_lower_token(field: &str, value: &str) -> Result<(), NativeRegistryError> { if value.is_empty() || !value.bytes().all(|byte| { - byte.is_ascii_lowercase() - || byte.is_ascii_digit() - || matches!(byte, b'-' | b'_' | b'.') + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') }) { return Err(NativeRegistryError::InvalidPlatformToken { @@ -673,12 +657,7 @@ mod tests { fn record() -> NativeRegistryAdapterRecord { let linux = platform("linux", "arm64", Some("musl")); let darwin = platform("darwin", "arm64", None); - let mut meta = publication( - "@fiducia/core", - NativePublicationKind::Meta, - None, - 'a', - ); + let mut meta = publication("@fiducia/core", NativePublicationKind::Meta, None, 'a'); meta.platform_packages = vec![ NativePlatformPackage { platform: linux.clone(), @@ -822,17 +801,17 @@ mod tests { #[test] fn artifact_and_version_drift_are_rejected() { - let mut record = record(); - record.publications[0].artifact.sha256 = "A".repeat(64); + let mut invalid_artifact = record(); + invalid_artifact.publications[0].artifact.sha256 = "A".repeat(64); assert!(matches!( - record.validate(), + invalid_artifact.validate(), Err(NativeRegistryError::InvalidSha256 { .. }) )); - let mut record = record(); - record.publications[0].package.version = "1.2.4".to_string(); + let mut version_drift = record(); + version_drift.publications[0].package.version = "1.2.4".to_string(); assert!(matches!( - record.validate(), + version_drift.validate(), Err(NativeRegistryError::VersionDrift { .. }) )); } From 6e893ad0f28ccfbb7722f007d75e88548f1bcfdf Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 10:46:35 -0500 Subject: [PATCH 122/191] 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 123/191] 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 124/191] 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 125/191] 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 126/191] 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 127/191] 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 128/191] 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 129/191] 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 130/191] 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 131/191] 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 132/191] 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 133/191] 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 134/191] 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 135/191] 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 136/191] 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 137/191] 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 138/191] 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 139/191] 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 140/191] 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 141/191] 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 142/191] 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 143/191] 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 85ee9302703efdfd9d1b8720db3d7ff2a2b725d1 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:12:12 -0500 Subject: [PATCH 144/191] feat(DEN-1507): add native dependency range and lock contract --- src/native_dependency.rs | 881 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 881 insertions(+) create mode 100644 src/native_dependency.rs diff --git a/src/native_dependency.rs b/src/native_dependency.rs new file mode 100644 index 0000000..cde6ca1 --- /dev/null +++ b/src/native_dependency.rs @@ -0,0 +1,881 @@ +//! Lossless native-registry requirement translation and exact dependency locks. +//! +//! Native ecosystems do not assign identical semantics to every identical- +//! looking requirement. In npm, `1.2.3` is exact and `1.2` is an x-range. In +//! Cargo, both are caret-compatible requirements. Zed therefore records the +//! source registry, original declaration, and deterministic canonical SemVer +//! requirement before binding one exact resolved version to immutable artifact +//! identity. + +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use semver::{BuildMetadata, Version, VersionReq}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{NativeArtifact, NativePackageIdentity, NativeRegistry}; + +/// Current schema for one exact native dependency resolution. +pub const NATIVE_DEPENDENCY_LOCK_SCHEMA_V1: &str = "zed.native-dependency-lock/v1"; + +const MAX_REQUIREMENT_LEN: usize = 512; + +/// One source-aware native requirement and its canonical SemVer translation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NativeVersionRequirement { + pub registry: NativeRegistry, + /// Exact project declaration before translation. + pub declared: String, + /// Deterministic `semver::VersionReq` representation used by Zed. + pub canonical: String, +} + +impl NativeVersionRequirement { + /// Translate the lossless v1 subset for npm or Cargo. + pub fn parse( + registry: NativeRegistry, + declared: impl Into, + ) -> Result { + let declared = declared.into(); + validate_requirement_input(registry, &declared)?; + let requirement = match registry { + NativeRegistry::Npm => translate_npm_requirement(&declared)?, + NativeRegistry::Cargo => translate_cargo_requirement(&declared)?, + }; + Ok(Self { + registry, + declared, + canonical: requirement.to_string(), + }) + } + + /// Recompute the canonical requirement so serialized translation receipts + /// cannot be edited independently from their source declaration. + pub fn validate(&self) -> Result<(), NativeDependencyError> { + let translated = Self::parse(self.registry, self.declared.clone())?; + if translated.canonical != self.canonical { + return Err(NativeDependencyError::CanonicalRequirementDrift { + declared: self.declared.clone(), + expected: translated.canonical, + found: self.canonical.clone(), + }); + } + Ok(()) + } + + /// Test one strict native version against the translated requirement. + pub fn matches(&self, version: &str) -> Result { + self.validate()?; + let requirement = parse_canonical_requirement(self.registry, &self.canonical)?; + let version = parse_strict_version("version", version)?; + Ok(requirement.matches(&version)) + } + + fn parsed(&self) -> Result { + self.validate()?; + parse_canonical_requirement(self.registry, &self.canonical) + } +} + +/// One eligible version and immutable artifact returned by a native registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NativeVersionCandidate { + pub version: String, + pub artifact: NativeArtifact, +} + +/// Frozen native dependency identity. The declaration remains auditable, while +/// `package.version` and `artifact` are the only restore-time identities. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NativeDependencyLock { + pub schema: String, + pub requirement: NativeVersionRequirement, + pub package: NativePackageIdentity, + pub artifact: NativeArtifact, +} + +impl NativeDependencyLock { + pub const SCHEMA_V1: &'static str = NATIVE_DEPENDENCY_LOCK_SCHEMA_V1; + + /// Resolve the highest satisfying candidate independent of candidate + /// presentation order. Callers must prefilter registry policy such as + /// yanked versions; this function validates every supplied identity. + pub fn resolve( + registry: NativeRegistry, + package_name: impl Into, + declared_requirement: impl Into, + candidates: &[NativeVersionCandidate], + ) -> Result { + let package_name = package_name.into(); + validate_native_package_name(registry, &package_name)?; + let requirement = NativeVersionRequirement::parse(registry, declared_requirement)?; + let parsed_requirement = requirement.parsed()?; + + let mut seen = BTreeSet::new(); + let mut selected: Option<(&NativeVersionCandidate, Version)> = None; + for candidate in candidates { + let version = parse_strict_version("candidate.version", &candidate.version)?; + validate_native_artifact(&candidate.artifact, "candidate.artifact")?; + if !seen.insert(version.clone()) { + return Err(NativeDependencyError::DuplicateCandidateVersion { + version: candidate.version.clone(), + }); + } + if parsed_requirement.matches(&version) + && selected + .as_ref() + .is_none_or(|(_, current)| version > *current) + { + selected = Some((candidate, version)); + } + } + + let (candidate, _) = selected.ok_or_else(|| NativeDependencyError::NoMatchingVersion { + registry, + package: package_name.clone(), + requirement: requirement.canonical.clone(), + })?; + + let lock = Self { + schema: Self::SCHEMA_V1.to_string(), + requirement, + package: NativePackageIdentity { + name: package_name, + version: candidate.version.clone(), + }, + artifact: candidate.artifact.clone(), + }; + lock.validate()?; + Ok(lock) + } + + pub fn validate(&self) -> Result<(), NativeDependencyError> { + if self.schema != Self::SCHEMA_V1 { + return Err(NativeDependencyError::UnsupportedSchema { + found: self.schema.clone(), + supported: Self::SCHEMA_V1.to_string(), + }); + } + self.requirement.validate()?; + validate_native_package_name(self.requirement.registry, &self.package.name)?; + let resolved = parse_strict_version("package.version", &self.package.version)?; + let requirement = self.requirement.parsed()?; + if !requirement.matches(&resolved) { + return Err(NativeDependencyError::ResolvedVersionDoesNotMatch { + package: self.package.name.clone(), + version: self.package.version.clone(), + requirement: self.requirement.canonical.clone(), + }); + } + validate_native_artifact(&self.artifact, "artifact") + } + + /// Stable compact JSON for lock provenance, signatures, and generated + /// client fixtures. + pub fn canonical_json_bytes(&self) -> Result, NativeDependencyError> { + self.validate()?; + serde_json::to_vec(self) + .map_err(|error| NativeDependencyError::Serialization(error.to_string())) + } +} + +fn validate_requirement_input( + registry: NativeRegistry, + declared: &str, +) -> Result<(), NativeDependencyError> { + if declared.is_empty() { + return Err(NativeDependencyError::EmptyRequirement { registry }); + } + if declared.len() > MAX_REQUIREMENT_LEN { + return Err(NativeDependencyError::RequirementTooLong { + registry, + length: declared.len(), + maximum: MAX_REQUIREMENT_LEN, + }); + } + if declared != declared.trim() { + return Err(NativeDependencyError::SurroundingWhitespace { + registry, + declared: declared.to_string(), + }); + } + if declared.chars().any(char::is_control) { + return Err(NativeDependencyError::ControlCharacter { + registry, + declared: declared.to_string(), + }); + } + if declared.contains("||") { + return Err(unsupported( + registry, + declared, + "logical unions are outside the lossless v1 subset", + )); + } + if declared.split_whitespace().any(|token| token == "-") { + return Err(unsupported( + registry, + declared, + "hyphen ranges are outside the lossless v1 subset", + )); + } + if declared.contains('+') { + return Err(NativeDependencyError::BuildMetadataNotAllowed { + field: "declared".to_string(), + version: declared.to_string(), + }); + } + + let lower = declared.to_ascii_lowercase(); + let source_prefixes = [ + "workspace:", + "file:", + "link:", + "git:", + "git+", + "http:", + "https:", + "ssh:", + "github:", + "npm:", + ]; + if declared.contains("://") || source_prefixes.iter().any(|prefix| lower.starts_with(prefix)) { + return Err(unsupported( + registry, + declared, + "source protocols, aliases, and workspace requirements are not SemVer ranges", + )); + } + Ok(()) +} + +fn translate_npm_requirement(declared: &str) -> Result { + if declared.contains(',') { + return Err(unsupported( + NativeRegistry::Npm, + declared, + "npm comparator intersections use whitespace, not Cargo commas, in strict v1", + )); + } + let tokens: Vec<&str> = declared.split_whitespace().collect(); + let normalized = tokens + .iter() + .map(|token| normalize_npm_token(token)) + .collect::, _>>()? + .join(", "); + parse_requirement(NativeRegistry::Npm, declared, &normalized) +} + +fn normalize_npm_token(token: &str) -> Result { + let (operator, body) = split_operator(token); + if body.is_empty() { + return Err(invalid_requirement( + NativeRegistry::Npm, + token, + "missing version after comparator", + )); + } + let body = strip_numeric_v_prefix(body); + let body = normalize_x_components(body)?; + + if operator.is_empty() || operator == "=" { + return normalize_npm_bare(&body).ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + token, + "expected an exact version, partial version, or wildcard", + ) + }); + } + Ok(format!("{operator}{body}")) +} + +fn normalize_npm_bare(body: &str) -> Option { + if let Ok(version) = Version::parse(body) { + if version.build != BuildMetadata::EMPTY { + return None; + } + return Some(format!("={version}")); + } + + let parts: Vec<&str> = body.split('.').collect(); + if parts.is_empty() || parts.len() > 3 { + return None; + } + let mut numeric = Vec::new(); + let mut wildcard_seen = false; + for part in parts { + if part == "*" { + wildcard_seen = true; + continue; + } + if wildcard_seen || part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let value: u64 = part.parse().ok()?; + numeric.push(value); + } + + match numeric.as_slice() { + [] => Some("*".to_string()), + [major] => Some(format!("{major}.*")), + [major, minor] => Some(format!("{major}.{minor}.*")), + [major, minor, patch] if !wildcard_seen => Some(format!("={major}.{minor}.{patch}")), + _ => None, + } +} + +fn translate_cargo_requirement(declared: &str) -> Result { + if declared.split_whitespace().count() > 1 && !declared.contains(',') { + return Err(unsupported( + NativeRegistry::Cargo, + declared, + "Cargo comparator intersections require commas in strict v1", + )); + } + if cargo_contains_x_wildcard(declared) { + return Err(unsupported( + NativeRegistry::Cargo, + declared, + "Cargo wildcards use `*`; npm-style `x` and `X` are rejected", + )); + } + parse_requirement(NativeRegistry::Cargo, declared, declared) +} + +fn split_operator(token: &str) -> (&str, &str) { + for operator in [">=", "<=", "^", "~", ">", "<", "="] { + if let Some(body) = token.strip_prefix(operator) { + return (operator, body); + } + } + ("", token) +} + +fn strip_numeric_v_prefix(body: &str) -> &str { + body.strip_prefix('v') + .filter(|rest| rest.bytes().next().is_some_and(|byte| byte.is_ascii_digit())) + .unwrap_or(body) +} + +fn normalize_x_components(body: &str) -> Result { + let mut normalized = Vec::new(); + for part in body.split('.') { + if part.eq_ignore_ascii_case("x") { + normalized.push("*".to_string()); + } else { + normalized.push(part.to_string()); + } + } + Ok(normalized.join(".")) +} + +fn cargo_contains_x_wildcard(declared: &str) -> bool { + declared + .split(|character: char| character.is_whitespace() || character == ',') + .filter(|token| !token.is_empty()) + .any(|token| { + let (_, body) = split_operator(token); + let core = body.split('-').next().unwrap_or(body); + core.split('.').any(|part| part.eq_ignore_ascii_case("x")) + }) +} + +fn parse_requirement( + registry: NativeRegistry, + declared: &str, + normalized: &str, +) -> Result { + VersionReq::parse(normalized).map_err(|error| NativeDependencyError::InvalidRequirement { + registry, + declared: declared.to_string(), + detail: error.to_string(), + }) +} + +fn parse_canonical_requirement( + registry: NativeRegistry, + canonical: &str, +) -> Result { + VersionReq::parse(canonical).map_err(|error| NativeDependencyError::InvalidCanonicalRequirement { + registry, + canonical: canonical.to_string(), + detail: error.to_string(), + }) +} + +fn parse_strict_version(field: &str, raw: &str) -> Result { + let version = Version::parse(raw).map_err(|error| NativeDependencyError::InvalidVersion { + field: field.to_string(), + version: raw.to_string(), + detail: error.to_string(), + })?; + if version.build != BuildMetadata::EMPTY { + return Err(NativeDependencyError::BuildMetadataNotAllowed { + field: field.to_string(), + version: raw.to_string(), + }); + } + Ok(version) +} + +fn validate_native_package_name( + registry: NativeRegistry, + name: &str, +) -> Result<(), NativeDependencyError> { + match registry { + NativeRegistry::Cargo => { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(NativeDependencyError::InvalidPackageName { + registry, + name: name.to_string(), + detail: "expected 1-64 ASCII alphanumeric, `-`, or `_` characters".to_string(), + }); + } + } + NativeRegistry::Npm => validate_npm_package_name(name)?, + } + Ok(()) +} + +fn validate_npm_package_name(name: &str) -> Result<(), NativeDependencyError> { + if name.is_empty() || name.len() > 214 || name.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "expected a lowercase npm name no longer than 214 bytes".to_string(), + }); + } + let components: Vec<&str> = if let Some(scoped) = name.strip_prefix('@') { + let mut parts = scoped.split('/'); + let scope = parts.next().unwrap_or_default(); + let package = parts.next().unwrap_or_default(); + if scope.is_empty() || package.is_empty() || parts.next().is_some() { + return Err(NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "scoped names must use exactly `@scope/package`".to_string(), + }); + } + vec![scope, package] + } else { + if name.contains('/') { + return Err(NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "unscoped names may not contain `/`".to_string(), + }); + } + vec![name] + }; + + for component in components { + let first = component.bytes().next().ok_or_else(|| { + NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must not be empty".to_string(), + } + })?; + if !first.is_ascii_lowercase() && !first.is_ascii_digit() { + return Err(NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must start with a lowercase letter or digit".to_string(), + }); + } + if !component.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'-' | b'_' | b'.') + }) { + return Err(NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components may contain lowercase letters, digits, `-`, `_`, or `.`" + .to_string(), + }); + } + } + Ok(()) +} + +fn validate_native_artifact( + artifact: &NativeArtifact, + field: &str, +) -> Result<(), NativeDependencyError> { + if artifact.sha256.len() != 64 + || !artifact + .sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || artifact.sha256.bytes().all(|byte| byte == b'0') + { + return Err(NativeDependencyError::InvalidSha256 { + field: format!("{field}.sha256"), + value: artifact.sha256.clone(), + }); + } + if artifact.size == 0 { + return Err(NativeDependencyError::EmptyArtifact { + field: field.to_string(), + }); + } + Ok(()) +} + +fn unsupported( + registry: NativeRegistry, + declared: &str, + reason: &str, +) -> NativeDependencyError { + NativeDependencyError::UnsupportedRequirement { + registry, + declared: declared.to_string(), + reason: reason.to_string(), + } +} + +fn invalid_requirement( + registry: NativeRegistry, + declared: &str, + detail: &str, +) -> NativeDependencyError { + NativeDependencyError::InvalidRequirement { + registry, + declared: declared.to_string(), + detail: detail.to_string(), + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum NativeDependencyError { + #[error("unsupported native dependency lock schema `{found}`; expected `{supported}`")] + UnsupportedSchema { found: String, supported: String }, + #[error("empty {registry:?} requirement is not reproducible")] + EmptyRequirement { registry: NativeRegistry }, + #[error("{registry:?} requirement length {length} exceeds the {maximum}-byte limit")] + RequirementTooLong { + registry: NativeRegistry, + length: usize, + maximum: usize, + }, + #[error("{registry:?} requirement must not contain surrounding whitespace: `{declared}`")] + SurroundingWhitespace { + registry: NativeRegistry, + declared: String, + }, + #[error("{registry:?} requirement contains a control character: `{declared}`")] + ControlCharacter { + registry: NativeRegistry, + declared: String, + }, + #[error("unsupported {registry:?} requirement `{declared}`: {reason}")] + UnsupportedRequirement { + registry: NativeRegistry, + declared: String, + reason: String, + }, + #[error("invalid {registry:?} requirement `{declared}`: {detail}")] + InvalidRequirement { + registry: NativeRegistry, + declared: String, + detail: String, + }, + #[error("invalid canonical {registry:?} requirement `{canonical}`: {detail}")] + InvalidCanonicalRequirement { + registry: NativeRegistry, + canonical: String, + detail: String, + }, + #[error( + "canonical requirement drift for `{declared}`: expected `{expected}`, found `{found}`" + )] + CanonicalRequirementDrift { + declared: String, + expected: String, + found: String, + }, + #[error("invalid strict SemVer `{version}` at `{field}`: {detail}")] + InvalidVersion { + field: String, + version: String, + detail: String, + }, + #[error("SemVer build metadata is not allowed at `{field}` (`{version}`)")] + BuildMetadataNotAllowed { field: String, version: String }, + #[error("invalid native package name `{name}` for {registry:?}: {detail}")] + InvalidPackageName { + registry: NativeRegistry, + name: String, + detail: String, + }, + #[error("duplicate native candidate version `{version}`")] + DuplicateCandidateVersion { version: String }, + #[error("no {registry:?} version of `{package}` satisfies `{requirement}`")] + NoMatchingVersion { + registry: NativeRegistry, + package: String, + requirement: String, + }, + #[error( + "resolved native package `{package}@{version}` does not satisfy `{requirement}`" + )] + ResolvedVersionDoesNotMatch { + package: String, + version: String, + requirement: String, + }, + #[error("invalid lowercase nonzero SHA-256 `{value}` at `{field}`")] + InvalidSha256 { field: String, value: String }, + #[error("artifact at `{field}` must contain at least one byte")] + EmptyArtifact { field: String }, + #[error("failed to serialize native dependency lock: {0}")] + Serialization(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ArtifactFormat; + + fn artifact(digit: char) -> NativeArtifact { + NativeArtifact { + sha256: std::iter::repeat_n(digit, 64).collect(), + size: 128, + format: ArtifactFormat::TarGz, + } + } + + fn candidate(version: &str, digit: char) -> NativeVersionCandidate { + NativeVersionCandidate { + version: version.to_string(), + artifact: artifact(digit), + } + } + + #[test] + fn npm_bare_exact_and_cargo_default_caret_are_distinct() { + let npm = NativeVersionRequirement::parse(NativeRegistry::Npm, "1.2.3").unwrap(); + let cargo = NativeVersionRequirement::parse(NativeRegistry::Cargo, "1.2.3").unwrap(); + assert_eq!(npm.canonical, "=1.2.3"); + assert_eq!(cargo.canonical, "^1.2.3"); + assert!(npm.matches("1.2.3").unwrap()); + assert!(!npm.matches("1.2.4").unwrap()); + assert!(cargo.matches("1.9.9").unwrap()); + assert!(!cargo.matches("2.0.0").unwrap()); + } + + #[test] + fn npm_partial_versions_are_x_ranges_while_cargo_partials_are_caret() { + let npm_minor = NativeVersionRequirement::parse(NativeRegistry::Npm, "1.2").unwrap(); + let cargo_minor = NativeVersionRequirement::parse(NativeRegistry::Cargo, "1.2").unwrap(); + let npm_major = NativeVersionRequirement::parse(NativeRegistry::Npm, "1").unwrap(); + let cargo_major = NativeVersionRequirement::parse(NativeRegistry::Cargo, "1").unwrap(); + + assert_eq!(npm_minor.canonical, "1.2.*"); + assert_eq!(cargo_minor.canonical, "^1.2"); + assert_eq!(npm_major.canonical, "1.*"); + assert_eq!(cargo_major.canonical, "^1"); + assert!(!npm_minor.matches("1.3.0").unwrap()); + assert!(cargo_minor.matches("1.3.0").unwrap()); + } + + #[test] + fn native_wildcards_and_comparator_intersections_are_source_aware() { + let npm = NativeVersionRequirement::parse( + NativeRegistry::Npm, + ">=1.2.3 <2.0.0", + ) + .unwrap(); + let cargo = NativeVersionRequirement::parse( + NativeRegistry::Cargo, + ">=1.2.3, <2.0.0", + ) + .unwrap(); + let npm_x = NativeVersionRequirement::parse(NativeRegistry::Npm, "1.2.X").unwrap(); + + assert_eq!(npm.canonical, ">=1.2.3, <2.0.0"); + assert_eq!(cargo.canonical, ">=1.2.3, <2.0.0"); + assert_eq!(npm_x.canonical, "1.2.*"); + assert!(npm.matches("1.9.0").unwrap()); + assert!(!cargo.matches("2.0.0").unwrap()); + } + + #[test] + fn major_zero_and_prerelease_rules_follow_semver() { + let cargo = NativeVersionRequirement::parse(NativeRegistry::Cargo, "0.2.3").unwrap(); + assert!(cargo.matches("0.2.9").unwrap()); + assert!(!cargo.matches("0.3.0").unwrap()); + + let ordinary = NativeVersionRequirement::parse(NativeRegistry::Npm, "^1.2.3").unwrap(); + assert!(!ordinary.matches("1.3.0-beta.1").unwrap()); + let explicit = + NativeVersionRequirement::parse(NativeRegistry::Npm, "1.3.0-beta.1").unwrap(); + assert!(explicit.matches("1.3.0-beta.1").unwrap()); + } + + #[test] + fn resolution_is_highest_satisfying_and_order_independent() { + let candidates = vec![ + candidate("1.2.3", 'a'), + candidate("1.9.0", 'b'), + candidate("2.0.0", 'c'), + candidate("1.10.0", 'd'), + ]; + let first = NativeDependencyLock::resolve( + NativeRegistry::Cargo, + "fiducia_core", + "1.2.3", + &candidates, + ) + .unwrap(); + let mut reversed = candidates; + reversed.reverse(); + let second = NativeDependencyLock::resolve( + NativeRegistry::Cargo, + "fiducia_core", + "1.2.3", + &reversed, + ) + .unwrap(); + + assert_eq!(first.package.version, "1.10.0"); + assert_eq!(first, second); + assert_eq!( + first.canonical_json_bytes().unwrap(), + second.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn npm_exact_resolution_does_not_float() { + let lock = NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "1.2.3", + &[candidate("1.2.3", 'a'), candidate("1.2.4", 'b')], + ) + .unwrap(); + assert_eq!(lock.package.version, "1.2.3"); + assert_eq!(lock.requirement.canonical, "=1.2.3"); + } + + #[test] + fn unsupported_or_mutable_native_syntax_fails_closed() { + for requirement in [ + "1.0.0 || 2.0.0", + "1.0.0 - 2.0.0", + "latest", + "workspace:^1.0.0", + "file:../core", + "npm:@scope/core@1.0.0", + ">=1.0.0, <2.0.0", + ] { + assert!(NativeVersionRequirement::parse(NativeRegistry::Npm, requirement).is_err()); + } + for requirement in [ + "1.0.0 || 2.0.0", + ">=1.0.0 <2.0.0", + "1.x", + "git+https://example.invalid/core", + ] { + assert!(NativeVersionRequirement::parse(NativeRegistry::Cargo, requirement).is_err()); + } + } + + #[test] + fn exact_lock_rejects_translation_version_and_artifact_drift() { + let mut lock = NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "^1.2.3", + &[candidate("1.9.0", 'a')], + ) + .unwrap(); + + lock.requirement.canonical = "^1.3.0".to_string(); + assert!(matches!( + lock.validate(), + Err(NativeDependencyError::CanonicalRequirementDrift { .. }) + )); + + let mut lock = NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "^1.2.3", + &[candidate("1.9.0", 'a')], + ) + .unwrap(); + lock.package.version = "2.0.0".to_string(); + assert!(matches!( + lock.validate(), + Err(NativeDependencyError::ResolvedVersionDoesNotMatch { .. }) + )); + + let mut lock = NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "^1.2.3", + &[candidate("1.9.0", 'a')], + ) + .unwrap(); + lock.artifact.sha256 = "0".repeat(64); + assert!(matches!( + lock.validate(), + Err(NativeDependencyError::InvalidSha256 { .. }) + )); + } + + #[test] + fn candidate_identity_is_strict_and_unambiguous() { + let duplicate = [candidate("1.2.3", 'a'), candidate("1.2.3", 'b')]; + assert!(matches!( + NativeDependencyLock::resolve( + NativeRegistry::Cargo, + "fiducia_core", + "1.2.3", + &duplicate, + ), + Err(NativeDependencyError::DuplicateCandidateVersion { .. }) + )); + + let build_metadata = [candidate("1.2.3+linux", 'a')]; + assert!(matches!( + NativeDependencyLock::resolve( + NativeRegistry::Cargo, + "fiducia_core", + "1.2.3", + &build_metadata, + ), + Err(NativeDependencyError::BuildMetadataNotAllowed { .. }) + )); + + let malformed_artifact = [NativeVersionCandidate { + version: "1.2.3".to_string(), + artifact: NativeArtifact { + sha256: "A".repeat(64), + size: 128, + format: ArtifactFormat::TarGz, + }, + }]; + assert!(matches!( + NativeDependencyLock::resolve( + NativeRegistry::Cargo, + "fiducia_core", + "1.2.3", + &malformed_artifact, + ), + Err(NativeDependencyError::InvalidSha256 { .. }) + )); + } +} From 992e1cfc5def6c15ae69d7ee4f8fc15f618c88ec Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:12:39 -0500 Subject: [PATCH 145/191] 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 46c521e43f2d8fa639423705d8ad0a06d2e3cd31 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:13:21 -0500 Subject: [PATCH 146/191] feat(DEN-1507): export native dependency lock types --- src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 0bd6d1f..539ffa8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod excludes; pub mod language; pub mod lockfile; pub mod manifest; +pub mod native_dependency; pub mod native_registry; pub mod nix; pub mod paths; @@ -30,6 +31,10 @@ pub use environment::{ pub use language::{Ecosystem, Language, detect_ecosystems}; pub use lockfile::{LockedPackage, Lockfile, LockfileError}; pub use manifest::{Manifest, ManifestError, NixExportRoute}; +pub use native_dependency::{ + NATIVE_DEPENDENCY_LOCK_SCHEMA_V1, NativeDependencyError, NativeDependencyLock, + NativeVersionCandidate, NativeVersionRequirement, +}; pub use native_registry::{ NATIVE_REGISTRY_ADAPTER_SCHEMA_V1, NativeArtifact, NativePackageIdentity, NativePlatform, NativePlatformPackage, NativePublication, NativePublicationKind, NativeRegistry, From 8212e8435b734943b5ca4092c21e99b1db062f8d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:13:40 -0500 Subject: [PATCH 147/191] feat(DEN-1507): generate native dependency lock schema --- examples/generate_schemas.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 72993a1..e0663af 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -25,6 +25,7 @@ fn main() { write::(dir, "nix-export-section"); write::(dir, "nix-adapter-record"); write::(dir, "native-registry-adapter-record"); + write::(dir, "native-dependency-lock"); write::(dir, "package-metadata"); write::(dir, "version-metadata"); write::(dir, "publish-meta"); From 3f1b1dbcd2266923a7b630313bd883fed16c590b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:14:03 -0500 Subject: [PATCH 148/191] docs(DEN-1507): document source-aware native dependency locks --- docs/native-dependency-lock.md | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/native-dependency-lock.md diff --git a/docs/native-dependency-lock.md b/docs/native-dependency-lock.md new file mode 100644 index 0000000..9c3e42b --- /dev/null +++ b/docs/native-dependency-lock.md @@ -0,0 +1,75 @@ +# Native dependency requirement and exact-lock contract + +`NativeDependencyLock` records how a dependency declaration from npm or Cargo +was translated into Zed's shared SemVer algebra and which exact immutable +artifact satisfied it. + +The source registry is part of the requirement identity. Identical text is not +assumed to have identical semantics: + +| Declaration | npm | Cargo | +| --- | --- | --- | +| `1.2.3` | exact `=1.2.3` | default caret `^1.2.3` | +| `1.2` | x-range `1.2.*` | default caret `^1.2` | +| `1` | x-range `1.*` | default caret `^1` | +| `^0.2.3` | caret | caret | +| `~1.2.3` | patch-compatible | patch-compatible | + +The original declaration remains in the record for auditability. The canonical +requirement is recomputed during validation, so editing it independently causes +a fail-closed drift error. + +## Frozen identity + +A requirement is not a frozen dependency. A valid v1 lock also requires: + +- one exact strict-SemVer package version; +- a native package name valid for the selected registry; +- lowercase, nonzero SHA-256 of the exact artifact bytes; +- a nonzero artifact size; and +- the artifact archive format. + +The resolved version must satisfy the recomputed canonical requirement. SemVer +build metadata is rejected for declarations, candidates, and exact lock +versions because it does not participate in precedence and cannot safely name +different artifacts. + +## Supported npm subset + +Version 1 supports exact versions, partial/x-ranges, `x`/`X`/`*` wildcards, +caret and tilde ranges, explicit comparators, whitespace-separated comparator +intersections, and explicit prerelease requirements. A full bare version is +made explicitly exact. + +Logical unions, hyphen ranges, dist-tags, aliases, workspace requirements, +local paths, Git sources, and URL sources are rejected rather than approximated +or silently converted to opaque strings. Cargo-style comma input is also +rejected for npm in strict v1. + +## Supported Cargo subset + +Version 1 supports Cargo's default-caret bare requirements, explicit caret, +tilde, exact and inequality comparators, `*` wildcards, comma-separated +comparator intersections, and explicit prerelease requirements. + +Npm-style `x` wildcards, whitespace-only comparator intersections, unions, +source protocols, and mutable or non-SemVer selectors are rejected. + +## Resolution boundary + +`NativeDependencyLock::resolve` validates every supplied candidate, rejects +duplicate exact versions, and selects the highest satisfying version independent +of input order. Registry policy such as filtering yanked releases remains the +caller's responsibility; the shared contract operates only on eligible +candidates. + +Frozen restore consumes the exact lock result. It does not ask npm, Cargo, Nix, +Flox, Devbox, mise, or asdf to reinterpret or re-resolve the declaration. + +## Relationship to publication records + +`NativeRegistryAdapterRecord` binds Zed publication identity to one native +publication family, including platform package selection and immutable archive +identity. `NativeDependencyLock` binds one native dependency declaration to one +exact member and artifact. They share registry and artifact types but remain +separate versioned contracts. From a120c2c0ea72dddcfe5ab996ac0a0974ca7c18c8 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:19:35 -0500 Subject: [PATCH 149/191] 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 bfb36a7bd1aa04ffb941fb293e792c3b5b1d6457 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:20:00 -0500 Subject: [PATCH 150/191] test(DEN-1507): pin native dependency schema contract --- tests/native_dependency_schema_contract.rs | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/native_dependency_schema_contract.rs diff --git a/tests/native_dependency_schema_contract.rs b/tests/native_dependency_schema_contract.rs new file mode 100644 index 0000000..e228c97 --- /dev/null +++ b/tests/native_dependency_schema_contract.rs @@ -0,0 +1,23 @@ +use schemars::schema_for; +use serde_json::Value; +use zed_interfaces::NativeDependencyLock; + +const NATIVE_DEPENDENCY_LOCK_SCHEMA: &str = + include_str!("../schemas/native-dependency-lock.json"); + +#[test] +fn checked_in_native_dependency_schema_matches_the_public_contract() { + let checked_in: Value = serde_json::from_str(NATIVE_DEPENDENCY_LOCK_SCHEMA) + .expect("checked-in native dependency schema must parse"); + let generated = serde_json::to_value(schema_for!(NativeDependencyLock)).unwrap(); + assert_eq!(checked_in, generated); + + let text = NATIVE_DEPENDENCY_LOCK_SCHEMA; + assert!(text.contains("NativeDependencyLock")); + assert!(text.contains("NativeVersionRequirement")); + assert!(text.contains("declared")); + assert!(text.contains("canonical")); + assert!(text.contains("sha256")); + assert!(text.contains("npm")); + assert!(text.contains("cargo")); +} From 242fdcaf387c8c00b5cab0cf0cd7ff540522f18b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:20:20 -0500 Subject: [PATCH 151/191] ci(DEN-1507): materialize native dependency schema and formatting --- .../bootstrap-native-dependency-lock.yml | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/bootstrap-native-dependency-lock.yml diff --git a/.github/workflows/bootstrap-native-dependency-lock.yml b/.github/workflows/bootstrap-native-dependency-lock.yml new file mode 100644 index 0000000..f2213d1 --- /dev/null +++ b/.github/workflows/bootstrap-native-dependency-lock.yml @@ -0,0 +1,70 @@ +name: Bootstrap native dependency lock contract + +on: + push: + branches: + - agent/native-dependency-range-lock + pull_request: + branches: + - agent/native-registry-semver-contract + types: + - opened + - synchronize + - reopened + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: bootstrap-native-dependency-lock + cancel-in-progress: true + +jobs: + materialize: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - name: Check out exact product branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/native-dependency-range-lock + fetch-depth: 0 + persist-credentials: true + + - name: Install stable Rust with formatter and Clippy + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Format and generate checked-in schema + run: | + set -euo pipefail + cargo fmt --all + cargo run --locked --example generate_schemas + test -s schemas/native-dependency-lock.json + git diff --check + + - name: Validate complete crate + run: | + set -euo pipefail + cargo test --locked --all-targets + cargo clippy --locked --all-targets --all-features -- -D warnings + git diff --check + + - name: Commit only the reviewed product result + run: | + set -euo pipefail + git rm .github/workflows/bootstrap-native-dependency-lock.yml + git add src/native_dependency.rs src/lib.rs examples/generate_schemas.rs \ + schemas/native-dependency-lock.json \ + tests/native_dependency_schema_contract.rs \ + docs/native-dependency-lock.md + git diff --cached --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "feat(DEN-1507): materialize native dependency lock contract" + git push origin HEAD:agent/native-dependency-range-lock 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 152/191] 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 c9b547c39ea5b1be50eb8284b726b914ea2d6041 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:21:07 +0000 Subject: [PATCH 153/191] feat(DEN-1507): materialize native dependency lock contract --- .../bootstrap-native-dependency-lock.yml | 70 ------------ schemas/native-dependency-lock.json | 107 ++++++++++++++++++ src/native_dependency.rs | 69 ++++++----- tests/native_dependency_schema_contract.rs | 3 +- 4 files changed, 140 insertions(+), 109 deletions(-) delete mode 100644 .github/workflows/bootstrap-native-dependency-lock.yml create mode 100644 schemas/native-dependency-lock.json diff --git a/.github/workflows/bootstrap-native-dependency-lock.yml b/.github/workflows/bootstrap-native-dependency-lock.yml deleted file mode 100644 index f2213d1..0000000 --- a/.github/workflows/bootstrap-native-dependency-lock.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Bootstrap native dependency lock contract - -on: - push: - branches: - - agent/native-dependency-range-lock - pull_request: - branches: - - agent/native-registry-semver-contract - types: - - opened - - synchronize - - reopened - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: bootstrap-native-dependency-lock - cancel-in-progress: true - -jobs: - materialize: - runs-on: ubuntu-22.04 - timeout-minutes: 30 - steps: - - name: Check out exact product branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/native-dependency-range-lock - fetch-depth: 0 - persist-credentials: true - - - name: Install stable Rust with formatter and Clippy - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - rustc --version --verbose - cargo --version --verbose - - - name: Format and generate checked-in schema - run: | - set -euo pipefail - cargo fmt --all - cargo run --locked --example generate_schemas - test -s schemas/native-dependency-lock.json - git diff --check - - - name: Validate complete crate - run: | - set -euo pipefail - cargo test --locked --all-targets - cargo clippy --locked --all-targets --all-features -- -D warnings - git diff --check - - - name: Commit only the reviewed product result - run: | - set -euo pipefail - git rm .github/workflows/bootstrap-native-dependency-lock.yml - git add src/native_dependency.rs src/lib.rs examples/generate_schemas.rs \ - schemas/native-dependency-lock.json \ - tests/native_dependency_schema_contract.rs \ - docs/native-dependency-lock.md - git diff --cached --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "feat(DEN-1507): materialize native dependency lock contract" - git push origin HEAD:agent/native-dependency-range-lock diff --git a/schemas/native-dependency-lock.json b/schemas/native-dependency-lock.json new file mode 100644 index 0000000..108abef --- /dev/null +++ b/schemas/native-dependency-lock.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "NativeDependencyLock", + "description": "Frozen native dependency identity. The declaration remains auditable, while\n`package.version` and `artifact` are the only restore-time identities.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NativeArtifact" + }, + "package": { + "$ref": "#/$defs/NativePackageIdentity" + }, + "requirement": { + "$ref": "#/$defs/NativeVersionRequirement" + }, + "schema": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema", + "requirement", + "package", + "artifact" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "NativeArtifact": { + "description": "Exact immutable bytes published under one native package identity.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "sha256": { + "description": "Lowercase hexadecimal SHA-256 of the exact uploaded archive bytes.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size" + ] + }, + "NativePackageIdentity": { + "description": "Public identity selected in npm or a Cargo-compatible registry.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ] + }, + "NativeRegistry": { + "description": "Native registries with a first-class publication identity contract.", + "type": "string", + "enum": [ + "npm", + "cargo" + ] + }, + "NativeVersionRequirement": { + "description": "One source-aware native requirement and its canonical SemVer translation.", + "type": "object", + "properties": { + "canonical": { + "description": "Deterministic `semver::VersionReq` representation used by Zed.", + "type": "string" + }, + "declared": { + "description": "Exact project declaration before translation.", + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + } + }, + "additionalProperties": false, + "required": [ + "registry", + "declared", + "canonical" + ] + } + } +} diff --git a/src/native_dependency.rs b/src/native_dependency.rs index cde6ca1..8108fa1 100644 --- a/src/native_dependency.rs +++ b/src/native_dependency.rs @@ -243,7 +243,11 @@ fn validate_requirement_input( "github:", "npm:", ]; - if declared.contains("://") || source_prefixes.iter().any(|prefix| lower.starts_with(prefix)) { + if declared.contains("://") + || source_prefixes + .iter() + .any(|prefix| lower.starts_with(prefix)) + { return Err(unsupported( registry, declared, @@ -358,7 +362,11 @@ fn split_operator(token: &str) -> (&str, &str) { fn strip_numeric_v_prefix(body: &str) -> &str { body.strip_prefix('v') - .filter(|rest| rest.bytes().next().is_some_and(|byte| byte.is_ascii_digit())) + .filter(|rest| { + rest.bytes() + .next() + .is_some_and(|byte| byte.is_ascii_digit()) + }) .unwrap_or(body) } @@ -401,10 +409,12 @@ fn parse_canonical_requirement( registry: NativeRegistry, canonical: &str, ) -> Result { - VersionReq::parse(canonical).map_err(|error| NativeDependencyError::InvalidCanonicalRequirement { - registry, - canonical: canonical.to_string(), - detail: error.to_string(), + VersionReq::parse(canonical).map_err(|error| { + NativeDependencyError::InvalidCanonicalRequirement { + registry, + canonical: canonical.to_string(), + detail: error.to_string(), + } }) } @@ -479,13 +489,15 @@ fn validate_npm_package_name(name: &str) -> Result<(), NativeDependencyError> { }; for component in components { - let first = component.bytes().next().ok_or_else(|| { - NativeDependencyError::InvalidPackageName { - registry: NativeRegistry::Npm, - name: name.to_string(), - detail: "name components must not be empty".to_string(), - } - })?; + let first = + component + .bytes() + .next() + .ok_or_else(|| NativeDependencyError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must not be empty".to_string(), + })?; if !first.is_ascii_lowercase() && !first.is_ascii_digit() { return Err(NativeDependencyError::InvalidPackageName { registry: NativeRegistry::Npm, @@ -494,9 +506,7 @@ fn validate_npm_package_name(name: &str) -> Result<(), NativeDependencyError> { }); } if !component.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'.') }) { return Err(NativeDependencyError::InvalidPackageName { registry: NativeRegistry::Npm, @@ -533,11 +543,7 @@ fn validate_native_artifact( Ok(()) } -fn unsupported( - registry: NativeRegistry, - declared: &str, - reason: &str, -) -> NativeDependencyError { +fn unsupported(registry: NativeRegistry, declared: &str, reason: &str) -> NativeDependencyError { NativeDependencyError::UnsupportedRequirement { registry, declared: declared.to_string(), @@ -597,9 +603,7 @@ pub enum NativeDependencyError { canonical: String, detail: String, }, - #[error( - "canonical requirement drift for `{declared}`: expected `{expected}`, found `{found}`" - )] + #[error("canonical requirement drift for `{declared}`: expected `{expected}`, found `{found}`")] CanonicalRequirementDrift { declared: String, expected: String, @@ -627,9 +631,7 @@ pub enum NativeDependencyError { package: String, requirement: String, }, - #[error( - "resolved native package `{package}@{version}` does not satisfy `{requirement}`" - )] + #[error("resolved native package `{package}@{version}` does not satisfy `{requirement}`")] ResolvedVersionDoesNotMatch { package: String, version: String, @@ -692,16 +694,9 @@ mod tests { #[test] fn native_wildcards_and_comparator_intersections_are_source_aware() { - let npm = NativeVersionRequirement::parse( - NativeRegistry::Npm, - ">=1.2.3 <2.0.0", - ) - .unwrap(); - let cargo = NativeVersionRequirement::parse( - NativeRegistry::Cargo, - ">=1.2.3, <2.0.0", - ) - .unwrap(); + let npm = NativeVersionRequirement::parse(NativeRegistry::Npm, ">=1.2.3 <2.0.0").unwrap(); + let cargo = + NativeVersionRequirement::parse(NativeRegistry::Cargo, ">=1.2.3, <2.0.0").unwrap(); let npm_x = NativeVersionRequirement::parse(NativeRegistry::Npm, "1.2.X").unwrap(); assert_eq!(npm.canonical, ">=1.2.3, <2.0.0"); diff --git a/tests/native_dependency_schema_contract.rs b/tests/native_dependency_schema_contract.rs index e228c97..b3e0019 100644 --- a/tests/native_dependency_schema_contract.rs +++ b/tests/native_dependency_schema_contract.rs @@ -2,8 +2,7 @@ use schemars::schema_for; use serde_json::Value; use zed_interfaces::NativeDependencyLock; -const NATIVE_DEPENDENCY_LOCK_SCHEMA: &str = - include_str!("../schemas/native-dependency-lock.json"); +const NATIVE_DEPENDENCY_LOCK_SCHEMA: &str = include_str!("../schemas/native-dependency-lock.json"); #[test] fn checked_in_native_dependency_schema_matches_the_public_contract() { From 85eab02b4abf8dc9001729c980a8946812efa6a3 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:22:06 -0500 Subject: [PATCH 154/191] 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 155/191] 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 156/191] 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 157/191] 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 158/191] 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 159/191] 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 4783de92dce9640ba11529fe590d249654491b05 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:31:10 -0500 Subject: [PATCH 160/191] test(DEN-1507): stage standards edge-case hardening --- scripts/den1507_harden_native_ranges.py | 493 ++++++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 scripts/den1507_harden_native_ranges.py diff --git a/scripts/den1507_harden_native_ranges.py b/scripts/den1507_harden_native_ranges.py new file mode 100644 index 0000000..1d43b8d --- /dev/null +++ b/scripts/den1507_harden_native_ranges.py @@ -0,0 +1,493 @@ +from pathlib import Path +import re + +path = Path("src/native_dependency.rs") +source = path.read_text(encoding="utf-8") + +old_constant = "const MAX_REQUIREMENT_LEN: usize = 512;" +new_constant = ( + "const MAX_REQUIREMENT_LEN: usize = 512;\n" + "const NPM_MAX_SAFE_COMPONENT: u64 = 9_007_199_254_740_991;" +) +if old_constant not in source: + raise SystemExit("requirement-length constant marker not found") +source = source.replace(old_constant, new_constant, 1) + +replacements = { + 'let version = parse_strict_version("version", version)?;': + 'let version = parse_native_version(self.registry, "version", version)?;', + 'let version = parse_strict_version("candidate.version", &candidate.version)?;': + 'let version = parse_native_version(registry, "candidate.version", &candidate.version)?;', + 'let resolved = parse_strict_version("package.version", &self.package.version)?;': + 'let resolved = parse_native_version(\n' + ' self.requirement.registry,\n' + ' "package.version",\n' + ' &self.package.version,\n' + ' )?;', +} +for old, new in replacements.items(): + if old not in source: + raise SystemExit(f"version parser marker not found: {old}") + source = source.replace(old, new, 1) + +range_block = r'''fn translate_npm_requirement(declared: &str) -> Result { + if declared.contains(',') { + return Err(unsupported( + NativeRegistry::Npm, + declared, + "npm comparator intersections use whitespace, not Cargo commas, in strict v1", + )); + } + let normalized = coalesce_npm_tokens(declared)? + .iter() + .map(|token| normalize_npm_token(token)) + .collect::, _>>()? + .join(", "); + parse_requirement(NativeRegistry::Npm, declared, &normalized) +} + +fn coalesce_npm_tokens(declared: &str) -> Result, NativeDependencyError> { + let mut normalized = Vec::new(); + let mut words = declared.split_whitespace(); + while let Some(word) = words.next() { + if is_operator_token(word) { + let body = words.next().ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + declared, + "missing version after comparator", + ) + })?; + if !split_operator(body).0.is_empty() { + return Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "multiple comparator operators may not be separated by whitespace", + )); + } + normalized.push(format!("{word}{body}")); + } else { + normalized.push(word.to_string()); + } + } + Ok(normalized) +} + +fn normalize_npm_token(token: &str) -> Result { + let (operator, body) = split_operator(token); + if body.is_empty() { + return Err(invalid_requirement( + NativeRegistry::Npm, + token, + "missing version after comparator", + )); + } + let body = strip_numeric_v_prefix(body); + let body = normalize_x_components(body); + + if operator.is_empty() || operator == "=" { + return normalize_npm_bare(&body).ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + token, + "expected an exact version, partial version, or wildcard", + ) + }); + } + normalize_npm_comparator(operator, &body, token) +} + +fn normalize_npm_bare(body: &str) -> Option { + if let Ok(version) = Version::parse(body) { + if version.build != BuildMetadata::EMPTY || !npm_components_supported(&version) { + return None; + } + return Some(format!("={version}")); + } + + let partial = parse_npm_partial(body)?; + match partial.components.as_slice() { + [] if partial.wildcard => Some("*".to_string()), + [major] => Some(format!("{major}.*")), + [major, minor] => Some(format!("{major}.{minor}.*")), + [major, minor, patch] if !partial.wildcard => { + Some(format!("={major}.{minor}.{patch}")) + } + _ => None, + } +} + +fn normalize_npm_comparator( + operator: &str, + body: &str, + declared: &str, +) -> Result { + if let Ok(version) = Version::parse(body) { + if version.build != BuildMetadata::EMPTY { + return Err(NativeDependencyError::BuildMetadataNotAllowed { + field: "declared".to_string(), + version: declared.to_string(), + }); + } + if !npm_components_supported(&version) { + return Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "npm numeric components must not exceed Number.MAX_SAFE_INTEGER", + )); + } + return Ok(format!("{operator}{version}")); + } + + let partial = parse_npm_partial(body).ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + declared, + "expected a strict or partial numeric version after comparator", + ) + })?; + if partial.components.is_empty() { + return Err(unsupported( + NativeRegistry::Npm, + declared, + "comparators against an unconstrained wildcard are outside strict v1", + )); + } + + match operator { + "^" | "~" => Ok(format!("{operator}{}", partial.numeric_prefix())), + ">=" => Ok(format!(">={}", partial.lower_bound())), + "<" => Ok(format!("<{}", partial.lower_bound())), + ">" => Ok(format!(">={}", partial.next_prefix(declared)?)), + "<=" => Ok(format!("<{}", partial.next_prefix(declared)?)), + _ => Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "unsupported comparator operator", + )), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NpmPartialVersion { + components: Vec, + wildcard: bool, +} + +impl NpmPartialVersion { + fn numeric_prefix(&self) -> String { + self.components + .iter() + .map(u64::to_string) + .collect::>() + .join(".") + } + + fn lower_bound(&self) -> String { + match self.components.as_slice() { + [major] => format!("{major}.0.0"), + [major, minor] => format!("{major}.{minor}.0"), + [major, minor, patch] => format!("{major}.{minor}.{patch}"), + _ => unreachable!("validated npm partial has one to three numeric components"), + } + } + + fn next_prefix(&self, declared: &str) -> Result { + match self.components.as_slice() { + [major] => Ok(format!("{}.0.0", increment_npm_component(*major, declared)?)), + [major, minor] => Ok(format!( + "{major}.{}.0", + increment_npm_component(*minor, declared)? + )), + [major, minor, patch] if !self.wildcard => Ok(format!( + "{major}.{minor}.{}", + increment_npm_component(*patch, declared)? + )), + _ => Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "cannot advance this partial comparator without changing its meaning", + )), + } + } +} + +fn parse_npm_partial(body: &str) -> Option { + let parts: Vec<&str> = body.split('.').collect(); + if parts.is_empty() || parts.len() > 3 { + return None; + } + + let mut components = Vec::new(); + let mut wildcard = false; + for part in parts { + if part == "*" { + wildcard = true; + continue; + } + if wildcard + || part.is_empty() + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || (part.len() > 1 && part.starts_with('0')) + { + return None; + } + let component: u64 = part.parse().ok()?; + if component > NPM_MAX_SAFE_COMPONENT { + return None; + } + components.push(component); + } + + Some(NpmPartialVersion { + components, + wildcard, + }) +} + +fn increment_npm_component( + component: u64, + declared: &str, +) -> Result { + let incremented = component.checked_add(1).ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + declared, + "partial comparator component overflows SemVer", + ) + })?; + if incremented > NPM_MAX_SAFE_COMPONENT { + return Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "partial comparator increment exceeds Number.MAX_SAFE_INTEGER", + )); + } + Ok(incremented) +} + +fn translate_cargo_requirement(declared: &str) -> Result { + if cargo_contains_x_wildcard(declared) { + return Err(unsupported( + NativeRegistry::Cargo, + declared, + "Cargo wildcards use `*`; npm-style `x` and `X` are rejected", + )); + } + + let normalized = declared + .split(',') + .map(str::trim) + .map(|segment| normalize_cargo_segment(declared, segment)) + .collect::, _>>()? + .join(", "); + parse_requirement(NativeRegistry::Cargo, declared, &normalized) +} + +fn normalize_cargo_segment( + declared: &str, + segment: &str, +) -> Result { + if segment.is_empty() { + return Err(invalid_requirement( + NativeRegistry::Cargo, + declared, + "empty comparator in comma-separated requirement", + )); + } + let words: Vec<&str> = segment.split_whitespace().collect(); + match words.as_slice() { + [single] => Ok((*single).to_string()), + [operator, body] if is_operator_token(operator) && split_operator(body).0.is_empty() => { + Ok(format!("{operator}{body}")) + } + _ => Err(unsupported( + NativeRegistry::Cargo, + declared, + "multiple Cargo comparators require commas; whitespace is only allowed between one operator and its version", + )), + } +} + +fn is_operator_token(token: &str) -> bool { + matches!(token, ">=" | "<=" | "^" | "~" | ">" | "<" | "=") +} + +fn split_operator(token: &str) -> (&str, &str) { + for operator in [">=", "<=", "^", "~", ">", "<", "="] { + if let Some(body) = token.strip_prefix(operator) { + return (operator, body); + } + } + ("", token) +} + +fn strip_numeric_v_prefix(body: &str) -> &str { + body.strip_prefix('v') + .filter(|rest| { + rest.bytes() + .next() + .is_some_and(|byte| byte.is_ascii_digit()) + }) + .unwrap_or(body) +} + +fn normalize_x_components(body: &str) -> String { + body.split('.') + .map(|part| { + if part.eq_ignore_ascii_case("x") { + "*" + } else { + part + } + }) + .collect::>() + .join(".") +} + +fn cargo_contains_x_wildcard(declared: &str) -> bool { + declared + .split(|character: char| character.is_whitespace() || character == ',') + .filter(|token| !token.is_empty()) + .any(|token| { + let (_, body) = split_operator(token); + let core = body.split('-').next().unwrap_or(body); + core.split('.').any(|part| part.eq_ignore_ascii_case("x")) + }) +} + +fn parse_requirement''' + +pattern = r"fn translate_npm_requirement\(declared: &str\).*?\nfn parse_requirement" +source, count = re.subn(pattern, range_block, source, flags=re.S) +if count != 1: + raise SystemExit(f"expected one range parser block, found {count}") + +old_parse = '''fn parse_strict_version(field: &str, raw: &str) -> Result { + let version = Version::parse(raw).map_err(|error| NativeDependencyError::InvalidVersion { + field: field.to_string(), + version: raw.to_string(), + detail: error.to_string(), + })?; + if version.build != BuildMetadata::EMPTY { + return Err(NativeDependencyError::BuildMetadataNotAllowed { + field: field.to_string(), + version: raw.to_string(), + }); + } + Ok(version) +} +''' +new_parse = '''fn parse_native_version( + registry: NativeRegistry, + field: &str, + raw: &str, +) -> Result { + let version = parse_strict_version(field, raw)?; + if registry == NativeRegistry::Npm && !npm_components_supported(&version) { + return Err(NativeDependencyError::InvalidVersion { + field: field.to_string(), + version: raw.to_string(), + detail: "npm numeric components must not exceed Number.MAX_SAFE_INTEGER".to_string(), + }); + } + Ok(version) +} + +fn npm_components_supported(version: &Version) -> bool { + version.major <= NPM_MAX_SAFE_COMPONENT + && version.minor <= NPM_MAX_SAFE_COMPONENT + && version.patch <= NPM_MAX_SAFE_COMPONENT +} + +fn parse_strict_version(field: &str, raw: &str) -> Result { + let version = Version::parse(raw).map_err(|error| NativeDependencyError::InvalidVersion { + field: field.to_string(), + version: raw.to_string(), + detail: error.to_string(), + })?; + if version.build != BuildMetadata::EMPTY { + return Err(NativeDependencyError::BuildMetadataNotAllowed { + field: field.to_string(), + version: raw.to_string(), + }); + } + Ok(version) +} +''' +if old_parse not in source: + raise SystemExit("strict version parser block not found") +source = source.replace(old_parse, new_parse, 1) + +test_marker = ''' #[test] + fn resolution_is_highest_satisfying_and_order_independent() {''' +new_tests = ''' #[test] + fn npm_partial_comparators_follow_node_semver_boundaries() { + let gt_major = NativeVersionRequirement::parse(NativeRegistry::Npm, ">1").unwrap(); + let gt_minor = NativeVersionRequirement::parse(NativeRegistry::Npm, ">1.2").unwrap(); + let lte_minor = NativeVersionRequirement::parse(NativeRegistry::Npm, "<=1.2").unwrap(); + let spaced = NativeVersionRequirement::parse( + NativeRegistry::Npm, + ">= 1.2.3 < 2.0.0", + ) + .unwrap(); + + assert_eq!(gt_major.canonical, ">=2.0.0"); + assert_eq!(gt_minor.canonical, ">=1.3.0"); + assert_eq!(lte_minor.canonical, "<1.3.0"); + assert_eq!(spaced.canonical, ">=1.2.3, <2.0.0"); + assert!(!gt_major.matches("1.99.99").unwrap()); + assert!(gt_major.matches("2.0.0").unwrap()); + assert!(!gt_minor.matches("1.2.999").unwrap()); + assert!(gt_minor.matches("1.3.0").unwrap()); + assert!(lte_minor.matches("1.2.999").unwrap()); + assert!(!lte_minor.matches("1.3.0").unwrap()); + } + + #[test] + fn cargo_allows_operator_whitespace_but_still_requires_comma_intersections() { + let requirement = NativeVersionRequirement::parse( + NativeRegistry::Cargo, + ">= 1.2, < 1.5", + ) + .unwrap(); + assert_eq!(requirement.canonical, ">=1.2, <1.5"); + assert!(requirement.matches("1.4.99").unwrap()); + assert!(!requirement.matches("1.5.0").unwrap()); + assert!(NativeVersionRequirement::parse( + NativeRegistry::Cargo, + ">= 1.2 < 1.5", + ) + .is_err()); + } + + #[test] + fn npm_rejects_leading_zero_and_unsafe_integer_components() { + for requirement in [ + "01.2", + "1.02", + ">1.02", + "^01.2", + "9007199254740992.0.0", + ">9007199254740991", + ] { + assert!(NativeVersionRequirement::parse(NativeRegistry::Npm, requirement).is_err()); + } + + assert!(matches!( + NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "*", + &[candidate("9007199254740992.0.0", 'a')], + ), + Err(NativeDependencyError::InvalidVersion { .. }) + )); + } + +''' + test_marker +if test_marker not in source: + raise SystemExit("unit test insertion marker not found") +source = source.replace(test_marker, new_tests, 1) + +path.write_text(source, encoding="utf-8") From 2b0bcfea7df0292a94bfaecbfbb14d8b0bd512fe Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:31:45 -0500 Subject: [PATCH 161/191] docs(DEN-1507): document native comparator edge semantics --- docs/native-dependency-lock.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/native-dependency-lock.md b/docs/native-dependency-lock.md index 9c3e42b..de1b3d7 100644 --- a/docs/native-dependency-lock.md +++ b/docs/native-dependency-lock.md @@ -14,6 +14,8 @@ assumed to have identical semantics: | `1` | x-range `1.*` | default caret `^1` | | `^0.2.3` | caret | caret | | `~1.2.3` | patch-compatible | patch-compatible | +| `>1` | `>=2.0.0` | Cargo comparison requirement | +| `<=1.2` | `<1.3.0` | Cargo comparison requirement | The original declaration remains in the record for auditability. The canonical requirement is recomputed during validation, so editing it independently causes @@ -34,6 +36,11 @@ build metadata is rejected for declarations, candidates, and exact lock versions because it does not participate in precedence and cannot safely name different artifacts. +For npm identities, numeric components must also fit JavaScript's exact integer +range. Leading-zero partial components and values above +`Number.MAX_SAFE_INTEGER` are rejected rather than accepted by Rust and later +reinterpreted differently by npm tooling. + ## Supported npm subset Version 1 supports exact versions, partial/x-ranges, `x`/`X`/`*` wildcards, @@ -41,6 +48,12 @@ caret and tilde ranges, explicit comparators, whitespace-separated comparator intersections, and explicit prerelease requirements. A full bare version is made explicitly exact. +Whitespace between one comparator operator and its version is normalized, so +`>= 1.2.3 < 2.0.0` produces the same canonical intersection as +`>=1.2.3 <2.0.0`. Partial inequality comparators are desugared using npm's +boundaries: `>1` becomes `>=2.0.0`, `>1.2` becomes `>=1.3.0`, and `<=1.2` +becomes `<1.3.0`. + Logical unions, hyphen ranges, dist-tags, aliases, workspace requirements, local paths, Git sources, and URL sources are rejected rather than approximated or silently converted to opaque strings. Cargo-style comma input is also @@ -50,7 +63,9 @@ rejected for npm in strict v1. Version 1 supports Cargo's default-caret bare requirements, explicit caret, tilde, exact and inequality comparators, `*` wildcards, comma-separated -comparator intersections, and explicit prerelease requirements. +comparator intersections, and explicit prerelease requirements. Whitespace +between one operator and its version is accepted, including +`>= 1.2, < 1.5`; multiple comparators still require commas. Npm-style `x` wildcards, whitespace-only comparator intersections, unions, source protocols, and mutable or non-SemVer selectors are rejected. From 9f7747524320f8af4c3402619f235d9b6dfa28e0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 12:32:01 -0500 Subject: [PATCH 162/191] ci(DEN-1507): apply and validate comparator hardening --- ...trap-native-dependency-range-hardening.yml | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/bootstrap-native-dependency-range-hardening.yml diff --git a/.github/workflows/bootstrap-native-dependency-range-hardening.yml b/.github/workflows/bootstrap-native-dependency-range-hardening.yml new file mode 100644 index 0000000..aec74c0 --- /dev/null +++ b/.github/workflows/bootstrap-native-dependency-range-hardening.yml @@ -0,0 +1,70 @@ +name: Bootstrap native dependency range hardening + +on: + push: + branches: + - agent/native-dependency-range-lock + pull_request: + branches: + - agent/native-registry-semver-contract + types: + - opened + - synchronize + - reopened + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: bootstrap-native-dependency-range-hardening + cancel-in-progress: true + +jobs: + harden: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + - name: Check out exact product branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/native-dependency-range-lock + fetch-depth: 0 + persist-credentials: true + + - name: Install stable Rust with formatter and Clippy + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Apply reviewed standards hardening + run: | + set -euo pipefail + python3 scripts/den1507_harden_native_ranges.py + cargo fmt --all + cargo run --locked --example generate_schemas + git diff --check + + - name: Validate complete crate + run: | + set -euo pipefail + cargo test --locked --all-targets + cargo clippy --locked --all-targets --all-features -- -D warnings + git diff --check + + - name: Commit only the product result + run: | + set -euo pipefail + git rm .github/workflows/bootstrap-native-dependency-range-hardening.yml + git rm scripts/den1507_harden_native_ranges.py + git add src/native_dependency.rs docs/native-dependency-lock.md \ + schemas/native-dependency-lock.json \ + tests/native_dependency_schema_contract.rs + git diff --cached --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix(DEN-1507): preserve native comparator edge semantics" + git push origin HEAD:agent/native-dependency-range-lock From cc801de501651ccfc3fd4ec8a481b819ee58cbb4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:32:52 +0000 Subject: [PATCH 163/191] fix(DEN-1507): preserve native comparator edge semantics --- ...trap-native-dependency-range-hardening.yml | 70 --- scripts/den1507_harden_native_ranges.py | 493 ------------------ src/native_dependency.rs | 338 ++++++++++-- 3 files changed, 302 insertions(+), 599 deletions(-) delete mode 100644 .github/workflows/bootstrap-native-dependency-range-hardening.yml delete mode 100644 scripts/den1507_harden_native_ranges.py diff --git a/.github/workflows/bootstrap-native-dependency-range-hardening.yml b/.github/workflows/bootstrap-native-dependency-range-hardening.yml deleted file mode 100644 index aec74c0..0000000 --- a/.github/workflows/bootstrap-native-dependency-range-hardening.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Bootstrap native dependency range hardening - -on: - push: - branches: - - agent/native-dependency-range-lock - pull_request: - branches: - - agent/native-registry-semver-contract - types: - - opened - - synchronize - - reopened - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: bootstrap-native-dependency-range-hardening - cancel-in-progress: true - -jobs: - harden: - runs-on: ubuntu-22.04 - timeout-minutes: 30 - steps: - - name: Check out exact product branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/native-dependency-range-lock - fetch-depth: 0 - persist-credentials: true - - - name: Install stable Rust with formatter and Clippy - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - rustc --version --verbose - cargo --version --verbose - - - name: Apply reviewed standards hardening - run: | - set -euo pipefail - python3 scripts/den1507_harden_native_ranges.py - cargo fmt --all - cargo run --locked --example generate_schemas - git diff --check - - - name: Validate complete crate - run: | - set -euo pipefail - cargo test --locked --all-targets - cargo clippy --locked --all-targets --all-features -- -D warnings - git diff --check - - - name: Commit only the product result - run: | - set -euo pipefail - git rm .github/workflows/bootstrap-native-dependency-range-hardening.yml - git rm scripts/den1507_harden_native_ranges.py - git add src/native_dependency.rs docs/native-dependency-lock.md \ - schemas/native-dependency-lock.json \ - tests/native_dependency_schema_contract.rs - git diff --cached --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix(DEN-1507): preserve native comparator edge semantics" - git push origin HEAD:agent/native-dependency-range-lock diff --git a/scripts/den1507_harden_native_ranges.py b/scripts/den1507_harden_native_ranges.py deleted file mode 100644 index 1d43b8d..0000000 --- a/scripts/den1507_harden_native_ranges.py +++ /dev/null @@ -1,493 +0,0 @@ -from pathlib import Path -import re - -path = Path("src/native_dependency.rs") -source = path.read_text(encoding="utf-8") - -old_constant = "const MAX_REQUIREMENT_LEN: usize = 512;" -new_constant = ( - "const MAX_REQUIREMENT_LEN: usize = 512;\n" - "const NPM_MAX_SAFE_COMPONENT: u64 = 9_007_199_254_740_991;" -) -if old_constant not in source: - raise SystemExit("requirement-length constant marker not found") -source = source.replace(old_constant, new_constant, 1) - -replacements = { - 'let version = parse_strict_version("version", version)?;': - 'let version = parse_native_version(self.registry, "version", version)?;', - 'let version = parse_strict_version("candidate.version", &candidate.version)?;': - 'let version = parse_native_version(registry, "candidate.version", &candidate.version)?;', - 'let resolved = parse_strict_version("package.version", &self.package.version)?;': - 'let resolved = parse_native_version(\n' - ' self.requirement.registry,\n' - ' "package.version",\n' - ' &self.package.version,\n' - ' )?;', -} -for old, new in replacements.items(): - if old not in source: - raise SystemExit(f"version parser marker not found: {old}") - source = source.replace(old, new, 1) - -range_block = r'''fn translate_npm_requirement(declared: &str) -> Result { - if declared.contains(',') { - return Err(unsupported( - NativeRegistry::Npm, - declared, - "npm comparator intersections use whitespace, not Cargo commas, in strict v1", - )); - } - let normalized = coalesce_npm_tokens(declared)? - .iter() - .map(|token| normalize_npm_token(token)) - .collect::, _>>()? - .join(", "); - parse_requirement(NativeRegistry::Npm, declared, &normalized) -} - -fn coalesce_npm_tokens(declared: &str) -> Result, NativeDependencyError> { - let mut normalized = Vec::new(); - let mut words = declared.split_whitespace(); - while let Some(word) = words.next() { - if is_operator_token(word) { - let body = words.next().ok_or_else(|| { - invalid_requirement( - NativeRegistry::Npm, - declared, - "missing version after comparator", - ) - })?; - if !split_operator(body).0.is_empty() { - return Err(invalid_requirement( - NativeRegistry::Npm, - declared, - "multiple comparator operators may not be separated by whitespace", - )); - } - normalized.push(format!("{word}{body}")); - } else { - normalized.push(word.to_string()); - } - } - Ok(normalized) -} - -fn normalize_npm_token(token: &str) -> Result { - let (operator, body) = split_operator(token); - if body.is_empty() { - return Err(invalid_requirement( - NativeRegistry::Npm, - token, - "missing version after comparator", - )); - } - let body = strip_numeric_v_prefix(body); - let body = normalize_x_components(body); - - if operator.is_empty() || operator == "=" { - return normalize_npm_bare(&body).ok_or_else(|| { - invalid_requirement( - NativeRegistry::Npm, - token, - "expected an exact version, partial version, or wildcard", - ) - }); - } - normalize_npm_comparator(operator, &body, token) -} - -fn normalize_npm_bare(body: &str) -> Option { - if let Ok(version) = Version::parse(body) { - if version.build != BuildMetadata::EMPTY || !npm_components_supported(&version) { - return None; - } - return Some(format!("={version}")); - } - - let partial = parse_npm_partial(body)?; - match partial.components.as_slice() { - [] if partial.wildcard => Some("*".to_string()), - [major] => Some(format!("{major}.*")), - [major, minor] => Some(format!("{major}.{minor}.*")), - [major, minor, patch] if !partial.wildcard => { - Some(format!("={major}.{minor}.{patch}")) - } - _ => None, - } -} - -fn normalize_npm_comparator( - operator: &str, - body: &str, - declared: &str, -) -> Result { - if let Ok(version) = Version::parse(body) { - if version.build != BuildMetadata::EMPTY { - return Err(NativeDependencyError::BuildMetadataNotAllowed { - field: "declared".to_string(), - version: declared.to_string(), - }); - } - if !npm_components_supported(&version) { - return Err(invalid_requirement( - NativeRegistry::Npm, - declared, - "npm numeric components must not exceed Number.MAX_SAFE_INTEGER", - )); - } - return Ok(format!("{operator}{version}")); - } - - let partial = parse_npm_partial(body).ok_or_else(|| { - invalid_requirement( - NativeRegistry::Npm, - declared, - "expected a strict or partial numeric version after comparator", - ) - })?; - if partial.components.is_empty() { - return Err(unsupported( - NativeRegistry::Npm, - declared, - "comparators against an unconstrained wildcard are outside strict v1", - )); - } - - match operator { - "^" | "~" => Ok(format!("{operator}{}", partial.numeric_prefix())), - ">=" => Ok(format!(">={}", partial.lower_bound())), - "<" => Ok(format!("<{}", partial.lower_bound())), - ">" => Ok(format!(">={}", partial.next_prefix(declared)?)), - "<=" => Ok(format!("<{}", partial.next_prefix(declared)?)), - _ => Err(invalid_requirement( - NativeRegistry::Npm, - declared, - "unsupported comparator operator", - )), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct NpmPartialVersion { - components: Vec, - wildcard: bool, -} - -impl NpmPartialVersion { - fn numeric_prefix(&self) -> String { - self.components - .iter() - .map(u64::to_string) - .collect::>() - .join(".") - } - - fn lower_bound(&self) -> String { - match self.components.as_slice() { - [major] => format!("{major}.0.0"), - [major, minor] => format!("{major}.{minor}.0"), - [major, minor, patch] => format!("{major}.{minor}.{patch}"), - _ => unreachable!("validated npm partial has one to three numeric components"), - } - } - - fn next_prefix(&self, declared: &str) -> Result { - match self.components.as_slice() { - [major] => Ok(format!("{}.0.0", increment_npm_component(*major, declared)?)), - [major, minor] => Ok(format!( - "{major}.{}.0", - increment_npm_component(*minor, declared)? - )), - [major, minor, patch] if !self.wildcard => Ok(format!( - "{major}.{minor}.{}", - increment_npm_component(*patch, declared)? - )), - _ => Err(invalid_requirement( - NativeRegistry::Npm, - declared, - "cannot advance this partial comparator without changing its meaning", - )), - } - } -} - -fn parse_npm_partial(body: &str) -> Option { - let parts: Vec<&str> = body.split('.').collect(); - if parts.is_empty() || parts.len() > 3 { - return None; - } - - let mut components = Vec::new(); - let mut wildcard = false; - for part in parts { - if part == "*" { - wildcard = true; - continue; - } - if wildcard - || part.is_empty() - || !part.bytes().all(|byte| byte.is_ascii_digit()) - || (part.len() > 1 && part.starts_with('0')) - { - return None; - } - let component: u64 = part.parse().ok()?; - if component > NPM_MAX_SAFE_COMPONENT { - return None; - } - components.push(component); - } - - Some(NpmPartialVersion { - components, - wildcard, - }) -} - -fn increment_npm_component( - component: u64, - declared: &str, -) -> Result { - let incremented = component.checked_add(1).ok_or_else(|| { - invalid_requirement( - NativeRegistry::Npm, - declared, - "partial comparator component overflows SemVer", - ) - })?; - if incremented > NPM_MAX_SAFE_COMPONENT { - return Err(invalid_requirement( - NativeRegistry::Npm, - declared, - "partial comparator increment exceeds Number.MAX_SAFE_INTEGER", - )); - } - Ok(incremented) -} - -fn translate_cargo_requirement(declared: &str) -> Result { - if cargo_contains_x_wildcard(declared) { - return Err(unsupported( - NativeRegistry::Cargo, - declared, - "Cargo wildcards use `*`; npm-style `x` and `X` are rejected", - )); - } - - let normalized = declared - .split(',') - .map(str::trim) - .map(|segment| normalize_cargo_segment(declared, segment)) - .collect::, _>>()? - .join(", "); - parse_requirement(NativeRegistry::Cargo, declared, &normalized) -} - -fn normalize_cargo_segment( - declared: &str, - segment: &str, -) -> Result { - if segment.is_empty() { - return Err(invalid_requirement( - NativeRegistry::Cargo, - declared, - "empty comparator in comma-separated requirement", - )); - } - let words: Vec<&str> = segment.split_whitespace().collect(); - match words.as_slice() { - [single] => Ok((*single).to_string()), - [operator, body] if is_operator_token(operator) && split_operator(body).0.is_empty() => { - Ok(format!("{operator}{body}")) - } - _ => Err(unsupported( - NativeRegistry::Cargo, - declared, - "multiple Cargo comparators require commas; whitespace is only allowed between one operator and its version", - )), - } -} - -fn is_operator_token(token: &str) -> bool { - matches!(token, ">=" | "<=" | "^" | "~" | ">" | "<" | "=") -} - -fn split_operator(token: &str) -> (&str, &str) { - for operator in [">=", "<=", "^", "~", ">", "<", "="] { - if let Some(body) = token.strip_prefix(operator) { - return (operator, body); - } - } - ("", token) -} - -fn strip_numeric_v_prefix(body: &str) -> &str { - body.strip_prefix('v') - .filter(|rest| { - rest.bytes() - .next() - .is_some_and(|byte| byte.is_ascii_digit()) - }) - .unwrap_or(body) -} - -fn normalize_x_components(body: &str) -> String { - body.split('.') - .map(|part| { - if part.eq_ignore_ascii_case("x") { - "*" - } else { - part - } - }) - .collect::>() - .join(".") -} - -fn cargo_contains_x_wildcard(declared: &str) -> bool { - declared - .split(|character: char| character.is_whitespace() || character == ',') - .filter(|token| !token.is_empty()) - .any(|token| { - let (_, body) = split_operator(token); - let core = body.split('-').next().unwrap_or(body); - core.split('.').any(|part| part.eq_ignore_ascii_case("x")) - }) -} - -fn parse_requirement''' - -pattern = r"fn translate_npm_requirement\(declared: &str\).*?\nfn parse_requirement" -source, count = re.subn(pattern, range_block, source, flags=re.S) -if count != 1: - raise SystemExit(f"expected one range parser block, found {count}") - -old_parse = '''fn parse_strict_version(field: &str, raw: &str) -> Result { - let version = Version::parse(raw).map_err(|error| NativeDependencyError::InvalidVersion { - field: field.to_string(), - version: raw.to_string(), - detail: error.to_string(), - })?; - if version.build != BuildMetadata::EMPTY { - return Err(NativeDependencyError::BuildMetadataNotAllowed { - field: field.to_string(), - version: raw.to_string(), - }); - } - Ok(version) -} -''' -new_parse = '''fn parse_native_version( - registry: NativeRegistry, - field: &str, - raw: &str, -) -> Result { - let version = parse_strict_version(field, raw)?; - if registry == NativeRegistry::Npm && !npm_components_supported(&version) { - return Err(NativeDependencyError::InvalidVersion { - field: field.to_string(), - version: raw.to_string(), - detail: "npm numeric components must not exceed Number.MAX_SAFE_INTEGER".to_string(), - }); - } - Ok(version) -} - -fn npm_components_supported(version: &Version) -> bool { - version.major <= NPM_MAX_SAFE_COMPONENT - && version.minor <= NPM_MAX_SAFE_COMPONENT - && version.patch <= NPM_MAX_SAFE_COMPONENT -} - -fn parse_strict_version(field: &str, raw: &str) -> Result { - let version = Version::parse(raw).map_err(|error| NativeDependencyError::InvalidVersion { - field: field.to_string(), - version: raw.to_string(), - detail: error.to_string(), - })?; - if version.build != BuildMetadata::EMPTY { - return Err(NativeDependencyError::BuildMetadataNotAllowed { - field: field.to_string(), - version: raw.to_string(), - }); - } - Ok(version) -} -''' -if old_parse not in source: - raise SystemExit("strict version parser block not found") -source = source.replace(old_parse, new_parse, 1) - -test_marker = ''' #[test] - fn resolution_is_highest_satisfying_and_order_independent() {''' -new_tests = ''' #[test] - fn npm_partial_comparators_follow_node_semver_boundaries() { - let gt_major = NativeVersionRequirement::parse(NativeRegistry::Npm, ">1").unwrap(); - let gt_minor = NativeVersionRequirement::parse(NativeRegistry::Npm, ">1.2").unwrap(); - let lte_minor = NativeVersionRequirement::parse(NativeRegistry::Npm, "<=1.2").unwrap(); - let spaced = NativeVersionRequirement::parse( - NativeRegistry::Npm, - ">= 1.2.3 < 2.0.0", - ) - .unwrap(); - - assert_eq!(gt_major.canonical, ">=2.0.0"); - assert_eq!(gt_minor.canonical, ">=1.3.0"); - assert_eq!(lte_minor.canonical, "<1.3.0"); - assert_eq!(spaced.canonical, ">=1.2.3, <2.0.0"); - assert!(!gt_major.matches("1.99.99").unwrap()); - assert!(gt_major.matches("2.0.0").unwrap()); - assert!(!gt_minor.matches("1.2.999").unwrap()); - assert!(gt_minor.matches("1.3.0").unwrap()); - assert!(lte_minor.matches("1.2.999").unwrap()); - assert!(!lte_minor.matches("1.3.0").unwrap()); - } - - #[test] - fn cargo_allows_operator_whitespace_but_still_requires_comma_intersections() { - let requirement = NativeVersionRequirement::parse( - NativeRegistry::Cargo, - ">= 1.2, < 1.5", - ) - .unwrap(); - assert_eq!(requirement.canonical, ">=1.2, <1.5"); - assert!(requirement.matches("1.4.99").unwrap()); - assert!(!requirement.matches("1.5.0").unwrap()); - assert!(NativeVersionRequirement::parse( - NativeRegistry::Cargo, - ">= 1.2 < 1.5", - ) - .is_err()); - } - - #[test] - fn npm_rejects_leading_zero_and_unsafe_integer_components() { - for requirement in [ - "01.2", - "1.02", - ">1.02", - "^01.2", - "9007199254740992.0.0", - ">9007199254740991", - ] { - assert!(NativeVersionRequirement::parse(NativeRegistry::Npm, requirement).is_err()); - } - - assert!(matches!( - NativeDependencyLock::resolve( - NativeRegistry::Npm, - "@fiducia/core", - "*", - &[candidate("9007199254740992.0.0", 'a')], - ), - Err(NativeDependencyError::InvalidVersion { .. }) - )); - } - -''' + test_marker -if test_marker not in source: - raise SystemExit("unit test insertion marker not found") -source = source.replace(test_marker, new_tests, 1) - -path.write_text(source, encoding="utf-8") diff --git a/src/native_dependency.rs b/src/native_dependency.rs index 8108fa1..af005a3 100644 --- a/src/native_dependency.rs +++ b/src/native_dependency.rs @@ -20,6 +20,7 @@ use crate::{NativeArtifact, NativePackageIdentity, NativeRegistry}; pub const NATIVE_DEPENDENCY_LOCK_SCHEMA_V1: &str = "zed.native-dependency-lock/v1"; const MAX_REQUIREMENT_LEN: usize = 512; +const NPM_MAX_SAFE_COMPONENT: u64 = 9_007_199_254_740_991; /// One source-aware native requirement and its canonical SemVer translation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -69,7 +70,7 @@ impl NativeVersionRequirement { pub fn matches(&self, version: &str) -> Result { self.validate()?; let requirement = parse_canonical_requirement(self.registry, &self.canonical)?; - let version = parse_strict_version("version", version)?; + let version = parse_native_version(self.registry, "version", version)?; Ok(requirement.matches(&version)) } @@ -118,7 +119,7 @@ impl NativeDependencyLock { let mut seen = BTreeSet::new(); let mut selected: Option<(&NativeVersionCandidate, Version)> = None; for candidate in candidates { - let version = parse_strict_version("candidate.version", &candidate.version)?; + let version = parse_native_version(registry, "candidate.version", &candidate.version)?; validate_native_artifact(&candidate.artifact, "candidate.artifact")?; if !seen.insert(version.clone()) { return Err(NativeDependencyError::DuplicateCandidateVersion { @@ -162,7 +163,11 @@ impl NativeDependencyLock { } self.requirement.validate()?; validate_native_package_name(self.requirement.registry, &self.package.name)?; - let resolved = parse_strict_version("package.version", &self.package.version)?; + let resolved = parse_native_version( + self.requirement.registry, + "package.version", + &self.package.version, + )?; let requirement = self.requirement.parsed()?; if !requirement.matches(&resolved) { return Err(NativeDependencyError::ResolvedVersionDoesNotMatch { @@ -265,8 +270,7 @@ fn translate_npm_requirement(declared: &str) -> Result = declared.split_whitespace().collect(); - let normalized = tokens + let normalized = coalesce_npm_tokens(declared)? .iter() .map(|token| normalize_npm_token(token)) .collect::, _>>()? @@ -274,6 +278,33 @@ fn translate_npm_requirement(declared: &str) -> Result Result, NativeDependencyError> { + let mut normalized = Vec::new(); + let mut words = declared.split_whitespace(); + while let Some(word) = words.next() { + if is_operator_token(word) { + let body = words.next().ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + declared, + "missing version after comparator", + ) + })?; + if !split_operator(body).0.is_empty() { + return Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "multiple comparator operators may not be separated by whitespace", + )); + } + normalized.push(format!("{word}{body}")); + } else { + normalized.push(word.to_string()); + } + } + Ok(normalized) +} + fn normalize_npm_token(token: &str) -> Result { let (operator, body) = split_operator(token); if body.is_empty() { @@ -284,7 +315,7 @@ fn normalize_npm_token(token: &str) -> Result { )); } let body = strip_numeric_v_prefix(body); - let body = normalize_x_components(body)?; + let body = normalize_x_components(body); if operator.is_empty() || operator == "=" { return normalize_npm_bare(&body).ok_or_else(|| { @@ -295,60 +326,218 @@ fn normalize_npm_token(token: &str) -> Result { ) }); } - Ok(format!("{operator}{body}")) + normalize_npm_comparator(operator, &body, token) } fn normalize_npm_bare(body: &str) -> Option { if let Ok(version) = Version::parse(body) { - if version.build != BuildMetadata::EMPTY { + if version.build != BuildMetadata::EMPTY || !npm_components_supported(&version) { return None; } return Some(format!("={version}")); } + let partial = parse_npm_partial(body)?; + match partial.components.as_slice() { + [] if partial.wildcard => Some("*".to_string()), + [major] => Some(format!("{major}.*")), + [major, minor] => Some(format!("{major}.{minor}.*")), + [major, minor, patch] if !partial.wildcard => Some(format!("={major}.{minor}.{patch}")), + _ => None, + } +} + +fn normalize_npm_comparator( + operator: &str, + body: &str, + declared: &str, +) -> Result { + if let Ok(version) = Version::parse(body) { + if version.build != BuildMetadata::EMPTY { + return Err(NativeDependencyError::BuildMetadataNotAllowed { + field: "declared".to_string(), + version: declared.to_string(), + }); + } + if !npm_components_supported(&version) { + return Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "npm numeric components must not exceed Number.MAX_SAFE_INTEGER", + )); + } + return Ok(format!("{operator}{version}")); + } + + let partial = parse_npm_partial(body).ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + declared, + "expected a strict or partial numeric version after comparator", + ) + })?; + if partial.components.is_empty() { + return Err(unsupported( + NativeRegistry::Npm, + declared, + "comparators against an unconstrained wildcard are outside strict v1", + )); + } + + match operator { + "^" | "~" => Ok(format!("{operator}{}", partial.numeric_prefix())), + ">=" => Ok(format!(">={}", partial.lower_bound())), + "<" => Ok(format!("<{}", partial.lower_bound())), + ">" => Ok(format!(">={}", partial.next_prefix(declared)?)), + "<=" => Ok(format!("<{}", partial.next_prefix(declared)?)), + _ => Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "unsupported comparator operator", + )), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NpmPartialVersion { + components: Vec, + wildcard: bool, +} + +impl NpmPartialVersion { + fn numeric_prefix(&self) -> String { + self.components + .iter() + .map(u64::to_string) + .collect::>() + .join(".") + } + + fn lower_bound(&self) -> String { + match self.components.as_slice() { + [major] => format!("{major}.0.0"), + [major, minor] => format!("{major}.{minor}.0"), + [major, minor, patch] => format!("{major}.{minor}.{patch}"), + _ => unreachable!("validated npm partial has one to three numeric components"), + } + } + + fn next_prefix(&self, declared: &str) -> Result { + match self.components.as_slice() { + [major] => Ok(format!( + "{}.0.0", + increment_npm_component(*major, declared)? + )), + [major, minor] => Ok(format!( + "{major}.{}.0", + increment_npm_component(*minor, declared)? + )), + [major, minor, patch] if !self.wildcard => Ok(format!( + "{major}.{minor}.{}", + increment_npm_component(*patch, declared)? + )), + _ => Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "cannot advance this partial comparator without changing its meaning", + )), + } + } +} + +fn parse_npm_partial(body: &str) -> Option { let parts: Vec<&str> = body.split('.').collect(); if parts.is_empty() || parts.len() > 3 { return None; } - let mut numeric = Vec::new(); - let mut wildcard_seen = false; + + let mut components = Vec::new(); + let mut wildcard = false; for part in parts { if part == "*" { - wildcard_seen = true; + wildcard = true; continue; } - if wildcard_seen || part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()) { + if wildcard + || part.is_empty() + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || (part.len() > 1 && part.starts_with('0')) + { + return None; + } + let component: u64 = part.parse().ok()?; + if component > NPM_MAX_SAFE_COMPONENT { return None; } - let value: u64 = part.parse().ok()?; - numeric.push(value); + components.push(component); } - match numeric.as_slice() { - [] => Some("*".to_string()), - [major] => Some(format!("{major}.*")), - [major, minor] => Some(format!("{major}.{minor}.*")), - [major, minor, patch] if !wildcard_seen => Some(format!("={major}.{minor}.{patch}")), - _ => None, + Some(NpmPartialVersion { + components, + wildcard, + }) +} + +fn increment_npm_component(component: u64, declared: &str) -> Result { + let incremented = component.checked_add(1).ok_or_else(|| { + invalid_requirement( + NativeRegistry::Npm, + declared, + "partial comparator component overflows SemVer", + ) + })?; + if incremented > NPM_MAX_SAFE_COMPONENT { + return Err(invalid_requirement( + NativeRegistry::Npm, + declared, + "partial comparator increment exceeds Number.MAX_SAFE_INTEGER", + )); } + Ok(incremented) } fn translate_cargo_requirement(declared: &str) -> Result { - if declared.split_whitespace().count() > 1 && !declared.contains(',') { + if cargo_contains_x_wildcard(declared) { return Err(unsupported( NativeRegistry::Cargo, declared, - "Cargo comparator intersections require commas in strict v1", + "Cargo wildcards use `*`; npm-style `x` and `X` are rejected", )); } - if cargo_contains_x_wildcard(declared) { - return Err(unsupported( + + let normalized = declared + .split(',') + .map(str::trim) + .map(|segment| normalize_cargo_segment(declared, segment)) + .collect::, _>>()? + .join(", "); + parse_requirement(NativeRegistry::Cargo, declared, &normalized) +} + +fn normalize_cargo_segment(declared: &str, segment: &str) -> Result { + if segment.is_empty() { + return Err(invalid_requirement( NativeRegistry::Cargo, declared, - "Cargo wildcards use `*`; npm-style `x` and `X` are rejected", + "empty comparator in comma-separated requirement", )); } - parse_requirement(NativeRegistry::Cargo, declared, declared) + let words: Vec<&str> = segment.split_whitespace().collect(); + match words.as_slice() { + [single] => Ok((*single).to_string()), + [operator, body] if is_operator_token(operator) && split_operator(body).0.is_empty() => { + Ok(format!("{operator}{body}")) + } + _ => Err(unsupported( + NativeRegistry::Cargo, + declared, + "multiple Cargo comparators require commas; whitespace is only allowed between one operator and its version", + )), + } +} + +fn is_operator_token(token: &str) -> bool { + matches!(token, ">=" | "<=" | "^" | "~" | ">" | "<" | "=") } fn split_operator(token: &str) -> (&str, &str) { @@ -370,16 +559,17 @@ fn strip_numeric_v_prefix(body: &str) -> &str { .unwrap_or(body) } -fn normalize_x_components(body: &str) -> Result { - let mut normalized = Vec::new(); - for part in body.split('.') { - if part.eq_ignore_ascii_case("x") { - normalized.push("*".to_string()); - } else { - normalized.push(part.to_string()); - } - } - Ok(normalized.join(".")) +fn normalize_x_components(body: &str) -> String { + body.split('.') + .map(|part| { + if part.eq_ignore_ascii_case("x") { + "*" + } else { + part + } + }) + .collect::>() + .join(".") } fn cargo_contains_x_wildcard(declared: &str) -> bool { @@ -418,6 +608,28 @@ fn parse_canonical_requirement( }) } +fn parse_native_version( + registry: NativeRegistry, + field: &str, + raw: &str, +) -> Result { + let version = parse_strict_version(field, raw)?; + if registry == NativeRegistry::Npm && !npm_components_supported(&version) { + return Err(NativeDependencyError::InvalidVersion { + field: field.to_string(), + version: raw.to_string(), + detail: "npm numeric components must not exceed Number.MAX_SAFE_INTEGER".to_string(), + }); + } + Ok(version) +} + +fn npm_components_supported(version: &Version) -> bool { + version.major <= NPM_MAX_SAFE_COMPONENT + && version.minor <= NPM_MAX_SAFE_COMPONENT + && version.patch <= NPM_MAX_SAFE_COMPONENT +} + fn parse_strict_version(field: &str, raw: &str) -> Result { let version = Version::parse(raw).map_err(|error| NativeDependencyError::InvalidVersion { field: field.to_string(), @@ -719,6 +931,60 @@ mod tests { assert!(explicit.matches("1.3.0-beta.1").unwrap()); } + #[test] + fn npm_partial_comparators_follow_node_semver_boundaries() { + let gt_major = NativeVersionRequirement::parse(NativeRegistry::Npm, ">1").unwrap(); + let gt_minor = NativeVersionRequirement::parse(NativeRegistry::Npm, ">1.2").unwrap(); + let lte_minor = NativeVersionRequirement::parse(NativeRegistry::Npm, "<=1.2").unwrap(); + let spaced = + NativeVersionRequirement::parse(NativeRegistry::Npm, ">= 1.2.3 < 2.0.0").unwrap(); + + assert_eq!(gt_major.canonical, ">=2.0.0"); + assert_eq!(gt_minor.canonical, ">=1.3.0"); + assert_eq!(lte_minor.canonical, "<1.3.0"); + assert_eq!(spaced.canonical, ">=1.2.3, <2.0.0"); + assert!(!gt_major.matches("1.99.99").unwrap()); + assert!(gt_major.matches("2.0.0").unwrap()); + assert!(!gt_minor.matches("1.2.999").unwrap()); + assert!(gt_minor.matches("1.3.0").unwrap()); + assert!(lte_minor.matches("1.2.999").unwrap()); + assert!(!lte_minor.matches("1.3.0").unwrap()); + } + + #[test] + fn cargo_allows_operator_whitespace_but_still_requires_comma_intersections() { + let requirement = + NativeVersionRequirement::parse(NativeRegistry::Cargo, ">= 1.2, < 1.5").unwrap(); + assert_eq!(requirement.canonical, ">=1.2, <1.5"); + assert!(requirement.matches("1.4.99").unwrap()); + assert!(!requirement.matches("1.5.0").unwrap()); + assert!(NativeVersionRequirement::parse(NativeRegistry::Cargo, ">= 1.2 < 1.5",).is_err()); + } + + #[test] + fn npm_rejects_leading_zero_and_unsafe_integer_components() { + for requirement in [ + "01.2", + "1.02", + ">1.02", + "^01.2", + "9007199254740992.0.0", + ">9007199254740991", + ] { + assert!(NativeVersionRequirement::parse(NativeRegistry::Npm, requirement).is_err()); + } + + assert!(matches!( + NativeDependencyLock::resolve( + NativeRegistry::Npm, + "@fiducia/core", + "*", + &[candidate("9007199254740992.0.0", 'a')], + ), + Err(NativeDependencyError::InvalidVersion { .. }) + )); + } + #[test] fn resolution_is_highest_satisfying_and_order_independent() { let candidates = vec![ From 35985cece2a58e708b72f6dfc883b0c803cfd94d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Mon, 3 Aug 2026 13:11:38 -0500 Subject: [PATCH 164/191] 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 165/191] 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 166/191] 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 167/191] 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 168/191] 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 169/191] 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 170/191] 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 171/191] 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 From 117c1a1e7aff96448fbf35da77f90f7d40dcc488 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 07:53:29 -0500 Subject: [PATCH 172/191] chore: make zed-interfaces a validated Zed package (#28) Add the canonical Zed manifest and lockfile, ignore local package state, and provide a reusable parser-backed validation workflow for interface repositories. --- .github/workflows/validate-zed-package.yml | 187 +++++++++++++++++++++ .gitignore | 4 + .zpkg.lock | 1 + .zpkg.toml | 39 +++++ 4 files changed, 231 insertions(+) create mode 100644 .github/workflows/validate-zed-package.yml create mode 100644 .zpkg.lock create mode 100644 .zpkg.toml diff --git a/.github/workflows/validate-zed-package.yml b/.github/workflows/validate-zed-package.yml new file mode 100644 index 0000000..225601a --- /dev/null +++ b/.github/workflows/validate-zed-package.yml @@ -0,0 +1,187 @@ +name: validate-zed-package + +on: + workflow_call: + pull_request: + paths: + - ".zpkg.toml" + - ".zpkg.lock" + - ".gitignore" + - ".github/workflows/validate-zed-package.yml" + push: + branches: [main] + paths: + - ".zpkg.toml" + - ".zpkg.lock" + - ".gitignore" + - ".github/workflows/validate-zed-package.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: validate-zed-package-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate canonical package contract + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + steps: + - name: Check out package + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + show-progress: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + + - name: Validate with the pinned zed-interfaces parser + shell: bash + run: | + set -euo pipefail + validator="$RUNNER_TEMP/zed-package-validator" + mkdir -p "$validator/src" + cat > "$validator/Cargo.toml" <<'TOML' + [package] + name = "zed-package-validator" + version = "0.0.0" + edition = "2024" + publish = false + + [dependencies] + zed-interfaces = { git = "https://github.com/zed-pkg/zed-interfaces.git", rev = "2082012d1903783a89200c3bd44d2659a6daf872" } + TOML + cat > "$validator/src/main.rs" <<'RUST' + use std::{env, fs, path::PathBuf, process}; + + use zed_interfaces::{Lockfile, Manifest}; + + fn fail(message: impl std::fmt::Display) -> ! { + eprintln!("zed-package validation failed: {message}"); + process::exit(1); + } + + fn main() { + let root = env::args_os() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| fail("missing repository root argument")); + let manifest_text = fs::read_to_string(root.join(".zpkg.toml")) + .unwrap_or_else(|error| fail(format!("reading .zpkg.toml: {error}"))); + let lock_text = fs::read_to_string(root.join(".zpkg.lock")) + .unwrap_or_else(|error| fail(format!("reading .zpkg.lock: {error}"))); + + let manifest = Manifest::parse(&manifest_text) + .unwrap_or_else(|error| fail(format!("parsing .zpkg.toml: {error}"))); + let lock = Lockfile::parse(&lock_text) + .unwrap_or_else(|error| fail(format!("parsing .zpkg.lock: {error}"))); + + if lock.version != Lockfile::CURRENT_VERSION { + fail(format!( + "lock version {} is not current version {}", + lock.version, + Lockfile::CURRENT_VERSION + )); + } + + let repository = env::var("GITHUB_REPOSITORY") + .unwrap_or_else(|error| fail(format!("reading GITHUB_REPOSITORY: {error}"))); + let (_, expected_name) = repository + .split_once('/') + .unwrap_or_else(|| fail("GITHUB_REPOSITORY is not owner/name")); + if manifest.package.name != expected_name { + fail(format!( + "package name `{}` does not match repository `{expected_name}`", + manifest.package.name + )); + } + + let expected_url = format!("https://github.com/{repository}"); + let actual_url = manifest + .package + .repository + .url + .strip_suffix(".git") + .unwrap_or(&manifest.package.repository.url); + if !actual_url.eq_ignore_ascii_case(&expected_url) { + fail(format!( + "repository URL `{actual_url}` does not match `{expected_url}`" + )); + } + + for dependency in manifest.dependencies.keys() { + let (org, name) = dependency + .split_once('/') + .unwrap_or_else(|| fail(format!("invalid dependency key `{dependency}`"))); + if lock.find(org, name).is_none() { + fail(format!("direct dependency `{dependency}` is absent from .zpkg.lock")); + } + } + + for (name, target) in &manifest.targets { + let path = root.join(&target.dir); + if !path.is_dir() { + fail(format!( + "target `{name}` points at missing or non-directory path `{}`", + target.dir + )); + } + } + + let ignore = fs::read_to_string(root.join(".gitignore")) + .unwrap_or_else(|error| fail(format!("reading .gitignore: {error}"))); + for required in ["/zed_modules/", "/.zed/pack/"] { + if !ignore.lines().any(|line| line.trim() == required) { + fail(format!(".gitignore is missing `{required}`")); + } + } + + for required in [".env", ".env.*", ".zed/**"] { + if !manifest.publish.exclude.iter().any(|entry| entry == required) { + fail(format!("publish.exclude is missing `{required}`")); + } + } + + println!( + "validated {}/{}@{} with {} target(s) and {} locked package(s)", + manifest.package.org, + manifest.package.name, + manifest.package.version, + manifest.targets.len(), + lock.packages.len() + ); + } + RUST + cargo generate-lockfile --manifest-path "$validator/Cargo.toml" + cargo run --quiet --locked --manifest-path "$validator/Cargo.toml" -- "$GITHUB_WORKSPACE" + + - name: Enforce read-only immutable workflow references + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import re + from pathlib import Path + + wrapper = Path(".github/workflows/zed-package.yml") + central = Path(".github/workflows/validate-zed-package.yml") + workflow = wrapper if wrapper.exists() else central + text = workflow.read_text() + assert "permissions:\n contents: read" in text, workflow + forbidden_trigger = "pull_request" + "_target:" + assert forbidden_trigger not in text, workflow + actions = re.findall(r"^\s*uses:\s*(\S+)\s*$", text, re.MULTILINE) + assert actions, workflow + for action in actions: + assert re.search(r"@[0-9a-f]{40}$", action), action + PY + + - name: Reject whitespace errors + run: git diff --check diff --git a/.gitignore b/.gitignore index 909180f..594be98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ /target .cache/ + +# zed-pkg local state +/zed_modules/ +/.zed/pack/ diff --git a/.zpkg.lock b/.zpkg.lock new file mode 100644 index 0000000..d9914df --- /dev/null +++ b/.zpkg.lock @@ -0,0 +1 @@ +version = 1 diff --git a/.zpkg.toml b/.zpkg.toml new file mode 100644 index 0000000..eefca1f --- /dev/null +++ b/.zpkg.toml @@ -0,0 +1,39 @@ +[package] +org = "zed-pkg" +name = "zed-interfaces" +version = "0.1.0" +description = "Core manifest, lockfile, registry, version, and package-model interfaces for zed-pkg" +license = "MIT" +keywords = ["package-manager", "interfaces", "manifest", "lockfile", "rust"] +language = "rust" + +[package.repository] +vcs = "git" +url = "https://github.com/zed-pkg/zed-interfaces" + +[publish] +include_readme = true +tag_format = "v{version}" +smoke_test = "cargo test --manifest-path \"$ZED_PKG_TEST_TARGET/Cargo.toml\" --locked" +exclude = [ + ".env", + ".env.*", + ".direnv/**", + ".zed/**", + ".zed-pack/**", + "target/**", + "**/target/**", + "tmp/**", + "**/*.log", + ".DS_Store", +] + +[publish.native] +registry = "crates-io" +package = "zed-interfaces" + +[install] +adapter = "rust" + +[scripts] +test = "cargo test --all-targets --locked" From 169e599646a75d874c3ec0351d3088b4f86aaca2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 11:59:55 -0500 Subject: [PATCH 173/191] ci: require canonical repository targets for targeted packages (#34) Require exactly one `[targets.repository]` rooted at `.` whenever a manifest declares targets, while preserving the latest parser pin and all existing package-contract checks. --- .github/workflows/validate-zed-package.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-zed-package.yml b/.github/workflows/validate-zed-package.yml index 225601a..8b4e886 100644 --- a/.github/workflows/validate-zed-package.yml +++ b/.github/workflows/validate-zed-package.yml @@ -125,6 +125,7 @@ jobs: } } + let mut repository_targets = Vec::new(); for (name, target) in &manifest.targets { let path = root.join(&target.dir); if !path.is_dir() { @@ -133,6 +134,24 @@ jobs: target.dir )); } + if target.dir == "." { + repository_targets.push(name.as_str()); + } + } + + if !manifest.targets.is_empty() { + if repository_targets.len() != 1 { + fail(format!( + "targeted packages must declare exactly one canonical `[targets.repository]` with `dir = \".\"`; found {} root target(s)", + repository_targets.len() + )); + } + if repository_targets[0] != "repository" { + fail(format!( + "canonical root target must be named `repository`, found `{}`", + repository_targets[0] + )); + } } let ignore = fs::read_to_string(root.join(".gitignore")) @@ -150,11 +169,12 @@ jobs: } println!( - "validated {}/{}@{} with {} target(s) and {} locked package(s)", + "validated {}/{}@{} with {} target(s), {} canonical root target(s), and {} locked package(s)", manifest.package.org, manifest.package.name, manifest.package.version, manifest.targets.len(), + repository_targets.len(), lock.packages.len() ); } From 933155fc41dc6443424d06b618137b23dc434835 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 16:53:17 -0500 Subject: [PATCH 174/191] ci: certify canonical Zed package artifacts (#35) Extend the reusable validator to build real canonical and target archives with the pinned Zed packer, inspect their identities and contents, and preserve all existing manifest, lockfile, workflow, and permission checks. Validated by successful Akrion multi-target and ACT single-target canaries. --- .github/workflows/validate-zed-package.yml | 182 ++++++++++++++++++++- 1 file changed, 175 insertions(+), 7 deletions(-) diff --git a/.github/workflows/validate-zed-package.yml b/.github/workflows/validate-zed-package.yml index 8b4e886..94a30bb 100644 --- a/.github/workflows/validate-zed-package.yml +++ b/.github/workflows/validate-zed-package.yml @@ -26,9 +26,9 @@ concurrency: jobs: validate: - name: Validate canonical package contract + name: Validate and pack canonical package contract runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Check out package @@ -42,7 +42,7 @@ jobs: with: toolchain: stable - - name: Validate with the pinned zed-interfaces parser + - name: Validate and pack with pinned Zed libraries shell: bash run: | set -euo pipefail @@ -56,11 +56,21 @@ jobs: publish = false [dependencies] - zed-interfaces = { git = "https://github.com/zed-pkg/zed-interfaces.git", rev = "2082012d1903783a89200c3bd44d2659a6daf872" } + flate2 = "1" + tar = "0.4" + tempfile = "3" + zed-cli = { git = "https://github.com/zed-pkg/zed-cli.git", rev = "0c1931bcdc2065279aea06abe9d66a0eb9e03315" } + zed-interfaces = { git = "https://github.com/zed-pkg/zed-interfaces.git", rev = "c2e049006453c26ca8ca291783f681fce75cb01f" } TOML cat > "$validator/src/main.rs" <<'RUST' - use std::{env, fs, path::PathBuf, process}; + use std::{ + collections::{BTreeMap, BTreeSet}, + env, fs, + path::{Path, PathBuf}, + process, + }; + use flate2::read::GzDecoder; use zed_interfaces::{Lockfile, Manifest}; fn fail(message: impl std::fmt::Display) -> ! { @@ -68,6 +78,109 @@ jobs: process::exit(1); } + fn archive_files(path: &Path) -> BTreeSet { + let file = fs::File::open(path) + .unwrap_or_else(|error| fail(format!("opening {}: {error}", path.display()))); + let mut archive = tar::Archive::new(GzDecoder::new(file)); + archive + .entries() + .unwrap_or_else(|error| { + fail(format!("reading archive entries from {}: {error}", path.display())) + }) + .map(|entry| { + let entry = entry.unwrap_or_else(|error| { + fail(format!("reading archive entry from {}: {error}", path.display())) + }); + entry + .path() + .unwrap_or_else(|error| { + fail(format!("reading archive path from {}: {error}", path.display())) + }) + .to_string_lossy() + .to_string() + }) + .collect() + } + + fn validate_artifact( + root_manifest: &Manifest, + package: &zed_cli::pack::PackagedTarget, + expected_name: &str, + output: &Path, + ) { + if package.manifest.package.org != root_manifest.package.org { + fail(format!( + "packed package `{expected_name}` changed org from `{}` to `{}`", + root_manifest.package.org, package.manifest.package.org + )); + } + if package.manifest.package.version != root_manifest.package.version { + fail(format!( + "packed package `{expected_name}` changed version from `{}` to `{}`", + root_manifest.package.version, package.manifest.package.version + )); + } + if package.manifest.package.name != expected_name { + fail(format!( + "packed package name `{}` does not match expected `{expected_name}`", + package.manifest.package.name + )); + } + + let expected_file = format!( + "{}-{}-{}.tar.gz", + root_manifest.package.org, expected_name, root_manifest.package.version + ); + let actual_file = package + .packed + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_else(|| fail("packed artifact has no UTF-8 file name")); + if actual_file != expected_file { + fail(format!( + "artifact `{actual_file}` does not match expected `{expected_file}`" + )); + } + if package.packed.path.parent() != Some(output) { + fail(format!( + "artifact {} escaped output directory {}", + package.packed.path.display(), + output.display() + )); + } + if package.packed.size == 0 || package.packed.file_count == 0 { + fail(format!("artifact `{actual_file}` is empty")); + } + if package.packed.sha256.len() != 64 + || !package + .packed + .sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + fail(format!("artifact `{actual_file}` has an invalid sha256")); + } + + let files = archive_files(&package.packed.path); + if !files.contains("pkg/.zpkg.toml") { + fail(format!("artifact `{actual_file}` is missing pkg/.zpkg.toml")); + } + for forbidden in [ + "pkg/.git/", + "pkg/.github/", + "pkg/.zed/pack/", + "pkg/.zpkg-staging/", + "pkg/zed_modules/", + ] { + if files.iter().any(|path| path.starts_with(forbidden)) { + fail(format!( + "artifact `{actual_file}` contains forbidden path prefix `{forbidden}`" + )); + } + } + } + fn main() { let root = env::args_os() .nth(1) @@ -168,12 +281,67 @@ jobs: } } + let output = tempfile::tempdir() + .unwrap_or_else(|error| fail(format!("creating artifact output: {error}"))); + let packages = zed_cli::pack::pack_all(&root, &manifest, Some(output.path())) + .unwrap_or_else(|error| fail(format!("packing repository: {error:#}"))); + + let mut expected_packages = BTreeMap::::new(); + if manifest.targets.is_empty() { + expected_packages.insert(String::new(), manifest.package.name.clone()); + } else { + for (target, derived_name) in manifest.target_package_names() { + let section = manifest.targets.get(&target).unwrap_or_else(|| { + fail(format!("target `{target}` disappeared after parsing")) + }); + let package_name = if section.dir == "." { + manifest.package.name.clone() + } else { + derived_name + }; + expected_packages.insert(target, package_name); + } + } + + if packages.len() != expected_packages.len() { + fail(format!( + "packing emitted {} package(s), expected {}", + packages.len(), + expected_packages.len() + )); + } + + let mut emitted_names = BTreeSet::new(); + for package in &packages { + let target = package.target.as_deref().unwrap_or(""); + let expected_package_name = expected_packages.remove(target).unwrap_or_else(|| { + fail(format!("packing emitted unexpected target `{target}`")) + }); + if !emitted_names.insert(expected_package_name.clone()) { + fail(format!( + "packing emitted duplicate package name `{expected_package_name}`" + )); + } + validate_artifact( + &manifest, + package, + &expected_package_name, + output.path(), + ); + } + if !expected_packages.is_empty() { + fail(format!( + "packing omitted target(s): {}", + expected_packages.keys().cloned().collect::>().join(", ") + )); + } + println!( - "validated {}/{}@{} with {} target(s), {} canonical root target(s), and {} locked package(s)", + "validated and packed {}/{}@{} into {} artifact(s), with {} canonical root target(s) and {} locked package(s)", manifest.package.org, manifest.package.name, manifest.package.version, - manifest.targets.len(), + packages.len(), repository_targets.len(), lock.packages.len() ); From e7b9e277dd2729811f293d5405a8fff38c026eaf Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 17:13:12 -0500 Subject: [PATCH 175/191] feat: define native dependency and install hook contracts (#16) Add canonical package- and target-level native dependency and lifecycle-hook declarations, strict validation, deterministic merge/projection behavior, generated schemas, and edge-case coverage. --- schemas/manifest.json | 46 ++++++ schemas/publish-meta.json | 46 ++++++ src/lib.rs | 5 +- src/manifest.rs | 248 +++++++++++++++++++++++++++++++- tests/install_contract.rs | 238 ++++++++++++++++++++++++++++++ tests/install_contract_edges.rs | 184 ++++++++++++++++++++++++ 6 files changed, 765 insertions(+), 2 deletions(-) create mode 100644 tests/install_contract.rs create mode 100644 tests/install_contract_edges.rs diff --git a/schemas/manifest.json b/schemas/manifest.json index 7d88fd8..b6f1188 100644 --- a/schemas/manifest.json +++ b/schemas/manifest.json @@ -36,10 +36,24 @@ "type": "string" } }, + "hooks": { + "description": "Package-local lifecycle hooks. They run in a writable staging copy,\nnever in the immutable source store and never in the consumer project.\n`pre-install` runs before `[build]`; `post-install` runs after it and\nbefore the finalized artifact is promoted to the platform cache.", + "$ref": "#/$defs/InstallHooksSection" + }, "install": { "description": "Where zed materializes the (few, hand-picked) dependencies it sources —\nzed complements npm/maven/etc. rather than replacing them, so this dir\nsits alongside the native one and the ecosystem adapter wires it into\nthe toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir`\ndefaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or\n`.deps/.zed`.", "$ref": "#/$defs/InstallSection" }, + "native-dependencies": { + "description": "Host-native packages required before this package's install hooks or\nbuild step can run. Keys are supported package-manager names (`apt`,\n`apk`, `brew`, `nix`, ...); values are package specs passed as argv,\nnever interpolated into a shell command. Installing these prerequisites\nis an explicitly consented zed operation, separate from build-hook\nconsent.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, "overrides": { "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing `[build]` step without waiting on upstream).", "$ref": "#/$defs/OverridesSection" @@ -163,6 +177,24 @@ "bitbucket-packages" ] }, + "InstallHooksSection": { + "description": "Package-local lifecycle hooks. Commands are author-controlled shell code,\nbut execution is separately consented by the installer and occurs only in\na writable staging tree.", + "type": "object", + "properties": { + "post-install": { + "type": "array", + "items": { + "type": "string" + } + }, + "pre-install": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "InstallSection": { "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", "type": "object", @@ -502,6 +534,10 @@ "description": "Override the ecosystem this target publishes into. Omit it (the normal\ncase) and it is derived from the target key via [`Language::ecosystem`].\nDeclare it when the key does not determine consumption — a `rust-wasm`\ntarget is consumed by a JS bundler, not by Cargo.", "$ref": "#/$defs/Ecosystem" }, + "hooks": { + "description": "Target-specific lifecycle hooks, appended after package-level hooks in\neach phase when the target is selected.", + "$ref": "#/$defs/InstallHooksSection" + }, "name": { "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", "type": [ @@ -520,6 +556,16 @@ } ] }, + "native-dependencies": { + "description": "Native prerequisites added by this target. Entries merge with the\npackage-level `[native-dependencies]` table when this target is selected.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, "nix": { "description": "Optional deterministic Nix export intent for this isolated target.", "anyOf": [ diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json index d6cbe20..4fa785e 100644 --- a/schemas/publish-meta.json +++ b/schemas/publish-meta.json @@ -124,6 +124,24 @@ "bitbucket-packages" ] }, + "InstallHooksSection": { + "description": "Package-local lifecycle hooks. Commands are author-controlled shell code,\nbut execution is separately consented by the installer and occurs only in\na writable staging tree.", + "type": "object", + "properties": { + "post-install": { + "type": "array", + "items": { + "type": "string" + } + }, + "pre-install": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "InstallSection": { "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", "type": "object", @@ -246,10 +264,24 @@ "type": "string" } }, + "hooks": { + "description": "Package-local lifecycle hooks. They run in a writable staging copy,\nnever in the immutable source store and never in the consumer project.\n`pre-install` runs before `[build]`; `post-install` runs after it and\nbefore the finalized artifact is promoted to the platform cache.", + "$ref": "#/$defs/InstallHooksSection" + }, "install": { "description": "Where zed materializes the (few, hand-picked) dependencies it sources —\nzed complements npm/maven/etc. rather than replacing them, so this dir\nsits alongside the native one and the ecosystem adapter wires it into\nthe toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir`\ndefaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or\n`.deps/.zed`.", "$ref": "#/$defs/InstallSection" }, + "native-dependencies": { + "description": "Host-native packages required before this package's install hooks or\nbuild step can run. Keys are supported package-manager names (`apt`,\n`apk`, `brew`, `nix`, ...); values are package specs passed as argv,\nnever interpolated into a shell command. Installing these prerequisites\nis an explicitly consented zed operation, separate from build-hook\nconsent.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, "overrides": { "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing `[build]` step without waiting on upstream).", "$ref": "#/$defs/OverridesSection" @@ -548,6 +580,10 @@ "description": "Override the ecosystem this target publishes into. Omit it (the normal\ncase) and it is derived from the target key via [`Language::ecosystem`].\nDeclare it when the key does not determine consumption — a `rust-wasm`\ntarget is consumed by a JS bundler, not by Cargo.", "$ref": "#/$defs/Ecosystem" }, + "hooks": { + "description": "Target-specific lifecycle hooks, appended after package-level hooks in\neach phase when the target is selected.", + "$ref": "#/$defs/InstallHooksSection" + }, "name": { "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", "type": [ @@ -566,6 +602,16 @@ } ] }, + "native-dependencies": { + "description": "Native prerequisites added by this target. Entries merge with the\npackage-level `[native-dependencies]` table when this target is selected.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, "nix": { "description": "Optional deterministic Nix export intent for this isolated target.", "anyOf": [ diff --git a/src/lib.rs b/src/lib.rs index e2ae0df..9772698 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,7 +41,10 @@ pub use environment_v2::{ }; pub use language::{Ecosystem, Language, detect_ecosystems}; pub use lockfile::{LockedPackage, Lockfile, LockfileError}; -pub use manifest::{Manifest, ManifestError, NixExportRoute}; +pub use manifest::{ + InstallHooksSection, Manifest, ManifestError, NATIVE_PACKAGE_MANAGERS, NativeDependencies, + NixExportRoute, +}; pub use nix::{ NIX_ADAPTER_SCHEMA_V1, NixAdapterRecord, NixBuilderNetwork, NixExportMode, NixExportSection, NixInteropArtifact, NixInteropError, NixOutputOrigin, NixPackageIdentity, NixPolicyEvidence, diff --git a/src/manifest.rs b/src/manifest.rs index 76b3b98..e75a798 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -55,6 +55,25 @@ pub struct Manifest { skip_serializing_if = "BTreeMap::is_empty" )] pub build_dependencies: BTreeMap, + /// Host-native packages required before this package's install hooks or + /// build step can run. Keys are supported package-manager names (`apt`, + /// `apk`, `brew`, `nix`, ...); values are package specs passed as argv, + /// never interpolated into a shell command. Installing these prerequisites + /// is an explicitly consented zed operation, separate from build-hook + /// consent. + #[serde( + default, + rename = "native-dependencies", + alias = "native_dependencies", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub native_dependencies: NativeDependencies, + /// Package-local lifecycle hooks. They run in a writable staging copy, + /// never in the immutable source store and never in the consumer project. + /// `pre-install` runs before `[build]`; `post-install` runs after it and + /// before the finalized artifact is promoted to the platform cache. + #[serde(default, skip_serializing_if = "InstallHooksSection::is_empty")] + pub hooks: InstallHooksSection, /// This package's own post-extract build step (compiled extensions, /// codegen), run when the package ships source that needs compiling. /// Builds run in an isolated staging copy — never inside the immutable @@ -285,6 +304,19 @@ pub struct TargetSection { /// Optional deterministic Nix export intent for this isolated target. #[serde(default, skip_serializing_if = "Option::is_none")] pub nix: Option, + /// Native prerequisites added by this target. Entries merge with the + /// package-level `[native-dependencies]` table when this target is selected. + #[serde( + default, + rename = "native-dependencies", + alias = "native_dependencies", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub native_dependencies: NativeDependencies, + /// Target-specific lifecycle hooks, appended after package-level hooks in + /// each phase when the target is selected. + #[serde(default, skip_serializing_if = "InstallHooksSection::is_empty")] + pub hooks: InstallHooksSection, /// Override the ecosystem this target publishes into. Omit it (the normal /// case) and it is derived from the target key via [`Language::ecosystem`]. /// Declare it when the key does not determine consumption — a `rust-wasm` @@ -618,6 +650,146 @@ fn is_valid_go_module(value: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '-' | '_' | '~')) } +/// Supported host package managers for `[native-dependencies]`. +/// +/// The manifest names *packages*, never an installer command. zed maps these +/// stable identifiers to fixed argv templates and rejects unknown keys, so a +/// package cannot disguise arbitrary privileged shell execution as dependency +/// installation. +pub const NATIVE_PACKAGE_MANAGERS: &[&str] = &[ + "apk", "apt", "brew", "choco", "dnf", "nix", "pacman", "pkg", "port", "scoop", "winget", + "xbps", "yum", "zypper", +]; + +/// Manager name to package specs. A manager may intentionally map to an empty +/// list to state that it is supported without adding target-specific packages. +pub type NativeDependencies = BTreeMap>; + +/// Package-local lifecycle hooks. Commands are author-controlled shell code, +/// but execution is separately consented by the installer and occurs only in +/// a writable staging tree. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct InstallHooksSection { + #[serde( + rename = "pre-install", + alias = "pre_install", + skip_serializing_if = "Vec::is_empty" + )] + pub pre_install: Vec, + #[serde( + rename = "post-install", + alias = "post_install", + skip_serializing_if = "Vec::is_empty" + )] + pub post_install: Vec, +} + +impl InstallHooksSection { + pub fn is_empty(&self) -> bool { + self.pre_install.is_empty() && self.post_install.is_empty() + } + + /// Package hooks run before target-specific hooks in each phase. + pub fn merged(&self, target: &Self) -> Self { + let mut merged = self.clone(); + merged + .pre_install + .extend(target.pre_install.iter().cloned()); + merged + .post_install + .extend(target.post_install.iter().cloned()); + merged + } +} + +fn merged_native_dependencies( + package: &NativeDependencies, + target: &NativeDependencies, +) -> NativeDependencies { + let mut merged = package.clone(); + for (manager, packages) in target { + let existing = merged.entry(manager.clone()).or_default(); + let mut seen: BTreeSet = existing.iter().cloned().collect(); + for package in packages { + if seen.insert(package.clone()) { + existing.push(package.clone()); + } + } + } + merged +} + +fn validate_native_dependencies( + dependencies: &NativeDependencies, + context: &str, +) -> Result<(), ManifestError> { + for (manager, packages) in dependencies { + if !NATIVE_PACKAGE_MANAGERS.contains(&manager.as_str()) { + return Err(ManifestError::InvalidNativeDependency( + context.to_string(), + format!( + "package manager `{manager}` is unsupported; expected one of {}", + NATIVE_PACKAGE_MANAGERS.join(", ") + ), + )); + } + let mut seen = BTreeSet::new(); + for package in packages { + if package.trim() != package + || package.is_empty() + || package.len() > 256 + || package.starts_with('-') + || package.chars().any(char::is_whitespace) + || package.chars().any(char::is_control) + { + return Err(ManifestError::InvalidNativeDependency( + context.to_string(), + format!( + "invalid `{manager}` package spec `{package}`; specs must be 1-256 non-whitespace, non-control characters and cannot begin with `-`" + ), + )); + } + if !seen.insert(package) { + return Err(ManifestError::InvalidNativeDependency( + context.to_string(), + format!("duplicate `{manager}` package spec `{package}`"), + )); + } + } + } + Ok(()) +} + +fn validate_install_hooks(hooks: &InstallHooksSection, context: &str) -> Result<(), ManifestError> { + for (phase, commands) in [ + ("pre-install", &hooks.pre_install), + ("post-install", &hooks.post_install), + ] { + for (index, command) in commands.iter().enumerate() { + if command.trim().is_empty() { + return Err(ManifestError::InvalidInstallHook( + context.to_string(), + format!("{phase} command {} must not be empty", index + 1), + )); + } + if command.contains('\0') { + return Err(ManifestError::InvalidInstallHook( + context.to_string(), + format!("{phase} command {} contains NUL", index + 1), + )); + } + if command.len() > 32 * 1024 { + return Err(ManifestError::InvalidInstallHook( + context.to_string(), + format!("{phase} command {} exceeds 32768 bytes", index + 1), + )); + } + } + } + Ok(()) +} + /// A post-extract build step. Because compiled output is OS/arch-specific, /// zed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the /// source and caches the result in a build cache keyed by @@ -677,6 +849,10 @@ pub enum ManifestError { InvalidBin(String, String), #[error("invalid build section: {0}")] InvalidBuild(String), + #[error("invalid native dependency declaration for `{0}`: {1}")] + InvalidNativeDependency(String, String), + #[error("invalid install hook declaration for `{0}`: {1}")] + InvalidInstallHook(String, String), #[error("invalid workspace member pattern `{0}`")] InvalidWorkspaceMember(String), #[error("invalid install dir `{0}`: {1}")] @@ -931,6 +1107,8 @@ impl Manifest { })?; nix_attributes.insert(nix.resolved_attribute(&self.package.name), "repository"); } + validate_native_dependencies(&self.native_dependencies, "package")?; + validate_install_hooks(&self.hooks, "package")?; for (name, target) in &self.targets { if !is_target_name(name) { return Err(ManifestError::InvalidTarget( @@ -1006,6 +1184,8 @@ impl Manifest { ), )); } + validate_native_dependencies(&target.native_dependencies, &format!("target `{name}`"))?; + validate_install_hooks(&target.hooks, &format!("target `{name}`"))?; if let Some(native) = &target.native { if target.dir == "." { return Err(ManifestError::InvalidNativeRoute( @@ -1279,6 +1459,9 @@ impl Manifest { // the manifest inside the Zed artifact remains self-describing. derived.publish.native = section.native.clone(); derived.publish.nix = section.nix.clone(); + derived.native_dependencies = + merged_native_dependencies(&self.native_dependencies, §ion.native_dependencies); + derived.hooks = self.hooks.merged(§ion.hooks); derived.targets = BTreeMap::new(); derived.workspace = None; // The consumer-facing wiring for this ecosystem. @@ -1374,6 +1557,69 @@ impl Manifest { } } + /// Native prerequisites for the selected polyglot target. Package-level + /// entries are inherited; target entries append in declaration order and + /// are deterministically de-duplicated per manager. + pub fn effective_native_dependencies( + &self, + requested: Option<&str>, + ) -> Result { + let Some(requested) = requested else { + return Ok(self.native_dependencies.clone()); + }; + if self.targets.is_empty() { + return Ok(self.native_dependencies.clone()); + } + let key = self.resolve_target_key(requested).ok_or_else(|| { + let mut available: Vec<&str> = self.targets.keys().map(String::as_str).collect(); + available.sort_unstable(); + ManifestError::InvalidTarget( + requested.to_string(), + format!( + "package `{}/{}` publishes no such target; it provides: {}", + self.package.org, + self.package.name, + available.join(", ") + ), + ) + })?; + let target = self.targets.get(key).expect("resolved target exists"); + Ok(merged_native_dependencies( + &self.native_dependencies, + &target.native_dependencies, + )) + } + + /// Lifecycle hooks for the selected target. Package hooks run before + /// target hooks in each phase. + pub fn effective_install_hooks( + &self, + requested: Option<&str>, + ) -> Result { + let Some(requested) = requested else { + return Ok(self.hooks.clone()); + }; + if self.targets.is_empty() { + return Ok(self.hooks.clone()); + } + let key = self.resolve_target_key(requested).ok_or_else(|| { + let mut available: Vec<&str> = self.targets.keys().map(String::as_str).collect(); + available.sort_unstable(); + ManifestError::InvalidTarget( + requested.to_string(), + format!( + "package `{}/{}` publishes no such target; it provides: {}", + self.package.org, + self.package.name, + available.join(", ") + ), + ) + })?; + Ok(self + .hooks + .merged(&self.targets.get(key).expect("resolved target exists").hooks)) + } + /// True when this manifest declares a non-empty monorepo workspace. pub fn is_workspace_root(&self) -> bool { self.workspace diff --git a/tests/install_contract.rs b/tests/install_contract.rs new file mode 100644 index 0000000..05a1a53 --- /dev/null +++ b/tests/install_contract.rs @@ -0,0 +1,238 @@ +use std::collections::BTreeMap; + +use zed_interfaces::manifest::{ + InstallHooksSection, Manifest, ManifestError, NATIVE_PACKAGE_MANAGERS, +}; + +fn manifest(extra: &str) -> String { + format!( + r#" +[package] +org = "acme" +name = "native-tool" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/native-tool" + +{extra} +"# + ) +} + +#[test] +fn native_dependencies_and_hooks_roundtrip_canonically() { + let parsed = Manifest::parse(&manifest( + r#" +[native_dependencies] +apt = ["pkg-config", "libssl-dev"] +brew = ["pkg-config", "openssl@3"] +nix = ["pkg-config", "openssl"] + +[hooks] +pre_install = ["./scripts/pre-install.sh"] +post_install = ["./scripts/post-install.sh"] +"#, + )) + .unwrap(); + + assert_eq!( + parsed.native_dependencies["apt"], + vec!["pkg-config", "libssl-dev"] + ); + assert_eq!( + parsed.hooks, + InstallHooksSection { + pre_install: vec!["./scripts/pre-install.sh".to_string()], + post_install: vec!["./scripts/post-install.sh".to_string()], + } + ); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[native-dependencies]"), "{encoded}"); + assert!(encoded.contains("pre-install ="), "{encoded}"); + assert!(encoded.contains("post-install ="), "{encoded}"); + assert!(!encoded.contains("native_dependencies"), "{encoded}"); + assert!(!encoded.contains("pre_install"), "{encoded}"); + assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); +} + +#[test] +fn target_native_dependencies_and_hooks_merge_in_order() { + let parsed = Manifest::parse(&manifest( + r#" +[native-dependencies] +apt = ["pkg-config", "libssl-dev"] +brew = ["pkg-config"] + +[hooks] +pre-install = ["echo package-pre"] +post-install = ["echo package-post"] + +[targets.rust] +dir = "clients/rust" + +[targets.rust.native-dependencies] +apt = ["clang", "libssl-dev"] +brew = ["llvm"] + +[targets.rust.hooks] +pre-install = ["echo target-pre"] +post-install = ["echo target-post"] +"#, + )) + .unwrap(); + + let native = parsed.effective_native_dependencies(Some("rust")).unwrap(); + assert_eq!(native["apt"], vec!["pkg-config", "libssl-dev", "clang"]); + assert_eq!(native["brew"], vec!["pkg-config", "llvm"]); + + let hooks = parsed.effective_install_hooks(Some("rust")).unwrap(); + assert_eq!( + hooks.pre_install, + vec!["echo package-pre", "echo target-pre"] + ); + assert_eq!( + hooks.post_install, + vec!["echo package-post", "echo target-post"] + ); + + let derived = parsed.manifest_for_target("rust").unwrap(); + assert!(derived.targets.is_empty()); + assert_eq!(derived.native_dependencies, native); + assert_eq!(derived.hooks, hooks); +} + +#[test] +fn an_unselected_target_does_not_leak_native_install_metadata() { + let parsed = Manifest::parse(&manifest( + r#" +[native-dependencies] +apt = ["pkg-config"] + +[targets.rust] +dir = "clients/rust" +[targets.rust.native-dependencies] +apt = ["clang"] + +[targets.node] +dir = "clients/node" +[targets.node.native-dependencies] +apt = ["nodejs"] +"#, + )) + .unwrap(); + + assert_eq!( + parsed.effective_native_dependencies(Some("node")).unwrap()["apt"], + vec!["pkg-config", "nodejs"] + ); + assert_eq!( + parsed.effective_native_dependencies(None).unwrap()["apt"], + vec!["pkg-config"] + ); +} + +#[test] +fn every_documented_native_manager_parses_even_with_no_packages() { + let mut source = manifest(""); + source.push_str("\n[native-dependencies]\n"); + for manager in NATIVE_PACKAGE_MANAGERS { + source.push_str(&format!("{manager} = []\n")); + } + let parsed = Manifest::parse(&source).unwrap(); + assert_eq!( + parsed.native_dependencies.len(), + NATIVE_PACKAGE_MANAGERS.len() + ); +} + +#[test] +fn unsafe_or_ambiguous_native_package_specs_are_rejected() { + for package in ["", "-y", "two words", "line\nbreak", "\u{7f}"] { + let source = manifest(&format!( + "[native-dependencies]\napt = [{}]\n", + toml::Value::String(package.to_string()) + )); + assert!(matches!( + Manifest::parse(&source), + Err(ManifestError::InvalidNativeDependency(_, _)) + )); + } + + let duplicate = manifest( + r#" +[native-dependencies] +apt = ["pkg-config", "pkg-config"] +"#, + ); + assert!(matches!( + Manifest::parse(&duplicate), + Err(ManifestError::InvalidNativeDependency(_, _)) + )); +} + +#[test] +fn unsupported_native_manager_is_rejected_with_the_allowlist() { + let error = Manifest::parse(&manifest( + r#" +[native-dependencies] +curl-pipe-sh = ["anything"] +"#, + )) + .unwrap_err(); + let message = error.to_string(); + assert!(matches!( + error, + ManifestError::InvalidNativeDependency(_, _) + )); + assert!(message.contains("unsupported"), "{message}"); + assert!(message.contains("apt"), "{message}"); + assert!(message.contains("nix"), "{message}"); +} + +#[test] +fn empty_and_oversized_install_hooks_are_rejected() { + for (key, value) in [ + ("pre-install", " ".to_string()), + ("post-install", "x".repeat(32 * 1024 + 1)), + ] { + let source = manifest(&format!( + "[hooks]\n{key} = [{}]\n", + toml::Value::String(value) + )); + assert!(matches!( + Manifest::parse(&source), + Err(ManifestError::InvalidInstallHook(_, _)) + )); + } +} + +#[test] +fn effective_install_metadata_rejects_an_unknown_explicit_target() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.rust] +dir = "clients/rust" +"#, + )) + .unwrap(); + assert!(matches!( + parsed.effective_native_dependencies(Some("python")), + Err(ManifestError::InvalidTarget(_, _)) + )); + assert!(matches!( + parsed.effective_install_hooks(Some("python")), + Err(ManifestError::InvalidTarget(_, _)) + )); +} + +#[test] +fn empty_default_sections_are_omitted() { + let parsed = Manifest::parse(&manifest("")).unwrap(); + assert_eq!(parsed.native_dependencies, BTreeMap::new()); + assert!(parsed.hooks.is_empty()); + let encoded = parsed.to_toml_string().unwrap(); + assert!(!encoded.contains("native-dependencies")); + assert!(!encoded.contains("[hooks]")); +} diff --git a/tests/install_contract_edges.rs b/tests/install_contract_edges.rs new file mode 100644 index 0000000..dd65966 --- /dev/null +++ b/tests/install_contract_edges.rs @@ -0,0 +1,184 @@ +use zed_interfaces::manifest::{Manifest, ManifestError}; + +fn manifest(extra: &str) -> String { + format!( + r#" +[package] +org = "acme" +name = "native-edge" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/native-edge" + +{extra} +"# + ) +} + +#[test] +fn target_language_synonyms_select_the_same_native_metadata() { + let parsed = Manifest::parse(&manifest( + r#" +[native-dependencies] +apt = ["pkg-config"] + +[hooks] +pre-install = ["echo package-pre"] + +[targets.nodejs] +dir = "clients/node" + +[targets.nodejs.native-dependencies] +apt = ["nodejs"] + +[targets.nodejs.hooks] +pre-install = ["echo node-pre"] +post-install = ["echo node-post"] +"#, + )) + .unwrap(); + + let native = parsed.effective_native_dependencies(Some("node")).unwrap(); + assert_eq!(native["apt"], vec!["pkg-config", "nodejs"]); + + let hooks = parsed.effective_install_hooks(Some("node")).unwrap(); + assert_eq!(hooks.pre_install, vec!["echo package-pre", "echo node-pre"]); + assert_eq!(hooks.post_install, vec!["echo node-post"]); +} + +#[test] +fn target_aliases_serialize_to_the_canonical_keys() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.rust] +dir = "clients/rust" + +[targets.rust.native_dependencies] +apt = ["clang"] + +[targets.rust.hooks] +pre_install = ["echo pre"] +post_install = ["echo post"] +"#, + )) + .unwrap(); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[targets.rust.native-dependencies]")); + assert!(encoded.contains("pre-install =")); + assert!(encoded.contains("post-install =")); + assert!(!encoded.contains("native_dependencies")); + assert!(!encoded.contains("pre_install")); + assert!(!encoded.contains("post_install")); +} + +#[test] +fn manifest_target_projection_applies_lifecycle_metadata_exactly_once() { + let parsed = Manifest::parse(&manifest( + r#" +[native-dependencies] +apt = ["pkg-config", "libssl-dev"] + +[hooks] +pre-install = ["echo package-pre"] +post-install = ["echo package-post"] + +[targets.rust] +dir = "clients/rust" + +[targets.rust.native-dependencies] +apt = ["clang", "libssl-dev"] + +[targets.rust.hooks] +pre-install = ["echo target-pre"] +post-install = ["echo target-post"] +"#, + )) + .unwrap(); + + let projected = parsed.manifest_for_target("rust").unwrap(); + assert!(projected.targets.is_empty()); + assert_eq!( + projected.effective_native_dependencies(None).unwrap()["apt"], + vec!["pkg-config", "libssl-dev", "clang"] + ); + assert_eq!( + projected.effective_install_hooks(None).unwrap().pre_install, + vec!["echo package-pre", "echo target-pre"] + ); + assert_eq!( + projected + .effective_install_hooks(None) + .unwrap() + .post_install, + vec!["echo package-post", "echo target-post"] + ); + assert!(projected.manifest_for_target("rust").is_none()); + + let reparsed = Manifest::parse(&projected.to_toml_string().unwrap()).unwrap(); + assert_eq!(reparsed, projected); +} + +#[test] +fn empty_target_manager_routes_preserve_support_without_adding_packages() { + let parsed = Manifest::parse(&manifest( + r#" +[native-dependencies] +apt = ["pkg-config"] + +[targets.rust] +dir = "clients/rust" + +[targets.rust.native-dependencies] +apt = [] +nix = [] +"#, + )) + .unwrap(); + + let native = parsed.effective_native_dependencies(Some("rust")).unwrap(); + assert_eq!(native["apt"], vec!["pkg-config"]); + assert_eq!(native["nix"], Vec::::new()); +} + +#[test] +fn native_and_hook_size_limits_accept_the_exact_boundary() { + let package = "a".repeat(256); + let hook = "x".repeat(32 * 1024); + let source = manifest(&format!( + "[native-dependencies]\napt = [{}]\n\n[hooks]\npre-install = [{}]\n", + toml::Value::String(package.clone()), + toml::Value::String(hook.clone()) + )); + + let parsed = Manifest::parse(&source).unwrap(); + assert_eq!(parsed.native_dependencies["apt"], vec![package]); + assert_eq!(parsed.hooks.pre_install, vec![hook]); +} + +#[test] +fn native_package_size_limit_rejects_the_first_oversized_value() { + let package = "a".repeat(257); + let source = manifest(&format!( + "[native-dependencies]\napt = [{}]\n", + toml::Value::String(package) + )); + + assert!(matches!( + Manifest::parse(&source), + Err(ManifestError::InvalidNativeDependency(_, _)) + )); +} + +#[test] +fn shell_metacharacters_remain_opaque_native_package_arguments() { + let package = "libssl-dev;echo-not-a-shell"; + let parsed = Manifest::parse(&manifest(&format!( + "[native-dependencies]\napt = [{}]\n", + toml::Value::String(package.to_string()) + ))) + .unwrap(); + + assert_eq!(parsed.native_dependencies["apt"], vec![package]); +} From 29addea1aca138133632c453b142c8fb0e0535cb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 18:08:25 -0500 Subject: [PATCH 176/191] feat(DEN-1420): add immutable native-registry publication contract (#36) Define strict npm/Cargo publication identities, portable/meta/platform roles, canonical deterministic JSON, immutable artifact evidence, complete topology validation, generated schema, cross-platform certification, and an external offline consumer canary. --- .github/workflows/native-registry.yml | 87 ++ docs/native-registry-contract.md | 38 + examples/generate_schemas.rs | 1 + schemas/native-registry-adapter-record.json | 207 +++++ src/lib.rs | 7 + src/native_registry.rs | 909 ++++++++++++++++++++ tests/native_registry_schema_contract.rs | 20 + 7 files changed, 1269 insertions(+) create mode 100644 .github/workflows/native-registry.yml create mode 100644 docs/native-registry-contract.md create mode 100644 schemas/native-registry-adapter-record.json create mode 100644 src/native_registry.rs create mode 100644 tests/native_registry_schema_contract.rs diff --git a/.github/workflows/native-registry.yml b/.github/workflows/native-registry.yml new file mode 100644 index 0000000..38cc781 --- /dev/null +++ b/.github/workflows/native-registry.yml @@ -0,0 +1,87 @@ +name: native registry contract + +on: + pull_request: + paths: + - docs/native-registry-contract.md + - examples/generate_schemas.rs + - schemas/native-registry-adapter-record.json + - src/lib.rs + - src/native_registry.rs + - tests/native_registry_schema_contract.rs + - .github/workflows/native-registry.yml + push: + branches: [main] + paths: + - docs/native-registry-contract.md + - examples/generate_schemas.rs + - schemas/native-registry-adapter-record.json + - src/lib.rs + - src/native_registry.rs + - tests/native_registry_schema_contract.rs + - .github/workflows/native-registry.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: native-registry-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + contract: + name: native registry / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + steps: + - name: Check out exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Normalize generated-schema line endings + run: | + set -euo pipefail + git config core.autocrlf false + git config core.eol lf + git reset --hard HEAD + + - name: Install Rust quality components + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Validate native publication contract + run: | + set -euo pipefail + cargo fmt --all --check + cargo test --locked native_registry + cargo test --locked --test native_registry_schema_contract + cargo clippy --locked --lib --test native_registry_schema_contract -- -D warnings + + - name: Require deterministic generated schema + run: | + set -euo pipefail + cargo run --locked --example generate_schemas + git diff --exit-code -- schemas + + - name: Require a clean checkout + run: | + set -euo pipefail + git diff --exit-code + git status --porcelain=v1 --untracked-files=all | tee "$RUNNER_TEMP/native-registry-status.txt" + test ! -s "$RUNNER_TEMP/native-registry-status.txt" diff --git a/docs/native-registry-contract.md b/docs/native-registry-contract.md new file mode 100644 index 0000000..db65126 --- /dev/null +++ b/docs/native-registry-contract.md @@ -0,0 +1,38 @@ +# Native registry publication contract + +`NativeRegistryAdapterRecord` binds one strict-SemVer Zed package target to the +exact immutable archives published to npm or a Cargo-compatible registry. + +The contract deliberately separates three identities: + +- **API compatibility:** `MAJOR.MINOR.PATCH[-prerelease]`; +- **platform:** explicit `os`, `arch`, and optional `libc` selectors; and +- **bytes:** lowercase SHA-256 plus archive size and format. + +SemVer build metadata is rejected at this boundary. It does not participate in +SemVer precedence, and Cargo registry indexes explicitly treat versions that +differ only in build metadata as one version. Architecture-specific artifacts +therefore use distinct package names or manager-native platform selectors while +sharing one strict version. + +A publication family may contain: + +- at most one portable package; +- at most one generic meta package, with at least one platform edge; and +- one package for each unique platform selector. + +When a meta package is present it must select every platform publication in the +record exactly once. Platform-only families remain valid when consumers select +native packages directly without a generic wrapper. + +Validation fails closed on version drift, duplicate package identities, +duplicate platforms, dangling or mismatched meta-package edges, malformed +native package names, zero-byte artifacts, uppercase or malformed digests, and +unsupported schema versions. `canonical_json_bytes()` validates first and then +sorts publications and platform edges for deterministic signing and lockfile +provenance. + +The contract is transport-neutral. It performs no npm, Cargo-registry, OCI, or +Zed-registry operation and consumes no credential. Independent certification in +`zed-pkg-test/zed-pkg-e2e` regenerates the schema, compiles an external consumer +offline, and pins the exact `zed-interfaces` product commit before promotion. diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs index 614fd05..a54f46b 100644 --- a/examples/generate_schemas.rs +++ b/examples/generate_schemas.rs @@ -28,6 +28,7 @@ fn main() { write::(dir, "nix-export-section"); write::(dir, "nix-export-plan"); write::(dir, "nix-adapter-record"); + write::(dir, "native-registry-adapter-record"); write::(dir, "oci-adapter-record"); write::(dir, "package-metadata"); write::(dir, "version-metadata"); diff --git a/schemas/native-registry-adapter-record.json b/schemas/native-registry-adapter-record.json new file mode 100644 index 0000000..aebd126 --- /dev/null +++ b/schemas/native-registry-adapter-record.json @@ -0,0 +1,207 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "NativeRegistryAdapterRecord", + "description": "Final immutable mapping from one Zed source target to a family of native\nregistry packages.", + "type": "object", + "properties": { + "publications": { + "type": "array", + "items": { + "$ref": "#/$defs/NativePublication" + } + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + }, + "schema": { + "type": "string" + }, + "source": { + "$ref": "#/$defs/ZedNativePackageIdentity" + } + }, + "required": [ + "schema", + "registry", + "source", + "publications" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "NativeArtifact": { + "description": "Exact immutable bytes published under one native package identity.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "sha256": { + "description": "Lowercase hexadecimal SHA-256 of the exact uploaded archive bytes.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size" + ] + }, + "NativePackageIdentity": { + "description": "Public identity selected in npm or a Cargo-compatible registry.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ] + }, + "NativePlatform": { + "description": "Platform identity kept outside the package version.", + "type": "object", + "properties": { + "arch": { + "type": "string" + }, + "libc": { + "type": [ + "string", + "null" + ] + }, + "os": { + "type": "string" + } + }, + "required": [ + "os", + "arch" + ] + }, + "NativePlatformPackage": { + "description": "One platform-to-package edge emitted by a generic wrapper package.", + "type": "object", + "properties": { + "package": { + "type": "string" + }, + "platform": { + "$ref": "#/$defs/NativePlatform" + } + }, + "required": [ + "platform", + "package" + ] + }, + "NativePublication": { + "description": "One uploaded package within a publication family.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NativeArtifact" + }, + "kind": { + "$ref": "#/$defs/NativePublicationKind" + }, + "package": { + "$ref": "#/$defs/NativePackageIdentity" + }, + "platform": { + "anyOf": [ + { + "$ref": "#/$defs/NativePlatform" + }, + { + "type": "null" + } + ] + }, + "platform_packages": { + "description": "Only a `meta` publication may contain selector edges. Each edge must\nreference a `platform` publication in the same adapter record.", + "type": "array", + "items": { + "$ref": "#/$defs/NativePlatformPackage" + } + } + }, + "required": [ + "package", + "kind", + "artifact" + ] + }, + "NativePublicationKind": { + "description": "Role of one package in a native-registry publication family.", + "oneOf": [ + { + "description": "One package whose payload is portable across all supported platforms.", + "type": "string", + "const": "portable" + }, + { + "description": "A generic wrapper package that selects platform-specific packages.", + "type": "string", + "const": "meta" + }, + { + "description": "One package containing bytes for exactly one explicit platform.", + "type": "string", + "const": "platform" + } + ] + }, + "NativeRegistry": { + "description": "Native registries with a first-class publication identity contract.", + "type": "string", + "enum": [ + "npm", + "cargo" + ] + }, + "ZedNativePackageIdentity": { + "description": "Strict SemVer source identity in the Zed registry.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "target": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version" + ] + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 9772698..eae40c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod excludes; pub mod language; pub mod lockfile; pub mod manifest; +pub mod native_registry; pub mod nix; pub mod nix_plan; pub mod oci; @@ -45,6 +46,12 @@ pub use manifest::{ InstallHooksSection, Manifest, ManifestError, NATIVE_PACKAGE_MANAGERS, NativeDependencies, NixExportRoute, }; +pub use native_registry::{ + NATIVE_REGISTRY_ADAPTER_SCHEMA_V1, NativeArtifact, NativePackageIdentity, NativePlatform, + NativePlatformPackage, NativePublication, NativePublicationKind, NativeRegistry, + NativeRegistryAdapterRecord, NativeRegistryError, ZedNativePackageIdentity, + native_versions_collide, semver_precedence_identity, +}; pub use nix::{ NIX_ADAPTER_SCHEMA_V1, NixAdapterRecord, NixBuilderNetwork, NixExportMode, NixExportSection, NixInteropArtifact, NixInteropError, NixOutputOrigin, NixPackageIdentity, NixPolicyEvidence, diff --git a/src/native_registry.rs b/src/native_registry.rs new file mode 100644 index 0000000..20ee3c5 --- /dev/null +++ b/src/native_registry.rs @@ -0,0 +1,909 @@ +//! Immutable publication contracts for npm, Cargo, and future native registries. +//! +//! Zed may resolve versions using its broader polyglot version model, but a +//! publication crossing a strict native-registry boundary must use one exact +//! Semantic Versioning 2.0.0 identity. Platform identity is represented by +//! explicit operating-system, architecture, and libc selectors. It is never +//! encoded in SemVer build metadata, because build metadata does not +//! participate in version precedence and some registries treat versions that +//! differ only there as the same release. + +use std::collections::{BTreeMap, BTreeSet}; + +use schemars::JsonSchema; +use semver::{BuildMetadata, Version}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::ArtifactFormat; + +/// Current immutable native-registry adapter-record schema. +pub const NATIVE_REGISTRY_ADAPTER_SCHEMA_V1: &str = "zed.native-registry-adapter/v1"; + +/// Native registries with a first-class publication identity contract. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NativeRegistry { + Npm, + Cargo, +} + +/// Role of one package in a native-registry publication family. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum NativePublicationKind { + /// One package whose payload is portable across all supported platforms. + Portable, + /// A generic wrapper package that selects platform-specific packages. + Meta, + /// One package containing bytes for exactly one explicit platform. + Platform, +} + +/// Platform identity kept outside the package version. +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, +)] +pub struct NativePlatform { + pub os: String, + pub arch: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub libc: Option, +} + +impl NativePlatform { + /// Stable human-readable selector used in diagnostics and deterministic + /// ordering. It is not a package name and does not affect version + /// precedence. + pub fn selector(&self) -> String { + match &self.libc { + Some(libc) => format!("{}-{}-{libc}", self.os, self.arch), + None => format!("{}-{}", self.os, self.arch), + } + } + + fn validate(&self, field: &str) -> Result<(), NativeRegistryError> { + validate_lower_token(&format!("{field}.os"), &self.os)?; + validate_lower_token(&format!("{field}.arch"), &self.arch)?; + if let Some(libc) = &self.libc { + validate_lower_token(&format!("{field}.libc"), libc)?; + } + Ok(()) + } +} + +/// Strict SemVer source identity in the Zed registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ZedNativePackageIdentity { + pub org: String, + pub name: String, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +impl ZedNativePackageIdentity { + fn validate(&self) -> Result<(), NativeRegistryError> { + validate_identity_component("source.org", &self.org)?; + validate_identity_component("source.name", &self.name)?; + if let Some(target) = &self.target { + validate_identity_component("source.target", target)?; + } + validate_native_version("source.version", &self.version).map(|_| ()) + } +} + +/// Public identity selected in npm or a Cargo-compatible registry. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct NativePackageIdentity { + pub name: String, + pub version: String, +} + +impl NativePackageIdentity { + fn validate(&self, registry: NativeRegistry, field: &str) -> Result<(), NativeRegistryError> { + validate_native_package_name(registry, &self.name)?; + validate_native_version(&format!("{field}.version"), &self.version).map(|_| ()) + } +} + +/// Exact immutable bytes published under one native package identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeArtifact { + /// Lowercase hexadecimal SHA-256 of the exact uploaded archive bytes. + pub sha256: String, + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, +} + +impl NativeArtifact { + fn validate(&self, field: &str) -> Result<(), NativeRegistryError> { + if self.sha256.len() != 64 + || !self + .sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(NativeRegistryError::InvalidSha256 { + field: format!("{field}.sha256"), + value: self.sha256.clone(), + }); + } + if self.size == 0 { + return Err(NativeRegistryError::EmptyArtifact { + field: field.to_string(), + }); + } + Ok(()) + } +} + +/// One platform-to-package edge emitted by a generic wrapper package. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +pub struct NativePlatformPackage { + pub platform: NativePlatform, + pub package: String, +} + +impl NativePlatformPackage { + fn validate(&self, registry: NativeRegistry, field: &str) -> Result<(), NativeRegistryError> { + self.platform.validate(&format!("{field}.platform"))?; + validate_native_package_name(registry, &self.package) + } +} + +/// One uploaded package within a publication family. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativePublication { + pub package: NativePackageIdentity, + pub kind: NativePublicationKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform: Option, + /// Only a `meta` publication may contain selector edges. Each edge must + /// reference a `platform` publication in the same adapter record. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub platform_packages: Vec, + pub artifact: NativeArtifact, +} + +impl NativePublication { + fn normalized(&self) -> Self { + let mut publication = self.clone(); + publication.platform_packages.sort(); + publication + } + + fn validate( + &self, + registry: NativeRegistry, + source_version: &str, + index: usize, + ) -> Result<(), NativeRegistryError> { + let field = format!("publications[{index}]"); + self.package + .validate(registry, &format!("{field}.package"))?; + if self.package.version != source_version { + return Err(NativeRegistryError::VersionDrift { + package: self.package.name.clone(), + source_version: source_version.to_string(), + publication_version: self.package.version.clone(), + }); + } + + match self.kind { + NativePublicationKind::Platform => { + let platform = self.platform.as_ref().ok_or_else(|| { + NativeRegistryError::PlatformRequired { + package: self.package.name.clone(), + } + })?; + platform.validate(&format!("{field}.platform"))?; + if !self.platform_packages.is_empty() { + return Err(NativeRegistryError::PlatformPackagesNotAllowed { + package: self.package.name.clone(), + kind: self.kind, + }); + } + } + NativePublicationKind::Portable | NativePublicationKind::Meta => { + if self.platform.is_some() { + return Err(NativeRegistryError::UnexpectedPlatform { + package: self.package.name.clone(), + kind: self.kind, + }); + } + if self.kind == NativePublicationKind::Portable + && !self.platform_packages.is_empty() + { + return Err(NativeRegistryError::PlatformPackagesNotAllowed { + package: self.package.name.clone(), + kind: self.kind, + }); + } + } + } + + let mut selected_platforms = BTreeSet::new(); + for (selection_index, selection) in self.platform_packages.iter().enumerate() { + selection.validate( + registry, + &format!("{field}.platform-packages[{selection_index}]"), + )?; + if !selected_platforms.insert(selection.platform.clone()) { + return Err(NativeRegistryError::DuplicateMetaPlatform { + package: self.package.name.clone(), + platform: selection.platform.selector(), + }); + } + } + self.artifact.validate(&format!("{field}.artifact")) + } +} + +/// Final immutable mapping from one Zed source target to a family of native +/// registry packages. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeRegistryAdapterRecord { + pub schema: String, + pub registry: NativeRegistry, + pub source: ZedNativePackageIdentity, + pub publications: Vec, +} + +impl NativeRegistryAdapterRecord { + pub const SCHEMA_V1: &'static str = NATIVE_REGISTRY_ADAPTER_SCHEMA_V1; + + /// Presentation-independent ordering for hashing, signing, lock + /// provenance, and generated client fixtures. + pub fn normalized(&self) -> Self { + let mut record = self.clone(); + record.publications = record + .publications + .iter() + .map(NativePublication::normalized) + .collect(); + record.publications.sort_by(|left, right| { + ( + &left.package, + left.kind, + &left.platform, + &left.platform_packages, + &left.artifact.sha256, + ) + .cmp(&( + &right.package, + right.kind, + &right.platform, + &right.platform_packages, + &right.artifact.sha256, + )) + }); + record + } + + /// Canonical compact JSON bytes. Validation runs first so invalid identity + /// cannot be made acceptable merely by normalization. + pub fn canonical_json_bytes(&self) -> Result, NativeRegistryError> { + self.validate()?; + serde_json::to_vec(&self.normalized()) + .map_err(|error| NativeRegistryError::Serialization(error.to_string())) + } + + pub fn validate(&self) -> Result<(), NativeRegistryError> { + if self.schema != Self::SCHEMA_V1 { + return Err(NativeRegistryError::UnsupportedSchema { + found: self.schema.clone(), + supported: Self::SCHEMA_V1.to_string(), + }); + } + self.source.validate()?; + if self.publications.is_empty() { + return Err(NativeRegistryError::NoPublications); + } + + let mut package_identities = BTreeSet::new(); + let mut platform_publications = BTreeMap::new(); + let mut meta_count = 0usize; + let mut portable_count = 0usize; + + for (index, publication) in self.publications.iter().enumerate() { + publication.validate(self.registry, &self.source.version, index)?; + let identity = ( + publication.package.name.clone(), + semver_precedence_identity(&publication.package.version)?, + ); + if !package_identities.insert(identity.clone()) { + return Err(NativeRegistryError::DuplicatePackageVersion { + package: identity.0, + version: identity.1, + }); + } + + match publication.kind { + NativePublicationKind::Meta => { + meta_count += 1; + if meta_count > 1 { + return Err(NativeRegistryError::MultipleMetaPackages); + } + } + NativePublicationKind::Platform => { + let platform = publication + .platform + .as_ref() + .expect("platform publication validated above") + .clone(); + if let Some(existing) = platform_publications + .insert(platform.clone(), publication.package.name.clone()) + { + return Err(NativeRegistryError::DuplicatePlatformPublication { + platform: platform.selector(), + first: existing, + second: publication.package.name.clone(), + }); + } + } + NativePublicationKind::Portable => { + portable_count += 1; + if portable_count > 1 { + return Err(NativeRegistryError::MultiplePortablePackages); + } + } + } + } + + for publication in self + .publications + .iter() + .filter(|publication| publication.kind == NativePublicationKind::Meta) + { + if publication.platform_packages.is_empty() { + return Err(NativeRegistryError::MetaPackageRequiresPlatformSelections { + package: publication.package.name.clone(), + }); + } + + let mut selected_platforms = BTreeSet::new(); + for selection in &publication.platform_packages { + selected_platforms.insert(selection.platform.clone()); + match platform_publications.get(&selection.platform) { + Some(package) if package == &selection.package => {} + Some(package) => { + return Err(NativeRegistryError::PlatformPackageMismatch { + meta_package: publication.package.name.clone(), + platform: selection.platform.selector(), + expected: package.clone(), + selected: selection.package.clone(), + }); + } + None => { + return Err(NativeRegistryError::MissingPlatformPublication { + meta_package: publication.package.name.clone(), + platform: selection.platform.selector(), + selected: selection.package.clone(), + }); + } + } + } + + for (platform, package) in &platform_publications { + if !selected_platforms.contains(platform) { + return Err(NativeRegistryError::UnselectedPlatformPublication { + meta_package: publication.package.name.clone(), + platform: platform.selector(), + package: package.clone(), + }); + } + } + } + + Ok(()) + } +} + +/// SemVer precedence identity with build metadata removed. +/// +/// Native adapters can use this before planning a publication set to detect +/// collisions such as `1.0.0+linux` and `1.0.0+darwin`. +pub fn semver_precedence_identity(version: &str) -> Result { + let mut version = + Version::parse(version).map_err(|error| NativeRegistryError::InvalidSemver { + field: "version".to_string(), + version: version.to_string(), + detail: error.to_string(), + })?; + version.build = BuildMetadata::EMPTY; + Ok(version.to_string()) +} + +/// Whether two valid SemVer strings identify the same native-registry version +/// after build metadata is ignored. +pub fn native_versions_collide(left: &str, right: &str) -> Result { + Ok(semver_precedence_identity(left)? == semver_precedence_identity(right)?) +} + +fn validate_native_version(field: &str, version: &str) -> Result { + let parsed = Version::parse(version).map_err(|error| NativeRegistryError::InvalidSemver { + field: field.to_string(), + version: version.to_string(), + detail: error.to_string(), + })?; + if parsed.build != BuildMetadata::EMPTY { + return Err(NativeRegistryError::BuildMetadataNotAllowed { + field: field.to_string(), + version: version.to_string(), + }); + } + Ok(parsed) +} + +fn validate_native_package_name( + registry: NativeRegistry, + name: &str, +) -> Result<(), NativeRegistryError> { + match registry { + NativeRegistry::Cargo => validate_cargo_name(name), + NativeRegistry::Npm => validate_npm_name(name), + } +} + +fn validate_cargo_name(name: &str) -> Result<(), NativeRegistryError> { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Cargo, + name: name.to_string(), + detail: "expected 1-64 ASCII alphanumeric, `-`, or `_` characters".to_string(), + }); + } + Ok(()) +} + +fn validate_npm_name(name: &str) -> Result<(), NativeRegistryError> { + if name.is_empty() || name.len() > 214 || name.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "expected a lowercase npm name no longer than 214 bytes".to_string(), + }); + } + + let components: Vec<&str> = if let Some(scoped) = name.strip_prefix('@') { + let mut parts = scoped.split('/'); + let scope = parts.next().unwrap_or_default(); + let package = parts.next().unwrap_or_default(); + if scope.is_empty() || package.is_empty() || parts.next().is_some() { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "scoped names must use exactly `@scope/package`".to_string(), + }); + } + vec![scope, package] + } else { + if name.contains('/') { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "unscoped names may not contain `/`".to_string(), + }); + } + vec![name] + }; + + for component in components { + let mut bytes = component.bytes(); + let first = bytes + .next() + .ok_or_else(|| NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must not be empty".to_string(), + })?; + if !first.is_ascii_lowercase() && !first.is_ascii_digit() { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components must start with a lowercase letter or digit".to_string(), + }); + } + if !component.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') + }) { + return Err(NativeRegistryError::InvalidPackageName { + registry: NativeRegistry::Npm, + name: name.to_string(), + detail: "name components may contain lowercase letters, digits, `-`, `_`, or `.`" + .to_string(), + }); + } + } + Ok(()) +} + +fn validate_identity_component(field: &str, value: &str) -> Result<(), NativeRegistryError> { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(NativeRegistryError::InvalidIdentityComponent { + field: field.to_string(), + value: value.to_string(), + }); + } + Ok(()) +} + +fn validate_lower_token(field: &str, value: &str) -> Result<(), NativeRegistryError> { + if value.is_empty() + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') + }) + { + return Err(NativeRegistryError::InvalidPlatformToken { + field: field.to_string(), + value: value.to_string(), + }); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum NativeRegistryError { + #[error("unsupported native-registry adapter schema `{found}`; expected `{supported}`")] + UnsupportedSchema { found: String, supported: String }, + #[error("native-registry adapter record must contain at least one publication")] + NoPublications, + #[error("invalid native package name `{name}` for {registry:?}: {detail}")] + InvalidPackageName { + registry: NativeRegistry, + name: String, + detail: String, + }, + #[error("invalid strict SemVer `{version}` at `{field}`: {detail}")] + InvalidSemver { + field: String, + version: String, + detail: String, + }, + #[error( + "SemVer build metadata is not allowed at `{field}` (`{version}`); encode platform identity outside the version" + )] + BuildMetadataNotAllowed { field: String, version: String }, + #[error( + "native publication `{package}` uses version `{publication_version}`, but the Zed source uses `{source_version}`" + )] + VersionDrift { + package: String, + source_version: String, + publication_version: String, + }, + #[error("invalid Zed package identity component `{value}` at `{field}`")] + InvalidIdentityComponent { field: String, value: String }, + #[error("invalid lowercase platform token `{value}` at `{field}`")] + InvalidPlatformToken { field: String, value: String }, + #[error("invalid lowercase SHA-256 `{value}` at `{field}`")] + InvalidSha256 { field: String, value: String }, + #[error("artifact at `{field}` must contain at least one byte")] + EmptyArtifact { field: String }, + #[error("platform publication `{package}` requires an explicit platform selector")] + PlatformRequired { package: String }, + #[error("{kind:?} publication `{package}` may not contain a platform selector")] + UnexpectedPlatform { + package: String, + kind: NativePublicationKind, + }, + #[error("{kind:?} publication `{package}` may not select platform packages")] + PlatformPackagesNotAllowed { + package: String, + kind: NativePublicationKind, + }, + #[error("meta publication `{package}` selects platform `{platform}` more than once")] + DuplicateMetaPlatform { package: String, platform: String }, + #[error("duplicate native package identity `{package}@{version}`")] + DuplicatePackageVersion { package: String, version: String }, + #[error("one adapter record may contain at most one portable package")] + MultiplePortablePackages, + #[error("one adapter record may contain at most one meta package")] + MultipleMetaPackages, + #[error("meta publication `{package}` must select at least one platform package")] + MetaPackageRequiresPlatformSelections { package: String }, + #[error("platform `{platform}` is published twice by `{first}` and `{second}`")] + DuplicatePlatformPublication { + platform: String, + first: String, + second: String, + }, + #[error( + "meta package `{meta_package}` selects `{selected}` for `{platform}`, but the record publishes `{expected}`" + )] + PlatformPackageMismatch { + meta_package: String, + platform: String, + expected: String, + selected: String, + }, + #[error( + "meta package `{meta_package}` selects missing platform package `{selected}` for `{platform}`" + )] + MissingPlatformPublication { + meta_package: String, + platform: String, + selected: String, + }, + #[error( + "meta package `{meta_package}` does not select published platform package `{package}` for `{platform}`" + )] + UnselectedPlatformPublication { + meta_package: String, + platform: String, + package: String, + }, + #[error("failed to serialize native-registry adapter record: {0}")] + Serialization(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn platform(os: &str, arch: &str, libc: Option<&str>) -> NativePlatform { + NativePlatform { + os: os.to_string(), + arch: arch.to_string(), + libc: libc.map(str::to_string), + } + } + + fn artifact(byte: char) -> NativeArtifact { + NativeArtifact { + sha256: std::iter::repeat_n(byte, 64).collect(), + size: 128, + format: ArtifactFormat::TarGz, + } + } + + fn publication( + name: &str, + kind: NativePublicationKind, + platform: Option, + byte: char, + ) -> NativePublication { + NativePublication { + package: NativePackageIdentity { + name: name.to_string(), + version: "1.2.3".to_string(), + }, + kind, + platform, + platform_packages: Vec::new(), + artifact: artifact(byte), + } + } + + fn record() -> NativeRegistryAdapterRecord { + let linux = platform("linux", "arm64", Some("musl")); + let darwin = platform("darwin", "arm64", None); + let mut meta = publication("@fiducia/core", NativePublicationKind::Meta, None, 'a'); + meta.platform_packages = vec![ + NativePlatformPackage { + platform: linux.clone(), + package: "@fiducia/core-linux-arm64-musl".to_string(), + }, + NativePlatformPackage { + platform: darwin.clone(), + package: "@fiducia/core-darwin-arm64".to_string(), + }, + ]; + NativeRegistryAdapterRecord { + schema: NATIVE_REGISTRY_ADAPTER_SCHEMA_V1.to_string(), + registry: NativeRegistry::Npm, + source: ZedNativePackageIdentity { + org: "fiducia".to_string(), + name: "core".to_string(), + version: "1.2.3".to_string(), + target: Some("node".to_string()), + }, + publications: vec![ + publication( + "@fiducia/core-linux-arm64-musl", + NativePublicationKind::Platform, + Some(linux), + 'b', + ), + meta, + publication( + "@fiducia/core-darwin-arm64", + NativePublicationKind::Platform, + Some(darwin), + 'c', + ), + ], + } + } + + #[test] + fn valid_multi_platform_record_is_canonical() { + let mut reversed = record(); + reversed.publications.reverse(); + for publication in &mut reversed.publications { + publication.platform_packages.reverse(); + } + + assert!(record().validate().is_ok()); + assert_eq!( + record().canonical_json_bytes().unwrap(), + reversed.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn build_metadata_collides_and_is_rejected_for_publication() { + assert!(native_versions_collide("1.2.3+linux", "1.2.3+darwin").unwrap()); + assert!(!native_versions_collide("1.2.3-rc.1", "1.2.3").unwrap()); + + let mut record = record(); + record.source.version = "1.2.3+linux".to_string(); + for publication in &mut record.publications { + publication.package.version = "1.2.3+linux".to_string(); + } + assert!(matches!( + record.validate(), + Err(NativeRegistryError::BuildMetadataNotAllowed { .. }) + )); + } + + #[test] + fn platform_identity_cannot_be_smuggled_into_a_portable_publication() { + let mut record = record(); + let meta = record + .publications + .iter_mut() + .find(|publication| publication.kind == NativePublicationKind::Meta) + .unwrap(); + meta.platform = Some(platform("linux", "x64", Some("gnu"))); + assert!(matches!( + record.validate(), + Err(NativeRegistryError::UnexpectedPlatform { .. }) + )); + } + + #[test] + fn meta_edges_must_reference_the_exact_platform_publication() { + let mut record = record(); + let meta = record + .publications + .iter_mut() + .find(|publication| publication.kind == NativePublicationKind::Meta) + .unwrap(); + meta.platform_packages[0].package = "@fiducia/not-published".to_string(); + assert!(matches!( + record.validate(), + Err(NativeRegistryError::PlatformPackageMismatch { .. }) + )); + } + + #[test] + fn duplicate_platforms_and_precedence_identities_fail_closed() { + let mut duplicate_platform = record(); + let first_platform = duplicate_platform + .publications + .iter() + .find(|publication| publication.kind == NativePublicationKind::Platform) + .unwrap() + .platform + .clone(); + let mut extra = publication( + "@fiducia/core-another", + NativePublicationKind::Platform, + first_platform, + 'd', + ); + extra.package.version = "1.2.3".to_string(); + duplicate_platform.publications.push(extra); + assert!(matches!( + duplicate_platform.validate(), + Err(NativeRegistryError::DuplicatePlatformPublication { .. }) + )); + + let mut duplicate_package = record(); + let duplicate = duplicate_package.publications[0].clone(); + duplicate_package.publications.push(duplicate); + assert!(matches!( + duplicate_package.validate(), + Err(NativeRegistryError::DuplicatePackageVersion { .. }) + )); + } + + #[test] + fn publication_family_cardinality_and_meta_coverage_fail_closed() { + let mut multiple_portable = record(); + multiple_portable.publications.extend([ + publication( + "@fiducia/core-portable", + NativePublicationKind::Portable, + None, + 'd', + ), + publication( + "@fiducia/core-portable-extra", + NativePublicationKind::Portable, + None, + 'e', + ), + ]); + assert!(matches!( + multiple_portable.validate(), + Err(NativeRegistryError::MultiplePortablePackages) + )); + + let mut empty_meta = record(); + empty_meta + .publications + .iter_mut() + .find(|publication| publication.kind == NativePublicationKind::Meta) + .unwrap() + .platform_packages + .clear(); + assert!(matches!( + empty_meta.validate(), + Err(NativeRegistryError::MetaPackageRequiresPlatformSelections { .. }) + )); + + let mut incomplete_meta = record(); + incomplete_meta + .publications + .iter_mut() + .find(|publication| publication.kind == NativePublicationKind::Meta) + .unwrap() + .platform_packages + .pop(); + assert!(matches!( + incomplete_meta.validate(), + Err(NativeRegistryError::UnselectedPlatformPublication { .. }) + )); + + let mut platform_only = record(); + platform_only + .publications + .retain(|publication| publication.kind == NativePublicationKind::Platform); + assert!(platform_only.validate().is_ok()); + } + + #[test] + fn cargo_and_npm_names_use_conservative_portable_subsets() { + assert!(validate_native_package_name(NativeRegistry::Cargo, "fiducia_core-sys").is_ok()); + assert!(validate_native_package_name(NativeRegistry::Cargo, "bad/name").is_err()); + assert!( + validate_native_package_name(NativeRegistry::Npm, "@fiducia/core-linux-x64").is_ok() + ); + assert!(validate_native_package_name(NativeRegistry::Npm, "@Fiducia/core").is_err()); + assert!(validate_native_package_name(NativeRegistry::Npm, "../core").is_err()); + } + + #[test] + fn artifact_and_version_drift_are_rejected() { + let mut invalid_artifact = record(); + invalid_artifact.publications[0].artifact.sha256 = "A".repeat(64); + assert!(matches!( + invalid_artifact.validate(), + Err(NativeRegistryError::InvalidSha256 { .. }) + )); + + let mut version_drift = record(); + version_drift.publications[0].package.version = "1.2.4".to_string(); + assert!(matches!( + version_drift.validate(), + Err(NativeRegistryError::VersionDrift { .. }) + )); + } +} diff --git a/tests/native_registry_schema_contract.rs b/tests/native_registry_schema_contract.rs new file mode 100644 index 0000000..4266928 --- /dev/null +++ b/tests/native_registry_schema_contract.rs @@ -0,0 +1,20 @@ +use schemars::schema_for; +use serde_json::Value; +use zed_interfaces::NativeRegistryAdapterRecord; + +const NATIVE_REGISTRY_ADAPTER_SCHEMA: &str = + include_str!("../schemas/native-registry-adapter-record.json"); + +#[test] +fn checked_in_native_registry_schema_matches_the_public_contract() { + let checked_in: Value = serde_json::from_str(NATIVE_REGISTRY_ADAPTER_SCHEMA) + .expect("checked-in native-registry schema must parse"); + let generated = serde_json::to_value(schema_for!(NativeRegistryAdapterRecord)).unwrap(); + assert_eq!(checked_in, generated); + + let text = NATIVE_REGISTRY_ADAPTER_SCHEMA; + assert!(text.contains("platform_packages")); + assert!(text.contains("sha256")); + assert!(text.contains("npm")); + assert!(text.contains("cargo")); +} From 11f7d850f15fb53a5d89ed49ac4ec1cc04cf1b57 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 21:41:21 -0500 Subject: [PATCH 177/191] chore: synchronize native range exact-head review --- .pr30-ci-sync | 1 + 1 file changed, 1 insertion(+) create mode 100644 .pr30-ci-sync diff --git a/.pr30-ci-sync b/.pr30-ci-sync new file mode 100644 index 0000000..bd86417 --- /dev/null +++ b/.pr30-ci-sync @@ -0,0 +1 @@ +Temporary exact-head CI synchronization marker; removed immediately. From c872714fdb9a811e4a01f70082dbd61252975f47 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 21:41:37 -0500 Subject: [PATCH 178/191] chore: remove native range review synchronization marker --- .pr30-ci-sync | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .pr30-ci-sync diff --git a/.pr30-ci-sync b/.pr30-ci-sync deleted file mode 100644 index bd86417..0000000 --- a/.pr30-ci-sync +++ /dev/null @@ -1 +0,0 @@ -Temporary exact-head CI synchronization marker; removed immediately. From 18664bb4a005d5d5509dfa1b9bf165f22778ee65 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 23:02:31 -0500 Subject: [PATCH 179/191] DEN-1565 add clean current-main lockfile composer --- scripts/den1565_compose_lock.py | 199 ++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 scripts/den1565_compose_lock.py diff --git a/scripts/den1565_compose_lock.py b/scripts/den1565_compose_lock.py new file mode 100644 index 0000000..fdf2869 --- /dev/null +++ b/scripts/den1565_compose_lock.py @@ -0,0 +1,199 @@ +from pathlib import Path +import re + +path = Path("src/lockfile.rs") +source = path.read_text(encoding="utf-8") + + +def replace_once(old: str, new: str, label: str) -> None: + global source + count = source.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one match, found {count}") + source = source.replace(old, new, 1) + + +def sub_once(pattern: str, replacement, label: str) -> None: + global source + source, count = re.subn(pattern, replacement, source, count=1, flags=re.DOTALL) + if count != 1: + raise SystemExit(f"{label}: expected one match, found {count}") + + +replace_once( + "use crate::artifact::ArtifactFormat;\nuse crate::nix::NixAdapterRecord;\n\ntype NixAdapterKey =", + "use crate::artifact::ArtifactFormat;\n" + "use crate::native_dependency::NativeDependencyLock;\n" + "use crate::native_registry::NativeRegistry;\n" + "use crate::nix::NixAdapterRecord;\n\n" + "type NativeDependencyKey = (NativeRegistry, String);\n" + "type NixAdapterKey =", + "native dependency imports and key", +) + +replace_once( + """/// 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.""", + """/// Serialized as TOML with one `[[package]]` table per locked Zed package, +/// optional `[[native-dependency]]` tables for exact npm/Cargo resolutions, +/// and optional `[[nix-adapter]]` tables for completed Nix translations. +/// Every entry pins exact immutable identity so frozen restore never needs to +/// reinterpret a native range or repeat an environment translation.""", + "lockfile format documentation", +) + +sub_once( + r'''(\s+#\[serde\(default, rename = "package", skip_serializing_if = "Vec::is_empty"\)\]\n\s+pub packages: Vec,\n)(\s+/// Optional immutable provenance for completed Nix interoperability)''', + lambda match: match.group(1) + + """ /// Exact source-aware npm/Cargo resolutions. This additive field keeps + /// existing lockfile version 1 documents readable while newer writers can + /// preserve native requirement translation and immutable artifact identity. + #[serde( + default, + rename = "native-dependency", + skip_serializing_if = "Vec::is_empty" + )] + pub native_dependencies: Vec, +""" + + match.group(2), + "native dependency lockfile field", +) + +replace_once( + """ #[error("duplicate locked package identity `{0}`")] + DuplicatePackage(String), + #[error("invalid Nix adapter provenance: {0}")]""", + """ #[error("duplicate locked package identity `{0}`")] + DuplicatePackage(String), + #[error("invalid native dependency provenance: {0}")] + InvalidNativeDependency(String), + #[error("duplicate native dependency key `{0}`")] + DuplicateNativeDependency(String), + #[error("invalid Nix adapter provenance: {0}")]""", + "native dependency errors", +) + +sub_once( + r'''(version:\s*Self::CURRENT_VERSION,\s*\n\s*packages:\s*Vec::new\(\),\s*\n)(\s*nix_adapters:\s*Vec::new\(\),)''', + lambda match: match.group(1) + + " native_dependencies: Vec::new(),\n" + + match.group(2), + "lockfile default", +) + +replace_once( + "lockfile.validate_packages()?;\n lockfile.validate_nix_adapters()?;", + "lockfile.validate_packages()?;\n" + " lockfile.validate_native_dependencies()?;\n" + " lockfile.validate_nix_adapters()?;", + "parse validation order", +) + +sub_once( + r''' pub fn to_toml_string\(&self\) -> Result \{.*?\n \}\n\n pub fn find\(''', + """ pub fn to_toml_string(&self) -> Result { + let mut normalized = self.clone(); + normalized.normalize_missing_package_revisions()?; + normalized.validate_packages()?; + normalized.validate_native_dependencies()?; + normalized.validate_nix_adapters()?; + normalized + .packages + .sort_by(|left, right| (&left.org, &left.name).cmp(&(&right.org, &right.name))); + normalized + .native_dependencies + .sort_by_key(native_dependency_key); + normalized.nix_adapters.sort_by_key(nix_adapter_key); + toml::to_string_pretty(&normalized) + .map_err(|error| LockfileError::Toml(error.to_string())) + } + + pub fn find(""", + "canonical writer", +) + +replace_once( + """ /// Insert or replace one completed Nix translation. Identity includes + /// package/target, direction, system, and selected output, so platform + /// variants never overwrite each other.""", + """ /// Return one exact native resolution by source registry and package name. + pub fn find_native_dependency( + &self, + registry: NativeRegistry, + package_name: &str, + ) -> Option<&NativeDependencyLock> { + self.native_dependencies.iter().find(|dependency| { + dependency.requirement.registry == registry + && dependency.package.name == package_name + }) + } + + /// Validate and insert or replace one exact native resolution. V1 identity + /// is `(registry, package.name)`, so a project cannot silently carry two + /// different exact resolutions of the same native package. + pub fn upsert_native_dependency( + &mut self, + dependency: NativeDependencyLock, + ) -> Result<(), LockfileError> { + dependency + .validate() + .map_err(|error| LockfileError::InvalidNativeDependency(error.to_string()))?; + let key = native_dependency_key(&dependency); + self.native_dependencies + .retain(|existing| native_dependency_key(existing) != key); + self.native_dependencies.push(dependency); + self.native_dependencies.sort_by_key(native_dependency_key); + Ok(()) + } + + /// Insert or replace one completed Nix translation. Identity includes + /// package/target, direction, system, and selected output, so platform + /// variants never overwrite each other.""", + "native dependency lookup and upsert", +) + +replace_once( + " fn validate_nix_adapters(&self) -> Result<(), LockfileError> {", + """ fn validate_native_dependencies(&self) -> Result<(), LockfileError> { + let mut seen = BTreeSet::new(); + for dependency in &self.native_dependencies { + dependency + .validate() + .map_err(|error| LockfileError::InvalidNativeDependency(error.to_string()))?; + let key = native_dependency_key(dependency); + if !seen.insert(key) { + return Err(LockfileError::DuplicateNativeDependency( + native_dependency_label(dependency), + )); + } + } + Ok(()) + } + + fn validate_nix_adapters(&self) -> Result<(), LockfileError> {""", + "native dependency validation", +) + +replace_once( + "fn nix_adapter_key(adapter: &NixAdapterRecord) -> NixAdapterKey {", + """fn native_dependency_key(dependency: &NativeDependencyLock) -> NativeDependencyKey { + ( + dependency.requirement.registry, + dependency.package.name.clone(), + ) +} + +fn native_dependency_label(dependency: &NativeDependencyLock) -> String { + format!( + "{:?}:{}", + dependency.requirement.registry, dependency.package.name + ) +} + +fn nix_adapter_key(adapter: &NixAdapterRecord) -> NixAdapterKey {""", + "native dependency key helpers", +) + +path.write_text(source, encoding="utf-8") From 2084f4fd141bade834b877fc722353a9257230f4 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 23:02:49 -0500 Subject: [PATCH 180/191] DEN-1565 add clean compatibility fixture updater --- scripts/den1565_update_lock_literals.py | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 scripts/den1565_update_lock_literals.py diff --git a/scripts/den1565_update_lock_literals.py b/scripts/den1565_update_lock_literals.py new file mode 100644 index 0000000..d33dfcb --- /dev/null +++ b/scripts/den1565_update_lock_literals.py @@ -0,0 +1,38 @@ +from pathlib import Path + + +def add_native_dependency_initializer(path: Path) -> int: + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + output: list[str] = [] + inserted = 0 + for line in lines: + if "nix_adapters: Vec::new()," in line: + previous = next((item.strip() for item in reversed(output) if item.strip()), "") + if not previous.startswith("native_dependencies:"): + indent = line[: len(line) - len(line.lstrip())] + output.append(f"{indent}native_dependencies: Vec::new(),\n") + inserted += 1 + output.append(line) + if inserted == 0: + raise SystemExit(f"{path}: no Lockfile literals required the additive field") + path.write_text("".join(output), encoding="utf-8") + return inserted + + +def refresh_legacy_fixture(path: Path) -> None: + source = path.read_text(encoding="utf-8") + old = 'vcs_tag = "v1.0.0"\nsource = "file:///tmp/registry"\n' + new = ( + 'vcs_tag = "v1.0.0"\n' + 'vcs_commit = "0123456789abcdef0123456789abcdef01234567"\n' + 'source = "file:///tmp/registry"\n' + ) + if source.count(old) != 1: + raise SystemExit("legacy fixture revision marker drifted") + path.write_text(source.replace(old, new, 1), encoding="utf-8") + + +internal = add_native_dependency_initializer(Path("src/lockfile.rs")) +external = add_native_dependency_initializer(Path("tests/lockfile_content_addressed_provenance.rs")) +refresh_legacy_fixture(Path("tests/native_dependency_lockfile_contract.rs")) +print(f"updated {internal} internal and {external} external Lockfile literals") From fc463c36656871b13f60bbad9ae0ffc0a57fa995 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 23:03:15 -0500 Subject: [PATCH 181/191] DEN-1565 certify clean current-main native lock provenance --- .github/workflows/den1565-compose-product.yml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/den1565-compose-product.yml diff --git a/.github/workflows/den1565-compose-product.yml b/.github/workflows/den1565-compose-product.yml new file mode 100644 index 0000000..d38f034 --- /dev/null +++ b/.github/workflows/den1565-compose-product.yml @@ -0,0 +1,60 @@ +name: DEN-1565 certify native lock provenance product + +on: + push: + branches: + - agent/den-1565-native-lock-provenance-current-main-v3 + paths: + - .github/workflows/den1565-compose-product.yml + - scripts/den1565_compose_lock.py + - scripts/den1565_update_lock_literals.py + +permissions: + contents: write + +jobs: + certify: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: agent/den-1565-native-lock-provenance-current-main-v3 + persist-credentials: true + show-progress: false + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt,clippy + - name: Import reviewed docs and contract tests + run: | + git fetch --no-tags origin agent/native-lockfile-provenance --depth=1 + for path in docs/native-dependency-lockfile.md tests/native_dependency_lockfile_contract.rs tests/native_dependency_lockfile_schema_contract.rs; do + mkdir -p "$(dirname "$path")" + git show "FETCH_HEAD:$path" > "$path" + done + - name: Compose current-main product + run: | + python3 scripts/den1565_compose_lock.py + python3 scripts/den1565_update_lock_literals.py + - name: Certify complete crate + run: | + cargo fmt --all + cargo run --locked --example generate_schemas + cargo fmt --all -- --check + cargo test --locked --all-targets + cargo clippy --locked --all-targets --all-features -- -D warnings + git diff --check + changed="$(git diff --name-only | grep -v '^scripts/den1565_' | sort)" + expected="$(printf '%s\n' docs/native-dependency-lockfile.md schemas/lockfile.json src/lockfile.rs tests/lockfile_content_addressed_provenance.rs tests/native_dependency_lockfile_contract.rs tests/native_dependency_lockfile_schema_contract.rs | sort)" + test "$changed" = "$expected" + - name: Commit six certified product files + run: | + git add docs/native-dependency-lockfile.md schemas/lockfile.json src/lockfile.rs tests/lockfile_content_addressed_provenance.rs tests/native_dependency_lockfile_contract.rs tests/native_dependency_lockfile_schema_contract.rs + git diff --cached --check + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git commit -m 'feat: persist exact native dependencies in zpkg lockfiles' + git push origin HEAD:agent/den-1565-native-lock-provenance-current-main-v3 From c09c39d1c4652882491a9b8742970aabfbaf118b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 23:06:12 -0500 Subject: [PATCH 182/191] ci: report and validate the exact native lock product delta --- .github/workflows/den1565-compose-product.yml | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/den1565-compose-product.yml b/.github/workflows/den1565-compose-product.yml index d38f034..b933cea 100644 --- a/.github/workflows/den1565-compose-product.yml +++ b/.github/workflows/den1565-compose-product.yml @@ -41,17 +41,37 @@ jobs: python3 scripts/den1565_update_lock_literals.py - name: Certify complete crate run: | + set -euo pipefail cargo fmt --all cargo run --locked --example generate_schemas cargo fmt --all -- --check cargo test --locked --all-targets cargo clippy --locked --all-targets --all-features -- -D warnings git diff --check - changed="$(git diff --name-only | grep -v '^scripts/den1565_' | sort)" - expected="$(printf '%s\n' docs/native-dependency-lockfile.md schemas/lockfile.json src/lockfile.rs tests/lockfile_content_addressed_provenance.rs tests/native_dependency_lockfile_contract.rs tests/native_dependency_lockfile_schema_contract.rs | sort)" - test "$changed" = "$expected" + + product_files=( + docs/native-dependency-lockfile.md + schemas/lockfile.json + src/lockfile.rs + tests/lockfile_content_addressed_provenance.rs + tests/native_dependency_lockfile_contract.rs + tests/native_dependency_lockfile_schema_contract.rs + ) + product_changed="$(git diff --name-only HEAD -- "${product_files[@]}" | sort)" + expected="$(printf '%s\n' "${product_files[@]}" | sort)" + if [ "$product_changed" != "$expected" ]; then + printf 'expected product delta:\n%s\nactual product delta:\n%s\n' "$expected" "$product_changed" >&2 + exit 1 + fi + + unexpected="$(git diff --name-only HEAD | grep -Ev '^(docs/native-dependency-lockfile\.md|schemas/lockfile\.json|src/lockfile\.rs|tests/lockfile_content_addressed_provenance\.rs|tests/native_dependency_lockfile_contract\.rs|tests/native_dependency_lockfile_schema_contract\.rs|scripts/den1565_compose_lock\.py|scripts/den1565_update_lock_literals\.py|\.github/workflows/den1565-compose-product\.yml)$' || true)" + if [ -n "$unexpected" ]; then + printf 'unexpected changed paths:\n%s\n' "$unexpected" >&2 + exit 1 + fi - name: Commit six certified product files run: | + set -euo pipefail git add docs/native-dependency-lockfile.md schemas/lockfile.json src/lockfile.rs tests/lockfile_content_addressed_provenance.rs tests/native_dependency_lockfile_contract.rs tests/native_dependency_lockfile_schema_contract.rs git diff --cached --check git config user.name github-actions[bot] From 865d2bb0c7f527b7675af87cad524df3fc34ac35 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 23:08:44 -0500 Subject: [PATCH 183/191] ci: include untracked reviewed files in provenance delta checks --- .github/workflows/den1565-compose-product.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/den1565-compose-product.yml b/.github/workflows/den1565-compose-product.yml index b933cea..dcddd0e 100644 --- a/.github/workflows/den1565-compose-product.yml +++ b/.github/workflows/den1565-compose-product.yml @@ -57,16 +57,22 @@ jobs: tests/native_dependency_lockfile_contract.rs tests/native_dependency_lockfile_schema_contract.rs ) - product_changed="$(git diff --name-only HEAD -- "${product_files[@]}" | sort)" + product_changed="$({ + git diff --name-only HEAD -- "${product_files[@]}" + git ls-files --others --exclude-standard -- "${product_files[@]}" + } | sort -u)" expected="$(printf '%s\n' "${product_files[@]}" | sort)" if [ "$product_changed" != "$expected" ]; then printf 'expected product delta:\n%s\nactual product delta:\n%s\n' "$expected" "$product_changed" >&2 exit 1 fi - unexpected="$(git diff --name-only HEAD | grep -Ev '^(docs/native-dependency-lockfile\.md|schemas/lockfile\.json|src/lockfile\.rs|tests/lockfile_content_addressed_provenance\.rs|tests/native_dependency_lockfile_contract\.rs|tests/native_dependency_lockfile_schema_contract\.rs|scripts/den1565_compose_lock\.py|scripts/den1565_update_lock_literals\.py|\.github/workflows/den1565-compose-product\.yml)$' || true)" + unexpected="$({ + git diff --name-only HEAD + git ls-files --others --exclude-standard + } | sort -u | grep -Ev '^(docs/native-dependency-lockfile\.md|schemas/lockfile\.json|src/lockfile\.rs|tests/lockfile_content_addressed_provenance\.rs|tests/native_dependency_lockfile_contract\.rs|tests/native_dependency_lockfile_schema_contract\.rs|scripts/den1565_compose_lock\.py|scripts/den1565_update_lock_literals\.py|\.github/workflows/den1565-compose-product\.yml)$' || true)" if [ -n "$unexpected" ]; then - printf 'unexpected changed paths:\n%s\n' "$unexpected" >&2 + printf 'unexpected changed or untracked paths:\n%s\n' "$unexpected" >&2 exit 1 fi - name: Commit six certified product files From b6d3c96caff08fd8425c7a9923003d442d2c4d5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:09:31 +0000 Subject: [PATCH 184/191] feat: persist exact native dependencies in zpkg lockfiles --- docs/native-dependency-lockfile.md | 92 +++++++++ schemas/lockfile.json | 104 +++++++++- src/lockfile.rs | 95 ++++++++- .../lockfile_content_addressed_provenance.rs | 3 + tests/native_dependency_lockfile_contract.rs | 195 ++++++++++++++++++ ...ive_dependency_lockfile_schema_contract.rs | 23 +++ 6 files changed, 507 insertions(+), 5 deletions(-) create mode 100644 docs/native-dependency-lockfile.md create mode 100644 tests/native_dependency_lockfile_contract.rs create mode 100644 tests/native_dependency_lockfile_schema_contract.rs diff --git a/docs/native-dependency-lockfile.md b/docs/native-dependency-lockfile.md new file mode 100644 index 0000000..58370e6 --- /dev/null +++ b/docs/native-dependency-lockfile.md @@ -0,0 +1,92 @@ +# Native dependency provenance in `.zpkg.lock` + +Lockfile version 1 may contain zero or more `[[native-dependency]]` tables. Each +table is a complete `NativeDependencyLock`: source registry, original native +requirement, deterministic canonical requirement, exact resolved version, and +immutable artifact identity. + +Existing lockfiles remain valid because the field is additive and defaults to +an empty list. + +## TOML shape + +```toml +version = 1 + +[[native-dependency]] +schema = "zed.native-dependency-lock/v1" + +[native-dependency.requirement] +registry = "npm" +declared = "^1.2.3" +canonical = "^1.2.3" + +[native-dependency.package] +name = "@fiducia/core" +version = "1.9.0" + +[native-dependency.artifact] +sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +size = 1024 +format = "tar.gz" +``` + +The lockfile owns exact restore identity. Nix, Flox, Devbox, mise, asdf, +container export, and future npm/Cargo adapters consume the frozen package +version and artifact hash; they do not reinterpret `declared` or rerun native +range resolution. + +## Identity and duplicates + +Native lock uniqueness is `(registry, package.name)`. Npm and Cargo entries with +the same textual name may coexist because their naming and requirement semantics +are independent. Two entries for the same registry and package name are rejected +while parsing or writing rather than resolved by array order. + +Protocol aliases and workspace, path, Git, or URL sources are not represented by +`NativeDependencyLock` v1 and cannot create hidden duplicate identities. + +## Validation + +`Lockfile::parse`, `Lockfile::to_toml_string`, and +`Lockfile::upsert_native_dependency` call `NativeDependencyLock::validate`. +That validation: + +- recomputes the source-aware canonical requirement; +- rejects requirement-receipt drift; +- checks that the exact resolved version satisfies the requirement; +- validates npm or Cargo package naming; +- rejects SemVer build metadata; and +- validates lowercase nonzero SHA-256, nonzero size, and archive format. + +The lockfile layer then rejects duplicate native keys. + +## Determinism + +Before serialization, the lockfile normalizes: + +1. ordinary Zed packages by `(org, name)`; +2. native dependencies by `(registry, package.name)`; and +3. Nix adapters by their existing package, direction, system, and output key. + +Insertion order therefore does not change emitted TOML. + +## Public helpers + +```rust +lockfile.find_native_dependency(NativeRegistry::Npm, "@fiducia/core"); +lockfile.upsert_native_dependency(exact_lock)?; +``` + +Upsert validates the new entry, replaces only the same native identity, and +restores deterministic ordering. A Cargo package with the same textual name is +not replaced by an npm entry. + +## Compatibility + +The serialized change is additive within lockfile version 1. The Rust `Lockfile` +struct gains a public `native_dependencies` vector, so downstream crates that +construct it with struct literals must add `native_dependencies: Vec::new()` or +prefer `Lockfile::default()` plus the public upsert methods. The independent +canary compiles the current downstream CLI against the exact interface commit to +make this source-level change explicit rather than assuming compatibility. diff --git a/schemas/lockfile.json b/schemas/lockfile.json index 2608dc1..6c81ed7 100644 --- a/schemas/lockfile.json +++ b/schemas/lockfile.json @@ -1,9 +1,16 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Lockfile", - "description": "The `.zpkg.lock` file written next to `.zpkg.toml` after resolution.\n\nSerialized as TOML with one `[[package]]` table per locked 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 Zed package,\noptional `[[native-dependency]]` tables for exact npm/Cargo resolutions,\nand optional `[[nix-adapter]]` tables for completed Nix translations.\nEvery entry pins exact immutable identity so frozen restore never needs to\nreinterpret a native range or repeat an environment translation.", "type": "object", "properties": { + "native-dependency": { + "description": "Exact source-aware npm/Cargo resolutions. This additive field keeps\nexisting lockfile version 1 documents readable while newer writers can\npreserve native requirement translation and immutable artifact identity.", + "type": "array", + "items": { + "$ref": "#/$defs/NativeDependencyLock" + } + }, "nix-adapter": { "description": "Optional immutable provenance for completed Nix interoperability\ntranslations. This additive field keeps lockfile version 1 readable by\ncurrent consumers while allowing newer writers to preserve evidence.", "type": "array", @@ -97,6 +104,101 @@ "source" ] }, + "NativeArtifact": { + "description": "Exact immutable bytes published under one native package identity.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "sha256": { + "description": "Lowercase hexadecimal SHA-256 of the exact uploaded archive bytes.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "sha256", + "size" + ] + }, + "NativeDependencyLock": { + "description": "Frozen native dependency identity. The declaration remains auditable, while\n`package.version` and `artifact` are the only restore-time identities.", + "type": "object", + "properties": { + "artifact": { + "$ref": "#/$defs/NativeArtifact" + }, + "package": { + "$ref": "#/$defs/NativePackageIdentity" + }, + "requirement": { + "$ref": "#/$defs/NativeVersionRequirement" + }, + "schema": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema", + "requirement", + "package", + "artifact" + ] + }, + "NativePackageIdentity": { + "description": "Public identity selected in npm or a Cargo-compatible registry.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ] + }, + "NativeRegistry": { + "description": "Native registries with a first-class publication identity contract.", + "type": "string", + "enum": [ + "npm", + "cargo" + ] + }, + "NativeVersionRequirement": { + "description": "One source-aware native requirement and its canonical SemVer translation.", + "type": "object", + "properties": { + "canonical": { + "description": "Deterministic `semver::VersionReq` representation used by Zed.", + "type": "string" + }, + "declared": { + "description": "Exact project declaration before translation.", + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + } + }, + "additionalProperties": false, + "required": [ + "registry", + "declared", + "canonical" + ] + }, "NixAdapterRecord": { "description": "Final, immutable provenance record for one completed translation.\n\n`direction` is internally tagged in JSON so consumers cannot deserialize a\ndirection whose required origin/result fields are absent.", "oneOf": [ diff --git a/src/lockfile.rs b/src/lockfile.rs index 8eb5488..cbb7916 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -4,23 +4,36 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::artifact::ArtifactFormat; +use crate::native_dependency::NativeDependencyLock; +use crate::native_registry::NativeRegistry; use crate::nix::NixAdapterRecord; +type NativeDependencyKey = (NativeRegistry, String); type NixAdapterKey = (String, String, String, Option, u8, String, String); const ARTIFACT_REVISION_PREFIX: &str = "artifact-sha256:"; /// The `.zpkg.lock` file written next to `.zpkg.toml` after resolution. /// -/// Serialized as TOML with one `[[package]]` table per locked 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. +/// Serialized as TOML with one `[[package]]` table per locked Zed package, +/// optional `[[native-dependency]]` tables for exact npm/Cargo resolutions, +/// and optional `[[nix-adapter]]` tables for completed Nix translations. +/// Every entry pins exact immutable identity so frozen restore never needs to +/// reinterpret a native range or repeat an environment translation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct Lockfile { pub version: u32, #[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")] pub packages: Vec, + /// Exact source-aware npm/Cargo resolutions. This additive field keeps + /// existing lockfile version 1 documents readable while newer writers can + /// preserve native requirement translation and immutable artifact identity. + #[serde( + default, + rename = "native-dependency", + skip_serializing_if = "Vec::is_empty" + )] + pub native_dependencies: Vec, /// Optional immutable provenance for completed Nix interoperability /// translations. This additive field keeps lockfile version 1 readable by /// current consumers while allowing newer writers to preserve evidence. @@ -76,6 +89,10 @@ pub enum LockfileError { InvalidPackageMetadata { package: String, reason: String }, #[error("duplicate locked package identity `{0}`")] DuplicatePackage(String), + #[error("invalid native dependency provenance: {0}")] + InvalidNativeDependency(String), + #[error("duplicate native dependency key `{0}`")] + DuplicateNativeDependency(String), #[error("invalid Nix adapter provenance: {0}")] InvalidNixAdapter(String), #[error("duplicate Nix adapter provenance key `{0}`")] @@ -87,6 +104,7 @@ impl Default for Lockfile { Self { version: Self::CURRENT_VERSION, packages: Vec::new(), + native_dependencies: Vec::new(), nix_adapters: Vec::new(), } } @@ -105,6 +123,7 @@ impl Lockfile { )); } lockfile.validate_packages()?; + lockfile.validate_native_dependencies()?; lockfile.validate_nix_adapters()?; Ok(lockfile) } @@ -113,7 +132,14 @@ impl Lockfile { let mut normalized = self.clone(); normalized.normalize_missing_package_revisions()?; normalized.validate_packages()?; + normalized.validate_native_dependencies()?; normalized.validate_nix_adapters()?; + normalized + .packages + .sort_by(|left, right| (&left.org, &left.name).cmp(&(&right.org, &right.name))); + normalized + .native_dependencies + .sort_by_key(native_dependency_key); normalized.nix_adapters.sort_by_key(nix_adapter_key); toml::to_string_pretty(&normalized).map_err(|error| LockfileError::Toml(error.to_string())) } @@ -133,6 +159,35 @@ impl Lockfile { .sort_by(|a, b| (&a.org, &a.name).cmp(&(&b.org, &b.name))); } + /// Return one exact native resolution by source registry and package name. + pub fn find_native_dependency( + &self, + registry: NativeRegistry, + package_name: &str, + ) -> Option<&NativeDependencyLock> { + self.native_dependencies.iter().find(|dependency| { + dependency.requirement.registry == registry && dependency.package.name == package_name + }) + } + + /// Validate and insert or replace one exact native resolution. V1 identity + /// is `(registry, package.name)`, so a project cannot silently carry two + /// different exact resolutions of the same native package. + pub fn upsert_native_dependency( + &mut self, + dependency: NativeDependencyLock, + ) -> Result<(), LockfileError> { + dependency + .validate() + .map_err(|error| LockfileError::InvalidNativeDependency(error.to_string()))?; + let key = native_dependency_key(&dependency); + self.native_dependencies + .retain(|existing| native_dependency_key(existing) != key); + self.native_dependencies.push(dependency); + self.native_dependencies.sort_by_key(native_dependency_key); + Ok(()) + } + /// Insert or replace one completed Nix translation. Identity includes /// package/target, direction, system, and selected output, so platform /// variants never overwrite each other. @@ -218,6 +273,22 @@ impl Lockfile { Ok(()) } + fn validate_native_dependencies(&self) -> Result<(), LockfileError> { + let mut seen = BTreeSet::new(); + for dependency in &self.native_dependencies { + dependency + .validate() + .map_err(|error| LockfileError::InvalidNativeDependency(error.to_string()))?; + let key = native_dependency_key(dependency); + if !seen.insert(key) { + return Err(LockfileError::DuplicateNativeDependency( + native_dependency_label(dependency), + )); + } + } + Ok(()) + } + fn validate_nix_adapters(&self) -> Result<(), LockfileError> { let mut seen = BTreeSet::new(); for adapter in &self.nix_adapters { @@ -285,6 +356,20 @@ fn is_immutable_vcs_revision(value: &str) -> bool { && !lower.starts_with("heads/") } +fn native_dependency_key(dependency: &NativeDependencyLock) -> NativeDependencyKey { + ( + dependency.requirement.registry, + dependency.package.name.clone(), + ) +} + +fn native_dependency_label(dependency: &NativeDependencyLock) -> String { + format!( + "{:?}:{}", + dependency.requirement.registry, dependency.package.name + ) +} + fn nix_adapter_key(adapter: &NixAdapterRecord) -> NixAdapterKey { match adapter { NixAdapterRecord::ZedToNix { package, .. } => ( @@ -468,6 +553,7 @@ source = "file:///tmp/registry" let lock = Lockfile { version: Lockfile::CURRENT_VERSION, packages: vec![package_without_commit(digest)], + native_dependencies: Vec::new(), nix_adapters: Vec::new(), }; let serialized = lock.to_toml_string().unwrap(); @@ -489,6 +575,7 @@ source = "file:///tmp/registry" let lock = Lockfile { version: Lockfile::CURRENT_VERSION, packages: vec![package_without_commit(digest)], + native_dependencies: Vec::new(), nix_adapters: Vec::new(), }; let error = lock.to_toml_string().unwrap_err().to_string(); diff --git a/tests/lockfile_content_addressed_provenance.rs b/tests/lockfile_content_addressed_provenance.rs index 2da1f34..e0e45a1 100644 --- a/tests/lockfile_content_addressed_provenance.rs +++ b/tests/lockfile_content_addressed_provenance.rs @@ -24,6 +24,7 @@ fn canonical_writer_upgrades_legacy_none_without_mutating_the_builder() { let lock = Lockfile { version: Lockfile::CURRENT_VERSION, packages: vec![package(None)], + native_dependencies: Vec::new(), nix_adapters: Vec::new(), }; @@ -45,6 +46,7 @@ fn canonical_writer_preserves_a_stronger_verified_revision() { let lock = Lockfile { version: Lockfile::CURRENT_VERSION, packages: vec![package(Some(revision))], + native_dependencies: Vec::new(), nix_adapters: Vec::new(), }; @@ -97,6 +99,7 @@ fn fallback_derivation_rejects_malformed_or_zero_hashes() { let lock = Lockfile { version: Lockfile::CURRENT_VERSION, packages: vec![bad], + native_dependencies: Vec::new(), nix_adapters: Vec::new(), }; let error = lock.to_toml_string().unwrap_err().to_string(); diff --git a/tests/native_dependency_lockfile_contract.rs b/tests/native_dependency_lockfile_contract.rs new file mode 100644 index 0000000..490e1ce --- /dev/null +++ b/tests/native_dependency_lockfile_contract.rs @@ -0,0 +1,195 @@ +use zed_interfaces::{ + ArtifactFormat, Lockfile, LockfileError, NativeArtifact, NativeDependencyError, + NativeDependencyLock, NativeRegistry, NativeVersionCandidate, +}; + +fn artifact(digit: char) -> NativeArtifact { + NativeArtifact { + sha256: std::iter::repeat_n(digit, 64).collect(), + size: 512, + format: ArtifactFormat::TarGz, + } +} + +fn exact_lock( + registry: NativeRegistry, + package: &str, + declared: &str, + version: &str, + digit: char, +) -> NativeDependencyLock { + NativeDependencyLock::resolve( + registry, + package, + declared, + &[NativeVersionCandidate { + version: version.to_string(), + artifact: artifact(digit), + }], + ) + .expect("fixture must resolve") +} + +#[test] +fn legacy_lockfile_v1_remains_readable_without_native_entries() { + let legacy = r#" +version = 1 + +[[package]] +org = "zedtest" +name = "core" +version = "1.0.0" +sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +size = 128 +format = "tar.gz" +vcs_tag = "v1.0.0" +vcs_commit = "0123456789abcdef0123456789abcdef01234567" +source = "file:///tmp/registry" +"#; + + let parsed = Lockfile::parse(legacy).expect("legacy v1 lockfile must parse"); + assert!(parsed.native_dependencies.is_empty()); + assert!(parsed.nix_adapters.is_empty()); + assert_eq!(parsed.find("zedtest", "core").unwrap().version, "1.0.0"); + + let emitted = parsed.to_toml_string().unwrap(); + assert!(!emitted.contains("native-dependency")); +} + +#[test] +fn native_locks_round_trip_and_serialize_independent_of_insertion_order() { + let npm = exact_lock(NativeRegistry::Npm, "@fiducia/core", "^1.2.3", "1.9.0", 'a'); + let cargo = exact_lock(NativeRegistry::Cargo, "fiducia_core", "1.2.3", "1.9.0", 'b'); + + let mut forward = Lockfile::default(); + forward.upsert_native_dependency(cargo.clone()).unwrap(); + forward.upsert_native_dependency(npm.clone()).unwrap(); + + let mut reverse = Lockfile::default(); + reverse.upsert_native_dependency(npm.clone()).unwrap(); + reverse.upsert_native_dependency(cargo.clone()).unwrap(); + + let forward_toml = forward.to_toml_string().unwrap(); + let reverse_toml = reverse.to_toml_string().unwrap(); + assert_eq!(forward_toml, reverse_toml); + assert_eq!(forward_toml.matches("[[native-dependency]]").count(), 2); + assert!(forward_toml.contains("declared = \"^1.2.3\"")); + assert!(forward_toml.contains("canonical = \"^1.2.3\"")); + + let parsed = Lockfile::parse(&forward_toml).unwrap(); + assert_eq!(parsed, forward); + assert_eq!( + parsed + .find_native_dependency(NativeRegistry::Npm, "@fiducia/core") + .unwrap() + .package + .version, + "1.9.0" + ); + assert_eq!( + parsed + .find_native_dependency(NativeRegistry::Cargo, "fiducia_core") + .unwrap() + .artifact + .sha256, + "b".repeat(64) + ); +} + +#[test] +fn upsert_replaces_only_the_same_registry_and_package_identity() { + let npm_123 = exact_lock(NativeRegistry::Npm, "core", "1.2.3", "1.2.3", 'a'); + let npm_124 = exact_lock(NativeRegistry::Npm, "core", "1.2.4", "1.2.4", 'b'); + let cargo = exact_lock(NativeRegistry::Cargo, "core", "1.2.3", "1.9.0", 'c'); + + let mut lockfile = Lockfile::default(); + lockfile.upsert_native_dependency(npm_123).unwrap(); + lockfile.upsert_native_dependency(cargo).unwrap(); + lockfile.upsert_native_dependency(npm_124).unwrap(); + + assert_eq!(lockfile.native_dependencies.len(), 2); + assert_eq!( + lockfile + .find_native_dependency(NativeRegistry::Npm, "core") + .unwrap() + .package + .version, + "1.2.4" + ); + assert_eq!( + lockfile + .find_native_dependency(NativeRegistry::Cargo, "core") + .unwrap() + .package + .version, + "1.9.0" + ); +} + +#[test] +fn duplicate_native_keys_fail_during_write_and_parse() { + let first = exact_lock(NativeRegistry::Npm, "@fiducia/core", "1.2.3", "1.2.3", 'a'); + let second = exact_lock(NativeRegistry::Npm, "@fiducia/core", "1.2.4", "1.2.4", 'b'); + let duplicate = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: Vec::new(), + native_dependencies: vec![first, second], + nix_adapters: Vec::new(), + }; + + assert!(matches!( + duplicate.to_toml_string(), + Err(LockfileError::DuplicateNativeDependency(_)) + )); + + let raw = toml::to_string_pretty(&duplicate).unwrap(); + assert!(matches!( + Lockfile::parse(&raw), + Err(LockfileError::DuplicateNativeDependency(_)) + )); +} + +#[test] +fn invalid_embedded_provenance_fails_during_upsert_write_and_parse() { + let mut drift = exact_lock(NativeRegistry::Npm, "@fiducia/core", "^1.2.3", "1.9.0", 'a'); + drift.requirement.canonical = "^1.3.0".to_string(); + + let mut lockfile = Lockfile::default(); + assert!(matches!( + lockfile.upsert_native_dependency(drift.clone()), + Err(LockfileError::InvalidNativeDependency(_)) + )); + + lockfile.native_dependencies.push(drift); + assert!(matches!( + lockfile.to_toml_string(), + Err(LockfileError::InvalidNativeDependency(_)) + )); + + let raw = toml::to_string_pretty(&lockfile).unwrap(); + assert!(matches!( + Lockfile::parse(&raw), + Err(LockfileError::InvalidNativeDependency(_)) + )); +} + +#[test] +fn embedded_lock_preserves_native_validation_errors() { + let mut invalid = exact_lock(NativeRegistry::Cargo, "fiducia_core", "1.2.3", "1.9.0", 'a'); + invalid.schema = "zed.native-dependency-lock/v2".to_string(); + assert!(matches!( + invalid.validate(), + Err(NativeDependencyError::UnsupportedSchema { .. }) + )); + + let lockfile = Lockfile { + version: Lockfile::CURRENT_VERSION, + packages: Vec::new(), + native_dependencies: vec![invalid], + nix_adapters: Vec::new(), + }; + assert!(matches!( + lockfile.to_toml_string(), + Err(LockfileError::InvalidNativeDependency(_)) + )); +} diff --git a/tests/native_dependency_lockfile_schema_contract.rs b/tests/native_dependency_lockfile_schema_contract.rs new file mode 100644 index 0000000..4fe0127 --- /dev/null +++ b/tests/native_dependency_lockfile_schema_contract.rs @@ -0,0 +1,23 @@ +use schemars::schema_for; +use serde_json::Value; +use zed_interfaces::Lockfile; + +const LOCKFILE_SCHEMA: &str = include_str!("../schemas/lockfile.json"); + +#[test] +fn checked_in_lockfile_schema_includes_native_dependency_provenance() { + let checked_in: Value = + serde_json::from_str(LOCKFILE_SCHEMA).expect("checked-in lockfile schema must parse"); + let generated = serde_json::to_value(schema_for!(Lockfile)).unwrap(); + assert_eq!(checked_in, generated); + + let text = LOCKFILE_SCHEMA; + assert!(text.contains("native-dependency")); + assert!(text.contains("NativeDependencyLock")); + assert!(text.contains("NativeVersionRequirement")); + assert!(text.contains("declared")); + assert!(text.contains("canonical")); + assert!(text.contains("sha256")); + assert!(text.contains("npm")); + assert!(text.contains("cargo")); +} From a23cdd2ac509a39ca6fd6d21a3774fdd3a0f7660 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 23:10:26 -0500 Subject: [PATCH 185/191] DEN-1565 remove native lock composition scaffolding Leave only the six certified product and compatibility files for ordinary exact-head review. --- .github/workflows/den1565-compose-product.yml | 86 -------- scripts/den1565_compose_lock.py | 199 ------------------ scripts/den1565_update_lock_literals.py | 38 ---- 3 files changed, 323 deletions(-) delete mode 100644 .github/workflows/den1565-compose-product.yml delete mode 100644 scripts/den1565_compose_lock.py delete mode 100644 scripts/den1565_update_lock_literals.py diff --git a/.github/workflows/den1565-compose-product.yml b/.github/workflows/den1565-compose-product.yml deleted file mode 100644 index dcddd0e..0000000 --- a/.github/workflows/den1565-compose-product.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: DEN-1565 certify native lock provenance product - -on: - push: - branches: - - agent/den-1565-native-lock-provenance-current-main-v3 - paths: - - .github/workflows/den1565-compose-product.yml - - scripts/den1565_compose_lock.py - - scripts/den1565_update_lock_literals.py - -permissions: - contents: write - -jobs: - certify: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: agent/den-1565-native-lock-provenance-current-main-v3 - persist-credentials: true - show-progress: false - fetch-depth: 0 - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt,clippy - - name: Import reviewed docs and contract tests - run: | - git fetch --no-tags origin agent/native-lockfile-provenance --depth=1 - for path in docs/native-dependency-lockfile.md tests/native_dependency_lockfile_contract.rs tests/native_dependency_lockfile_schema_contract.rs; do - mkdir -p "$(dirname "$path")" - git show "FETCH_HEAD:$path" > "$path" - done - - name: Compose current-main product - run: | - python3 scripts/den1565_compose_lock.py - python3 scripts/den1565_update_lock_literals.py - - name: Certify complete crate - run: | - set -euo pipefail - cargo fmt --all - cargo run --locked --example generate_schemas - cargo fmt --all -- --check - cargo test --locked --all-targets - cargo clippy --locked --all-targets --all-features -- -D warnings - git diff --check - - product_files=( - docs/native-dependency-lockfile.md - schemas/lockfile.json - src/lockfile.rs - tests/lockfile_content_addressed_provenance.rs - tests/native_dependency_lockfile_contract.rs - tests/native_dependency_lockfile_schema_contract.rs - ) - product_changed="$({ - git diff --name-only HEAD -- "${product_files[@]}" - git ls-files --others --exclude-standard -- "${product_files[@]}" - } | sort -u)" - expected="$(printf '%s\n' "${product_files[@]}" | sort)" - if [ "$product_changed" != "$expected" ]; then - printf 'expected product delta:\n%s\nactual product delta:\n%s\n' "$expected" "$product_changed" >&2 - exit 1 - fi - - unexpected="$({ - git diff --name-only HEAD - git ls-files --others --exclude-standard - } | sort -u | grep -Ev '^(docs/native-dependency-lockfile\.md|schemas/lockfile\.json|src/lockfile\.rs|tests/lockfile_content_addressed_provenance\.rs|tests/native_dependency_lockfile_contract\.rs|tests/native_dependency_lockfile_schema_contract\.rs|scripts/den1565_compose_lock\.py|scripts/den1565_update_lock_literals\.py|\.github/workflows/den1565-compose-product\.yml)$' || true)" - if [ -n "$unexpected" ]; then - printf 'unexpected changed or untracked paths:\n%s\n' "$unexpected" >&2 - exit 1 - fi - - name: Commit six certified product files - run: | - set -euo pipefail - git add docs/native-dependency-lockfile.md schemas/lockfile.json src/lockfile.rs tests/lockfile_content_addressed_provenance.rs tests/native_dependency_lockfile_contract.rs tests/native_dependency_lockfile_schema_contract.rs - git diff --cached --check - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git commit -m 'feat: persist exact native dependencies in zpkg lockfiles' - git push origin HEAD:agent/den-1565-native-lock-provenance-current-main-v3 diff --git a/scripts/den1565_compose_lock.py b/scripts/den1565_compose_lock.py deleted file mode 100644 index fdf2869..0000000 --- a/scripts/den1565_compose_lock.py +++ /dev/null @@ -1,199 +0,0 @@ -from pathlib import Path -import re - -path = Path("src/lockfile.rs") -source = path.read_text(encoding="utf-8") - - -def replace_once(old: str, new: str, label: str) -> None: - global source - count = source.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one match, found {count}") - source = source.replace(old, new, 1) - - -def sub_once(pattern: str, replacement, label: str) -> None: - global source - source, count = re.subn(pattern, replacement, source, count=1, flags=re.DOTALL) - if count != 1: - raise SystemExit(f"{label}: expected one match, found {count}") - - -replace_once( - "use crate::artifact::ArtifactFormat;\nuse crate::nix::NixAdapterRecord;\n\ntype NixAdapterKey =", - "use crate::artifact::ArtifactFormat;\n" - "use crate::native_dependency::NativeDependencyLock;\n" - "use crate::native_registry::NativeRegistry;\n" - "use crate::nix::NixAdapterRecord;\n\n" - "type NativeDependencyKey = (NativeRegistry, String);\n" - "type NixAdapterKey =", - "native dependency imports and key", -) - -replace_once( - """/// 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.""", - """/// Serialized as TOML with one `[[package]]` table per locked Zed package, -/// optional `[[native-dependency]]` tables for exact npm/Cargo resolutions, -/// and optional `[[nix-adapter]]` tables for completed Nix translations. -/// Every entry pins exact immutable identity so frozen restore never needs to -/// reinterpret a native range or repeat an environment translation.""", - "lockfile format documentation", -) - -sub_once( - r'''(\s+#\[serde\(default, rename = "package", skip_serializing_if = "Vec::is_empty"\)\]\n\s+pub packages: Vec,\n)(\s+/// Optional immutable provenance for completed Nix interoperability)''', - lambda match: match.group(1) - + """ /// Exact source-aware npm/Cargo resolutions. This additive field keeps - /// existing lockfile version 1 documents readable while newer writers can - /// preserve native requirement translation and immutable artifact identity. - #[serde( - default, - rename = "native-dependency", - skip_serializing_if = "Vec::is_empty" - )] - pub native_dependencies: Vec, -""" - + match.group(2), - "native dependency lockfile field", -) - -replace_once( - """ #[error("duplicate locked package identity `{0}`")] - DuplicatePackage(String), - #[error("invalid Nix adapter provenance: {0}")]""", - """ #[error("duplicate locked package identity `{0}`")] - DuplicatePackage(String), - #[error("invalid native dependency provenance: {0}")] - InvalidNativeDependency(String), - #[error("duplicate native dependency key `{0}`")] - DuplicateNativeDependency(String), - #[error("invalid Nix adapter provenance: {0}")]""", - "native dependency errors", -) - -sub_once( - r'''(version:\s*Self::CURRENT_VERSION,\s*\n\s*packages:\s*Vec::new\(\),\s*\n)(\s*nix_adapters:\s*Vec::new\(\),)''', - lambda match: match.group(1) - + " native_dependencies: Vec::new(),\n" - + match.group(2), - "lockfile default", -) - -replace_once( - "lockfile.validate_packages()?;\n lockfile.validate_nix_adapters()?;", - "lockfile.validate_packages()?;\n" - " lockfile.validate_native_dependencies()?;\n" - " lockfile.validate_nix_adapters()?;", - "parse validation order", -) - -sub_once( - r''' pub fn to_toml_string\(&self\) -> Result \{.*?\n \}\n\n pub fn find\(''', - """ pub fn to_toml_string(&self) -> Result { - let mut normalized = self.clone(); - normalized.normalize_missing_package_revisions()?; - normalized.validate_packages()?; - normalized.validate_native_dependencies()?; - normalized.validate_nix_adapters()?; - normalized - .packages - .sort_by(|left, right| (&left.org, &left.name).cmp(&(&right.org, &right.name))); - normalized - .native_dependencies - .sort_by_key(native_dependency_key); - normalized.nix_adapters.sort_by_key(nix_adapter_key); - toml::to_string_pretty(&normalized) - .map_err(|error| LockfileError::Toml(error.to_string())) - } - - pub fn find(""", - "canonical writer", -) - -replace_once( - """ /// Insert or replace one completed Nix translation. Identity includes - /// package/target, direction, system, and selected output, so platform - /// variants never overwrite each other.""", - """ /// Return one exact native resolution by source registry and package name. - pub fn find_native_dependency( - &self, - registry: NativeRegistry, - package_name: &str, - ) -> Option<&NativeDependencyLock> { - self.native_dependencies.iter().find(|dependency| { - dependency.requirement.registry == registry - && dependency.package.name == package_name - }) - } - - /// Validate and insert or replace one exact native resolution. V1 identity - /// is `(registry, package.name)`, so a project cannot silently carry two - /// different exact resolutions of the same native package. - pub fn upsert_native_dependency( - &mut self, - dependency: NativeDependencyLock, - ) -> Result<(), LockfileError> { - dependency - .validate() - .map_err(|error| LockfileError::InvalidNativeDependency(error.to_string()))?; - let key = native_dependency_key(&dependency); - self.native_dependencies - .retain(|existing| native_dependency_key(existing) != key); - self.native_dependencies.push(dependency); - self.native_dependencies.sort_by_key(native_dependency_key); - Ok(()) - } - - /// Insert or replace one completed Nix translation. Identity includes - /// package/target, direction, system, and selected output, so platform - /// variants never overwrite each other.""", - "native dependency lookup and upsert", -) - -replace_once( - " fn validate_nix_adapters(&self) -> Result<(), LockfileError> {", - """ fn validate_native_dependencies(&self) -> Result<(), LockfileError> { - let mut seen = BTreeSet::new(); - for dependency in &self.native_dependencies { - dependency - .validate() - .map_err(|error| LockfileError::InvalidNativeDependency(error.to_string()))?; - let key = native_dependency_key(dependency); - if !seen.insert(key) { - return Err(LockfileError::DuplicateNativeDependency( - native_dependency_label(dependency), - )); - } - } - Ok(()) - } - - fn validate_nix_adapters(&self) -> Result<(), LockfileError> {""", - "native dependency validation", -) - -replace_once( - "fn nix_adapter_key(adapter: &NixAdapterRecord) -> NixAdapterKey {", - """fn native_dependency_key(dependency: &NativeDependencyLock) -> NativeDependencyKey { - ( - dependency.requirement.registry, - dependency.package.name.clone(), - ) -} - -fn native_dependency_label(dependency: &NativeDependencyLock) -> String { - format!( - "{:?}:{}", - dependency.requirement.registry, dependency.package.name - ) -} - -fn nix_adapter_key(adapter: &NixAdapterRecord) -> NixAdapterKey {""", - "native dependency key helpers", -) - -path.write_text(source, encoding="utf-8") diff --git a/scripts/den1565_update_lock_literals.py b/scripts/den1565_update_lock_literals.py deleted file mode 100644 index d33dfcb..0000000 --- a/scripts/den1565_update_lock_literals.py +++ /dev/null @@ -1,38 +0,0 @@ -from pathlib import Path - - -def add_native_dependency_initializer(path: Path) -> int: - lines = path.read_text(encoding="utf-8").splitlines(keepends=True) - output: list[str] = [] - inserted = 0 - for line in lines: - if "nix_adapters: Vec::new()," in line: - previous = next((item.strip() for item in reversed(output) if item.strip()), "") - if not previous.startswith("native_dependencies:"): - indent = line[: len(line) - len(line.lstrip())] - output.append(f"{indent}native_dependencies: Vec::new(),\n") - inserted += 1 - output.append(line) - if inserted == 0: - raise SystemExit(f"{path}: no Lockfile literals required the additive field") - path.write_text("".join(output), encoding="utf-8") - return inserted - - -def refresh_legacy_fixture(path: Path) -> None: - source = path.read_text(encoding="utf-8") - old = 'vcs_tag = "v1.0.0"\nsource = "file:///tmp/registry"\n' - new = ( - 'vcs_tag = "v1.0.0"\n' - 'vcs_commit = "0123456789abcdef0123456789abcdef01234567"\n' - 'source = "file:///tmp/registry"\n' - ) - if source.count(old) != 1: - raise SystemExit("legacy fixture revision marker drifted") - path.write_text(source.replace(old, new, 1), encoding="utf-8") - - -internal = add_native_dependency_initializer(Path("src/lockfile.rs")) -external = add_native_dependency_initializer(Path("tests/lockfile_content_addressed_provenance.rs")) -refresh_legacy_fixture(Path("tests/native_dependency_lockfile_contract.rs")) -print(f"updated {internal} internal and {external} external Lockfile literals") From 5f15f1f2686199924b3e32e7ef8e6a85434bca3e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 5 Aug 2026 00:53:09 -0500 Subject: [PATCH 186/191] Support .zedignore negation for default directories (#39) Honor !target, !target/, and !target/** overrides for built-in target exclusions while preserving unrelated defaults and later re-exclusions. Exact-head CI, Nix, and agent-policy checks passed. --- src/excludes.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/excludes.rs b/src/excludes.rs index 4bed4fb..2dee1a6 100644 --- a/src/excludes.rs +++ b/src/excludes.rs @@ -81,6 +81,12 @@ const REGISTRY_DOC_PATTERNS: &[&str] = &["README", "CHANGELOG"]; /// registry-facing doc patterns when `include_readme` is set), plus the /// manifest's own `publish.exclude` globs. `.zedignore` lines are appended by /// the CLI on top of this. +/// +/// A leading `!` negates a built-in or earlier extra exclusion with the same +/// normalized path. This supports the common root-directory override contract, +/// for example `!target`, `!target/`, or `!target/**` to publish a checked-in +/// Rust target directory. The negation itself is removed before the CLI builds +/// its positive-only glob set. pub fn effective_excludes(extra: &[String], include_readme: bool) -> Vec { let mut out: Vec = Vec::new(); for pattern in DEFAULT_EXCLUDES { @@ -89,6 +95,64 @@ pub fn effective_excludes(extra: &[String], include_readme: bool) -> Vec } out.push((*pattern).to_string()); } - out.extend(extra.iter().cloned()); + + for pattern in extra { + if let Some(negated) = pattern.strip_prefix('!') { + let normalized = normalize_pattern(negated, false); + if normalized.is_empty() { + continue; + } + out.retain(|existing| normalize_pattern(existing, true) != normalized); + } else { + out.push(pattern.clone()); + } + } out } + +fn normalize_pattern(pattern: &str, strip_recursive_prefix: bool) -> String { + let mut value = pattern.trim().replace('\\', "/"); + if strip_recursive_prefix { + value = value.strip_prefix("**/").unwrap_or(&value).to_string(); + } + while let Some(stripped) = value.strip_suffix("/**") { + value = stripped.to_string(); + } + value.trim_matches('/').to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_negation_removes_root_and_recursive_defaults() { + for negation in ["!target", "!target/", "!target/**"] { + let excludes = effective_excludes(&[negation.to_string()], false); + assert!(!excludes.iter().any(|pattern| pattern == "target/**")); + assert!(!excludes.iter().any(|pattern| pattern == "**/target/**")); + assert!(!excludes.iter().any(|pattern| pattern.starts_with('!'))); + } + } + + #[test] + fn negation_only_removes_the_matching_default_family() { + let excludes = effective_excludes(&["!target".to_string()], false); + assert!(excludes.iter().any(|pattern| pattern == "node_modules/**")); + assert!(excludes.iter().any(|pattern| pattern == "build/**")); + } + + #[test] + fn later_exclusion_can_reapply_after_negation() { + let excludes = effective_excludes( + &["!target".to_string(), "target/private/**".to_string()], + false, + ); + assert!( + excludes + .iter() + .any(|pattern| pattern == "target/private/**") + ); + assert!(!excludes.iter().any(|pattern| pattern == "target/**")); + } +} From d36ac522915792539740cb105e928652503dfde2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 5 Aug 2026 12:56:33 -0500 Subject: [PATCH 187/191] ci: publish certified Zed package artifacts (#42) Merge after successful exact-head checks and an immediate mergeability, review, draft, repository-state, and operational-policy refresh. --- .github/workflows/certify-zed-package.yml | 221 ++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 .github/workflows/certify-zed-package.yml diff --git a/.github/workflows/certify-zed-package.yml b/.github/workflows/certify-zed-package.yml new file mode 100644 index 0000000..d9ea4dd --- /dev/null +++ b/.github/workflows/certify-zed-package.yml @@ -0,0 +1,221 @@ +name: certify-and-publish-zed-package + +on: + workflow_call: + pull_request: + paths: + - ".zpkg.toml" + - ".zpkg.lock" + - ".gitignore" + - ".github/workflows/certify-zed-package.yml" + push: + branches: [main] + paths: + - ".zpkg.toml" + - ".zpkg.lock" + - ".gitignore" + - ".github/workflows/certify-zed-package.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: certify-zed-package-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + uses: zed-pkg/zed-interfaces/.github/workflows/validate-zed-package.yml@933155fc41dc6443424d06b618137b23dc434835 + + publish: + name: Pack and publish certified artifacts + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Check out package + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + show-progress: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + + - name: Pack canonical and target packages + shell: bash + env: + ZED_ARTIFACT_OUTPUT: ${{ runner.temp }}/zed-package-artifacts + run: | + set -euo pipefail + packer="$RUNNER_TEMP/zed-artifact-publisher" + rm -rf "$packer" "$ZED_ARTIFACT_OUTPUT" + mkdir -p "$packer/src" "$ZED_ARTIFACT_OUTPUT" + + cat > "$packer/Cargo.toml" <<'TOML' + [package] + name = "zed-artifact-publisher" + version = "0.0.0" + edition = "2024" + publish = false + + [dependencies] + serde_json = "1" + zed-cli = { git = "https://github.com/zed-pkg/zed-cli.git", rev = "fd3b08eb1ac170518cb795e662318ae2714b1176" } + zed-interfaces = { git = "https://github.com/zed-pkg/zed-interfaces.git", rev = "a23cdd2ac509a39ca6fd6d21a3774fdd3a0f7660" } + TOML + + cat > "$packer/src/main.rs" <<'RUST' + use std::{env, fs, path::PathBuf, process}; + + use serde_json::json; + use zed_interfaces::Manifest; + + fn fail(message: impl std::fmt::Display) -> ! { + eprintln!("zed artifact publication failed: {message}"); + process::exit(1); + } + + fn main() { + let root = env::args_os() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| fail("missing repository root argument")); + let output = env::var_os("ZED_ARTIFACT_OUTPUT") + .map(PathBuf::from) + .unwrap_or_else(|| fail("ZED_ARTIFACT_OUTPUT is not set")); + fs::create_dir_all(&output) + .unwrap_or_else(|error| fail(format!("creating output directory: {error}"))); + + let manifest_text = fs::read_to_string(root.join(".zpkg.toml")) + .unwrap_or_else(|error| fail(format!("reading .zpkg.toml: {error}"))); + let manifest = Manifest::parse(&manifest_text) + .unwrap_or_else(|error| fail(format!("parsing .zpkg.toml: {error}"))); + + let packages = zed_cli::pack::pack_all(&root, &manifest, Some(&output)) + .unwrap_or_else(|error| fail(format!("packing repository: {error:#}"))); + if packages.is_empty() { + fail("packer emitted no artifacts"); + } + + let canonical_file = format!( + "{}-{}-{}.tar.gz", + manifest.package.org, manifest.package.name, manifest.package.version + ); + if !output.join(&canonical_file).is_file() { + fail(format!("canonical artifact `{canonical_file}` was not emitted")); + } + + let mut records = Vec::with_capacity(packages.len()); + for package in packages { + let file = package + .packed + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_else(|| fail("artifact has no UTF-8 file name")) + .to_string(); + if package.packed.size == 0 || package.packed.file_count == 0 { + fail(format!("artifact `{file}` is empty")); + } + if package.packed.sha256.len() != 64 + || !package + .packed + .sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + fail(format!("artifact `{file}` has an invalid sha256")); + } + + records.push(json!({ + "target": package.target, + "org": package.manifest.package.org, + "name": package.manifest.package.name, + "version": package.manifest.package.version, + "file": file, + "sha256": package.packed.sha256, + "size": package.packed.size, + "file_count": package.packed.file_count, + "excluded_count": package.packed.excluded_count, + "format": package.packed.format.extension(), + })); + } + + records.sort_by(|left, right| { + left["file"] + .as_str() + .cmp(&right["file"].as_str()) + }); + + let mut checksums = String::new(); + for record in &records { + checksums.push_str(record["sha256"].as_str().unwrap()); + checksums.push_str(" "); + checksums.push_str(record["file"].as_str().unwrap()); + checksums.push('\n'); + } + fs::write(output.join("SHA256SUMS"), checksums) + .unwrap_or_else(|error| fail(format!("writing SHA256SUMS: {error}"))); + + let provenance = json!({ + "schema": "zed.packages.v1", + "repository": env::var("GITHUB_REPOSITORY").unwrap_or_default(), + "commit": env::var("GITHUB_SHA").unwrap_or_default(), + "run_id": env::var("GITHUB_RUN_ID").unwrap_or_default(), + "run_attempt": env::var("GITHUB_RUN_ATTEMPT").unwrap_or_default(), + "package": { + "org": manifest.package.org, + "name": manifest.package.name, + "version": manifest.package.version, + }, + "artifacts": records, + }); + fs::write( + output.join("zed-artifacts.json"), + serde_json::to_vec_pretty(&provenance).unwrap(), + ) + .unwrap_or_else(|error| fail(format!("writing artifact manifest: {error}"))); + } + RUST + + cargo generate-lockfile --manifest-path "$packer/Cargo.toml" + cargo run --quiet --locked --manifest-path "$packer/Cargo.toml" -- "$GITHUB_WORKSPACE" + test -s "$ZED_ARTIFACT_OUTPUT/SHA256SUMS" + test -s "$ZED_ARTIFACT_OUTPUT/zed-artifacts.json" + (cd "$ZED_ARTIFACT_OUTPUT" && sha256sum --check SHA256SUMS) + find "$ZED_ARTIFACT_OUTPUT" -maxdepth 1 -type f -printf '%f\n' | sort + + - name: Publish certified package artifacts + id: publish + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: zed-packages-${{ github.event.repository.name }}-${{ github.sha }} + path: ${{ runner.temp }}/zed-package-artifacts + if-no-files-found: error + retention-days: 30 + compression-level: 0 + overwrite: false + + - name: Verify publication metadata + shell: bash + env: + ARTIFACT_ID: ${{ steps.publish.outputs.artifact-id }} + ARTIFACT_URL: ${{ steps.publish.outputs.artifact-url }} + ARTIFACT_DIGEST: ${{ steps.publish.outputs.artifact-digest }} + run: | + set -euo pipefail + test -n "$ARTIFACT_ID" + test -n "$ARTIFACT_URL" + test -n "$ARTIFACT_DIGEST" + { + echo "### Certified Zed package artifacts" + echo + echo "- Artifact ID: \`$ARTIFACT_ID\`" + echo "- Artifact digest: \`$ARTIFACT_DIGEST\`" + echo "- Download: $ARTIFACT_URL" + } >> "$GITHUB_STEP_SUMMARY" From bc3f14364e674e7629968b90bbdcb99e5d11eabe Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Thu, 6 Aug 2026 14:12:38 -0500 Subject: [PATCH 188/191] Prefer primary branches and avoid agent worktrees --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 594be98..040a8a5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ # zed-pkg local state /zed_modules/ /.zed/pack/ +tmp +temp +tmp/worktrees +temp/worktrees From 7f96edaeccd139f7e7ab59b3763fa3e2bf426931 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Thu, 6 Aug 2026 14:12:44 -0500 Subject: [PATCH 189/191] Prefer primary branches and avoid agent worktrees --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 594be98..040a8a5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ # zed-pkg local state /zed_modules/ /.zed/pack/ +tmp +temp +tmp/worktrees +temp/worktrees From 0125d8c7e5bea6c9c366ade1fc344254b8cc4ec1 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Thu, 6 Aug 2026 14:12:45 -0500 Subject: [PATCH 190/191] Prefer primary branches and avoid agent worktrees --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 594be98..040a8a5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ # zed-pkg local state /zed_modules/ /.zed/pack/ +tmp +temp +tmp/worktrees +temp/worktrees From 762071627a6a8ddfc3391cb7ca8ecf7277f21faa Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Thu, 6 Aug 2026 19:40:28 -0500 Subject: [PATCH 191/191] feat: publish isolated Rust, Dart, and TypeScript interface slices --- .gitignore | 8 + .zpkg.toml | 53 +- Cargo.toml | 32 +- codegen/generate.mjs | 727 ++++++++++++++++++ codegen/generate.test.mjs | 218 ++++++ package.json | 12 + schemas/index.json | 164 ++++ src/dart/analysis_options.yaml | 12 + src/dart/lib/api_error.dart | 27 + src/dart/lib/audit_integrity_response.dart | 52 ++ src/dart/lib/audit_log_response.dart | 135 ++++ src/dart/lib/claim_org_request.dart | 19 + src/dart/lib/claim_org_response.dart | 25 + src/dart/lib/common.dart | 40 + src/dart/lib/package_list_response.dart | 28 + src/dart/lib/package_metadata.dart | 128 +++ src/dart/lib/publish_response.dart | 34 + src/dart/lib/search_response.dart | 26 + src/dart/lib/semantic_search_request.dart | 43 ++ src/dart/lib/semantic_search_response.dart | 56 ++ src/dart/lib/sync_change_event.dart | 107 +++ src/dart/lib/sync_conflict_resolution.dart | 26 + src/dart/lib/sync_error_policy.dart | 28 + src/dart/lib/sync_write_mode.dart | 30 + src/dart/lib/version_metadata.dart | 98 +++ src/dart/lib/yank_request.dart | 20 + src/dart/lib/yank_response.dart | 34 + src/dart/lib/zed_interfaces.dart | 26 + src/dart/pubspec.yaml | 16 + src/rust/Cargo.toml | 26 + src/{ => rust}/artifact.rs | 0 src/{ => rust}/environment.rs | 0 src/{ => rust}/environment_lock.rs | 0 src/{ => rust}/environment_v2.rs | 0 .../rust/examples}/generate_schemas.rs | 12 +- src/{ => rust}/excludes.rs | 0 src/{ => rust}/language.rs | 0 src/{ => rust}/lib.rs | 0 src/{ => rust}/lockfile.rs | 0 src/{ => rust}/manifest.rs | 0 src/{ => rust}/native_dependency.rs | 0 src/{ => rust}/native_registry.rs | 0 src/{ => rust}/nix.rs | 0 src/{ => rust}/nix_plan.rs | 0 src/{ => rust}/oci.rs | 0 src/{ => rust}/paths.rs | 0 src/{ => rust}/registry.rs | 0 src/{ => rust}/sync.rs | 0 {tests => src/rust/tests}/install_contract.rs | 0 .../rust/tests}/install_contract_edges.rs | 0 .../lockfile_content_addressed_provenance.rs | 0 .../native_dependency_lockfile_contract.rs | 0 ...ive_dependency_lockfile_schema_contract.rs | 2 +- .../native_dependency_schema_contract.rs | 3 +- .../tests}/native_registry_schema_contract.rs | 2 +- {tests => src/rust/tests}/native_release.rs | 0 .../rust/tests}/nix_interop_contract.rs | 0 .../rust/tests}/nix_manifest_lock.rs | 0 .../rust/tests}/nix_schema_contract.rs | 4 +- src/rust/tests/own_manifest.rs | 85 ++ {tests => src/rust/tests}/roundtrip.rs | 0 src/{ => rust}/vcs.rs | 0 src/{ => rust}/version.rs | 0 src/ts/api-error.ts | 10 + src/ts/audit-integrity-response.ts | 17 + src/ts/audit-log-response.ts | 45 ++ src/ts/claim-org-request.ts | 7 + src/ts/claim-org-response.ts | 9 + src/ts/common.ts | 12 + src/ts/index.ts | 22 + src/ts/package-list-response.ts | 12 + src/ts/package-metadata.ts | 37 + src/ts/package.json | 28 + src/ts/publish-response.ts | 10 + src/ts/search-response.ts | 10 + src/ts/semantic-search-request.ts | 14 + src/ts/semantic-search-response.ts | 16 + src/ts/sync-change-event.ts | 27 + src/ts/sync-conflict-resolution.ts | 11 + src/ts/sync-error-policy.ts | 11 + src/ts/sync-write-mode.ts | 11 + src/ts/tsconfig.json | 16 + src/ts/version-metadata.ts | 27 + src/ts/yank-request.ts | 8 + src/ts/yank-response.ts | 10 + 85 files changed, 2697 insertions(+), 31 deletions(-) create mode 100644 codegen/generate.mjs create mode 100644 codegen/generate.test.mjs create mode 100644 package.json create mode 100644 schemas/index.json create mode 100644 src/dart/analysis_options.yaml create mode 100644 src/dart/lib/api_error.dart create mode 100644 src/dart/lib/audit_integrity_response.dart create mode 100644 src/dart/lib/audit_log_response.dart create mode 100644 src/dart/lib/claim_org_request.dart create mode 100644 src/dart/lib/claim_org_response.dart create mode 100644 src/dart/lib/common.dart create mode 100644 src/dart/lib/package_list_response.dart create mode 100644 src/dart/lib/package_metadata.dart create mode 100644 src/dart/lib/publish_response.dart create mode 100644 src/dart/lib/search_response.dart create mode 100644 src/dart/lib/semantic_search_request.dart create mode 100644 src/dart/lib/semantic_search_response.dart create mode 100644 src/dart/lib/sync_change_event.dart create mode 100644 src/dart/lib/sync_conflict_resolution.dart create mode 100644 src/dart/lib/sync_error_policy.dart create mode 100644 src/dart/lib/sync_write_mode.dart create mode 100644 src/dart/lib/version_metadata.dart create mode 100644 src/dart/lib/yank_request.dart create mode 100644 src/dart/lib/yank_response.dart create mode 100644 src/dart/lib/zed_interfaces.dart create mode 100644 src/dart/pubspec.yaml create mode 100644 src/rust/Cargo.toml rename src/{ => rust}/artifact.rs (100%) rename src/{ => rust}/environment.rs (100%) rename src/{ => rust}/environment_lock.rs (100%) rename src/{ => rust}/environment_v2.rs (100%) rename {examples => src/rust/examples}/generate_schemas.rs (84%) rename src/{ => rust}/excludes.rs (100%) rename src/{ => rust}/language.rs (100%) rename src/{ => rust}/lib.rs (100%) rename src/{ => rust}/lockfile.rs (100%) rename src/{ => rust}/manifest.rs (100%) rename src/{ => rust}/native_dependency.rs (100%) rename src/{ => rust}/native_registry.rs (100%) rename src/{ => rust}/nix.rs (100%) rename src/{ => rust}/nix_plan.rs (100%) rename src/{ => rust}/oci.rs (100%) rename src/{ => rust}/paths.rs (100%) rename src/{ => rust}/registry.rs (100%) rename src/{ => rust}/sync.rs (100%) rename {tests => src/rust/tests}/install_contract.rs (100%) rename {tests => src/rust/tests}/install_contract_edges.rs (100%) rename {tests => src/rust/tests}/lockfile_content_addressed_provenance.rs (100%) rename {tests => src/rust/tests}/native_dependency_lockfile_contract.rs (100%) rename {tests => src/rust/tests}/native_dependency_lockfile_schema_contract.rs (90%) rename {tests => src/rust/tests}/native_dependency_schema_contract.rs (87%) rename {tests => src/rust/tests}/native_registry_schema_contract.rs (90%) rename {tests => src/rust/tests}/native_release.rs (100%) rename {tests => src/rust/tests}/nix_interop_contract.rs (100%) rename {tests => src/rust/tests}/nix_manifest_lock.rs (100%) rename {tests => src/rust/tests}/nix_schema_contract.rs (85%) create mode 100644 src/rust/tests/own_manifest.rs rename {tests => src/rust/tests}/roundtrip.rs (100%) rename src/{ => rust}/vcs.rs (100%) rename src/{ => rust}/version.rs (100%) create mode 100644 src/ts/api-error.ts create mode 100644 src/ts/audit-integrity-response.ts create mode 100644 src/ts/audit-log-response.ts create mode 100644 src/ts/claim-org-request.ts create mode 100644 src/ts/claim-org-response.ts create mode 100644 src/ts/common.ts create mode 100644 src/ts/index.ts create mode 100644 src/ts/package-list-response.ts create mode 100644 src/ts/package-metadata.ts create mode 100644 src/ts/package.json create mode 100644 src/ts/publish-response.ts create mode 100644 src/ts/search-response.ts create mode 100644 src/ts/semantic-search-request.ts create mode 100644 src/ts/semantic-search-response.ts create mode 100644 src/ts/sync-change-event.ts create mode 100644 src/ts/sync-conflict-resolution.ts create mode 100644 src/ts/sync-error-policy.ts create mode 100644 src/ts/sync-write-mode.ts create mode 100644 src/ts/tsconfig.json create mode 100644 src/ts/version-metadata.ts create mode 100644 src/ts/yank-request.ts create mode 100644 src/ts/yank-response.ts diff --git a/.gitignore b/.gitignore index 040a8a5..8d9cbfd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,15 @@ # zed-pkg local state /zed_modules/ /.zed/pack/ +/.vendor/ tmp temp tmp/worktrees temp/worktrees + +# Language-slice tooling state. The slices themselves are generated and +# committed; their toolchains' scratch dirs are not. +node_modules/ +src/dart/.dart_tool/ +src/dart/pubspec.lock +src/dart/build/ diff --git a/.zpkg.toml b/.zpkg.toml index eefca1f..e69b71d 100644 --- a/.zpkg.toml +++ b/.zpkg.toml @@ -4,8 +4,10 @@ name = "zed-interfaces" version = "0.1.0" description = "Core manifest, lockfile, registry, version, and package-model interfaces for zed-pkg" license = "MIT" -keywords = ["package-manager", "interfaces", "manifest", "lockfile", "rust"] -language = "rust" +keywords = ["package-manager", "interfaces", "manifest", "lockfile", "polyglot"] +# Polyglot: one source of truth and one version in the repo, three packages on +# the wire. `language` stays unset because no single language describes the +# package — each `[targets.*]` declares its own. [package.repository] vcs = "git" @@ -14,7 +16,7 @@ url = "https://github.com/zed-pkg/zed-interfaces" [publish] include_readme = true tag_format = "v{version}" -smoke_test = "cargo test --manifest-path \"$ZED_PKG_TEST_TARGET/Cargo.toml\" --locked" +smoke_test = "test -f \"$ZED_PKG_TEST_TARGET/schemas/index.json\" && test -f \"$ZED_PKG_TEST_TARGET/src/rust/Cargo.toml\" && test -f \"$ZED_PKG_TEST_TARGET/src/dart/lib/zed_interfaces.dart\" && test -f \"$ZED_PKG_TEST_TARGET/src/ts/index.ts\"" exclude = [ ".env", ".env.*", @@ -23,17 +25,56 @@ exclude = [ ".zed-pack/**", "target/**", "**/target/**", + "**/node_modules/**", + "**/.dart_tool/**", "tmp/**", "**/*.log", ".DS_Store", ] -[publish.native] +# Whole-repository slice: schemas + all three language trees + the generator. +# Take this one to regenerate or to diff the contract; take a language slice to +# consume it. +[targets.repository] +dir = "." +name = "zed-interfaces-repository" + +# The Rust crate is hand-written and is the source of truth: `cargo run +# --example generate_schemas` derives schemas/, and codegen/generate.mjs +# derives the Dart and TS slices from those. +[targets.rust] +dir = "src/rust" +adapter = "rust" + +[targets.rust.native] registry = "crates-io" package = "zed-interfaces" +# Front-end slices. Generated — never hand-edited — and deliberately narrower +# than Rust: only the schemas marked front-end-facing in schemas/index.json. +[targets.dart] +dir = "src/dart" +adapter = "dart" + +[targets.dart.native] +registry = "pub.dev" +package = "zed_interfaces" +tag_format = "dart/v{version}" + +[targets.typescript] +dir = "src/ts" +name = "zed-interfaces-typescript" +adapter = "node" + +[targets.typescript.native] +registry = "npm" +package = "@zed-pkg/zed-interfaces" +tag_format = "ts/v{version}" + [install] -adapter = "rust" +dir = ".vendor/.zed" [scripts] -test = "cargo test --all-targets --locked" +test = "cargo test --all-targets --locked && npm test" +codegen = "cargo run --locked --example generate_schemas && npm run codegen" +codegen-check = "npm run codegen:check" diff --git a/Cargo.toml b/Cargo.toml index 8dfa1b6..3521398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,17 +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] -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" +# Virtual workspace. The `zed-interfaces` crate itself lives in `src/rust/`, +# beside the generated `src/dart/` and `src/ts/` slices. +# +# The root stays a workspace (rather than the crate) for two reasons: +# * `.zpkg.toml` publishes each language slice as its own zed-package, and a +# target may not own `dir = "."`, so the Rust slice needs its own subtree. +# * `zed-interfaces = { git = "…/zed-interfaces" }` consumers keep resolving, +# because Cargo finds the package through this workspace's members. +# +# Sibling repos that used `{ path = "../zed-interfaces" }` must now point at +# `{ path = "../zed-interfaces/src/rust" }`. +[workspace] +resolver = "3" +members = ["src/rust"] +default-members = ["src/rust"] diff --git a/codegen/generate.mjs b/codegen/generate.mjs new file mode 100644 index 0000000..39e4210 --- /dev/null +++ b/codegen/generate.mjs @@ -0,0 +1,727 @@ +#!/usr/bin/env node +// JSON Schema -> front-end language slices for zed-interfaces. +// +// node codegen/generate.mjs # write src/dart/** and src/ts/** +// node codegen/generate.mjs --check # fail if anything is out of date (CI) +// +// Direction of truth, which is the opposite of most codegen setups: +// +// src/rust/*.rs --(cargo run --example generate_schemas)--> schemas/*.json +// schemas/*.json --(this script)--> src/dart/lib/*.dart, src/ts/*.ts +// +// So Rust is hand-written and Dart/TS are derived. Only the schemas marked +// front-end-facing in schemas/index.json are emitted: Dart and TS exist for +// browser and Flutter clients, and the toolchain formats (manifest, lockfile, +// environment plans) have no front-end consumer. Never hand-edit the output. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, ".."); +const schemaDir = path.join(root, "schemas"); +const outDir = path.join(root, "src"); + +const INDEX_FILE = "index.json"; +const KNOWN_TARGETS = ["dart", "ts"]; +const DART_PACKAGE = "zed_interfaces"; +const BANNER = + "GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand.\n" + + "Regenerate with `npm run codegen` after changing the Rust types."; + +export class GenError extends Error {} +const fail = (msg) => { + throw new GenError(msg); +}; + +// --- naming ------------------------------------------------------------------ + +const oneLine = (s) => String(s ?? "").replace(/\s+/g, " ").trim(); +// Splits on separators *and* camelCase humps, so `VersionScheme` becomes +// VERSION_SCHEME rather than VERSIONSCHEME. +const words = (s) => + String(s).replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean); +const pascal = (s) => words(s).map((w) => w[0].toUpperCase() + w.slice(1)).join(""); +const camel = (s) => { + const p = pascal(s); + return p ? p[0].toLowerCase() + p.slice(1) : p; +}; +const snake = (s) => words(s).join("_").toLowerCase(); + +// Reserved words that cannot be identifiers at all. +const DART_KEYWORDS = new Set([ + "abstract", "as", "assert", "async", "await", "break", "case", "catch", "class", "const", + "continue", "covariant", "default", "deferred", "do", "dynamic", "else", "enum", "export", + "extends", "extension", "external", "factory", "false", "final", "finally", "for", "function", + "get", "hide", "if", "implements", "import", "in", "interface", "is", "late", "library", "mixin", + "new", "null", "on", "operator", "part", "required", "rethrow", "return", "sealed", "set", + "show", "static", "super", "switch", "sync", "this", "throw", "true", "try", "typedef", "var", + "void", "when", "while", "with", "yield", +]); +// Members every Object already has, plus the ones this generator adds. A field +// named `hashCode` or `toJson` would not compile. +const DART_CLASS_MEMBERS = new Set([ + "hashCode", "runtimeType", "toString", "noSuchMethod", "fromJson", "toJson", +]); +// Enums carry more: `name`, `index` and `values` are real members there, so an +// enum value called `name` collides even though a *field* called `name` is fine. +const DART_ENUM_MEMBERS = new Set([ + ...DART_CLASS_MEMBERS, "index", "values", "name", "compareTo", "wire", +]); +const dartIdent = (raw, reserved = DART_CLASS_MEMBERS) => { + let id = camel(raw); + if (!id) fail(`cannot derive a Dart identifier from ${JSON.stringify(raw)}`); + if (/^[0-9]/.test(id)) id = `v${id}`; + return DART_KEYWORDS.has(id) || reserved.has(id) ? `${id}_` : id; +}; +const dartEnumIdent = (raw) => dartIdent(raw, DART_ENUM_MEMBERS); +// Dart's own lints prefer single quotes; `$` starts an interpolation, so it +// has to be escaped even inside a plain literal. +const dartStr = (value) => + `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\$/g, "\\$").replace(/\n/g, "\\n")}'`; +// TS keeps wire keys verbatim so a decoded response is the interface; only +// non-identifier keys need quoting. +const TS_IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const tsKey = (wire) => (TS_IDENT_RE.test(wire) ? wire : JSON.stringify(wire)); + +// Doc-comment escapers: a description can never break out of the comment. +const dartDoc = (s, indent = "") => + oneLine(s) + ? oneLine(s).replace(/\r?\n/g, " ").match(/.{1,88}(\s|$)/g).map((line) => `${indent}/// ${line.trim()}`).join("\n") + "\n" + : ""; +const tsDoc = (s, indent = "") => { + const text = oneLine(s).replace(/\*\//g, "*\\/"); + if (!text) return ""; + return `${indent}/** ${text} */\n`; +}; + +// --- schema -> IR ------------------------------------------------------------ + +const refName = (schema) => + schema && typeof schema.$ref === "string" ? schema.$ref.split("/").pop() : null; + +/** Split `["string","null"]` / `anyOf:[T,{type:"null"}]` into (type, nullable). */ +function splitNullable(schema, where) { + if (Array.isArray(schema.anyOf)) { + const variants = schema.anyOf; + const nonNull = variants.filter((v) => !(v && v.type === "null")); + if (nonNull.length !== variants.length - 1 || nonNull.length !== 1) { + fail(`${where}: only \`anyOf: [T, {"type":"null"}]\` is supported, got ${variants.length} variants`); + } + return { schema: { ...nonNull[0] }, nullable: true }; + } + if (Array.isArray(schema.type)) { + const rest = schema.type.filter((t) => t !== "null"); + if (rest.length !== 1) { + fail(`${where}: only one concrete type plus "null" is supported, got ${JSON.stringify(schema.type)}`); + } + return { schema: { ...schema, type: rest[0] }, nullable: schema.type.includes("null") }; + } + return { schema, nullable: false }; +} + +/** String enums arrive as `enum: [...]` or as a `oneOf` of `const` strings. */ +function enumValues(schema) { + if (schema.type === "string" && Array.isArray(schema.enum) && schema.enum.length) { + return schema.enum.map((wire) => ({ wire, description: "" })); + } + if (Array.isArray(schema.oneOf) && schema.oneOf.length) { + const values = schema.oneOf.map((v) => + v && v.type === "string" && typeof v.const === "string" + ? { wire: v.const, description: v.description || "" } + : null, + ); + if (values.every(Boolean)) return values; + } + return null; +} + +/** + * Resolve a property schema to a type reference. `emit` registers synthesized + * inline enums so a schema can never silently degrade to a bare string. + */ +function typeRef(schema, where, emit) { + if (schema === true || schema === undefined || schema === null) return { kind: "any", nullable: true }; + if (schema === false) fail(`${where}: \`false\` schemas have no inhabitants`); + if (typeof schema !== "object") fail(`${where}: expected a schema object`); + + const { schema: inner, nullable } = splitNullable(schema, where); + + const ref = refName(inner); + if (ref) return { kind: "ref", name: ref, nullable }; + + const values = enumValues(inner); + if (values) { + // Inline enum on a property: give it a deterministic name and hoist it. + const name = emit ? emit(values, inner.description || "") : null; + if (!name) fail(`${where}: inline enums are only supported on object properties`); + return { kind: "ref", name, nullable }; + } + + switch (inner.type) { + case "string": + return { kind: "string", nullable }; + case "integer": + return { kind: "int", nullable }; + case "number": + return { kind: "double", nullable }; + case "boolean": + return { kind: "bool", nullable }; + case "array": + return { kind: "list", item: typeRef(inner.items, `${where}[]`, null), nullable }; + case "object": { + if (inner.properties) fail(`${where}: inline object types are not supported — give it a $def`); + return { kind: "map", value: typeRef(inner.additionalProperties, `${where}{}`, null), nullable }; + } + case undefined: + return { kind: "any", nullable: true }; + default: + fail(`${where}: unsupported JSON Schema type ${JSON.stringify(inner.type)}`); + } +} + +/** One named type: a class or an enum. */ +function buildType(name, schema, sourceFile, sink) { + const values = enumValues(schema); + if (values) { + return { kind: "enum", name, description: schema.description || "", values, sourceFile }; + } + if (schema.type !== "object" && schema.properties === undefined) { + fail(`${sourceFile}:${name}: only object and string-enum types can be generated`); + } + const required = new Set(schema.required || []); + const props = Object.entries(schema.properties || {}).map(([wire, propSchema]) => { + const where = `${sourceFile}:${name}.${wire}`; + const emitInline = (values_, description) => { + const inlineName = `${name}${pascal(wire)}`; + sink.push({ kind: "enum", name: inlineName, description, values: values_, sourceFile }); + return inlineName; + }; + const type = typeRef(propSchema, where, emitInline); + const hasDefault = propSchema && typeof propSchema === "object" && "default" in propSchema; + return { + wire, + dart: dartIdent(wire), + type, + required: required.has(wire), + // A serde default means the field is optional on the wire but always + // meaningful, so Dart keeps it non-nullable and falls back to the default. + default: hasDefault ? propSchema.default : undefined, + hasDefault, + description: (propSchema && propSchema.description) || "", + }; + }); + return { kind: "object", name, description: schema.description || "", props, sourceFile }; +} + +// --- load + validate --------------------------------------------------------- + +export function loadIndex(dir = schemaDir) { + const indexPath = path.join(dir, INDEX_FILE); + if (!fs.existsSync(indexPath)) fail(`missing schemas/${INDEX_FILE}`); + let index; + try { + index = JSON.parse(fs.readFileSync(indexPath, "utf8")); + } catch (error) { + fail(`schemas/${INDEX_FILE} is not valid JSON: ${error.message}`); + } + if (!Array.isArray(index.schemas)) fail(`schemas/${INDEX_FILE} must have a \`schemas\` array`); + + const listed = new Map(); + for (const entry of index.schemas) { + if (!entry || typeof entry.file !== "string") fail(`schemas/${INDEX_FILE}: every entry needs a \`file\``); + if (listed.has(entry.file)) fail(`schemas/${INDEX_FILE}: ${entry.file} is listed twice`); + if (!Array.isArray(entry.targets)) fail(`schemas/${INDEX_FILE}: ${entry.file} needs a \`targets\` array`); + for (const target of entry.targets) { + if (!KNOWN_TARGETS.includes(target)) { + fail(`schemas/${INDEX_FILE}: ${entry.file} requests unknown target ${JSON.stringify(target)}`); + } + } + if (!fs.existsSync(path.join(dir, entry.file))) { + fail(`schemas/${INDEX_FILE} lists ${entry.file}, which does not exist`); + } + listed.set(entry.file, entry); + } + + // A new schema must be classified, not silently skipped: that is the whole + // point of the index, so an unlisted file is an error rather than a default. + const onDisk = fs.readdirSync(dir).filter((f) => f.endsWith(".json") && f !== INDEX_FILE); + const missing = onDisk.filter((f) => !listed.has(f)); + if (missing.length) { + fail( + `schemas/${INDEX_FILE} does not classify: ${missing.join(", ")}\n` + + ` add each file with "targets": ["dart","ts"] (front-end-facing) or "targets": [] (Rust-only)`, + ); + } + return [...listed.values()]; +} + +/** + * Build the emit plan: every generated type, assigned to a module. A type used + * by two schemas is hoisted into `common` so neither slice defines it twice. + */ +export function plan() { + const entries = loadIndex().filter((entry) => entry.targets.length > 0); + const modules = new Map(); // module name -> { title, types[] } + const byName = new Map(); // type name -> { type, modules:Set } + + for (const entry of entries) { + const moduleName = entry.file.replace(/\.json$/, ""); + let doc; + try { + doc = JSON.parse(fs.readFileSync(path.join(schemaDir, entry.file), "utf8")); + } catch (error) { + fail(`${entry.file} is not valid JSON: ${error.message}`); + } + if (typeof doc.title !== "string" || !/^[A-Z][A-Za-z0-9]*$/.test(doc.title)) { + fail(`${entry.file}: needs a PascalCase \`title\` (it names the generated type)`); + } + + const collected = []; + for (const [name, def] of Object.entries(doc.$defs || {})) { + collected.push(buildType(name, def, entry.file, collected)); + } + collected.push(buildType(doc.title, doc, entry.file, collected)); + + modules.set(moduleName, { file: entry.file, targets: entry.targets, types: [], root: doc.title }); + for (const type of collected) { + const seen = byName.get(type.name); + if (seen) { + // Same name from two schemas is fine when the shape matches (schemars + // re-emits a shared Rust type into each schema); a mismatch is a real + // contract bug and must not be papered over by picking one. + if (JSON.stringify(stable(type)) !== JSON.stringify(stable(seen.type))) { + fail( + `type \`${type.name}\` is defined differently in ${seen.type.sourceFile} and ${type.sourceFile}`, + ); + } + seen.modules.add(moduleName); + } else { + byName.set(type.name, { type, modules: new Set([moduleName]) }); + } + } + } + + const home = new Map(); // type name -> module that defines it + for (const [name, { type, modules: owners }] of byName) { + home.set(name, owners.size > 1 ? "common" : [...owners][0]); + void type; + } + if ([...home.values()].includes("common")) { + modules.set("common", { file: null, targets: KNOWN_TARGETS, types: [], root: null }); + } + for (const [name, { type }] of byName) modules.get(home.get(name)).types.push(type); + + for (const module of modules.values()) { + module.types.sort((a, b) => a.name.localeCompare(b.name)); + } + // Deterministic module order so the barrels never churn. + const ordered = [...modules.entries()].sort((a, b) => a[0].localeCompare(b[0])); + return { modules: new Map(ordered), home, byName }; +} + +const stable = (value) => { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) { + if (key === "sourceFile") continue; + out[key] = stable(value[key]); + } + return out; + } + return value; +}; + +// --- Dart emitter ------------------------------------------------------------ + +const dartFileName = (module) => `${snake(module)}.dart`; + +function dartType(ref, kinds) { + const q = ref.nullable ? "?" : ""; + switch (ref.kind) { + case "string": return `String${q}`; + case "int": return `int${q}`; + case "double": return `double${q}`; + case "bool": return `bool${q}`; + case "any": return "Object?"; + case "ref": return `${ref.name}${q}`; + case "list": return `List<${dartType({ ...ref.item, nullable: ref.item.nullable }, kinds)}>${q}`; + case "map": return `Map${q}`; + default: return fail(`unsupported type ${ref.kind}`); + } +} + +/** + * Expression decoding `expr` (a `dynamic`) into the Dart type of `ref`. + * `nullable` emits null-aware access rather than a `== null ? null : …` + * ternary, so the output reads like hand-written Dart. + */ +function dartDecode(ref, expr, kinds, nullable = false) { + const q = nullable ? "?" : ""; + switch (ref.kind) { + case "string": return `${expr} as String${q}`; + case "int": return `(${expr} as num${q})${q}.toInt()`; + case "double": return `(${expr} as num${q})${q}.toDouble()`; + case "bool": return `${expr} as bool${q}`; + case "any": return expr; + case "ref": + if (kinds.get(ref.name) === "enum") { + return nullable + ? `${ref.name}.maybeFromJson(${expr} as String?)` + : `${ref.name}.fromJson(${expr} as String)`; + } + return nullable + ? `${expr} == null ? null : ${ref.name}.fromJson(${expr} as Map)` + : `${ref.name}.fromJson(${expr} as Map)`; + case "list": + return `(${expr} as List${q})${q}.map((e) => ${dartDecode(ref.item, "e", kinds, ref.item.nullable)}).toList()`; + case "map": + return `(${expr} as Map${q})${q}.map((k, v) => MapEntry(k, ${dartDecode(ref.value, "v", kinds, ref.value.nullable)}))`; + default: return fail(`unsupported type ${ref.kind}`); + } +} + +/** Expression encoding the Dart field `name` back to JSON. */ +function dartEncode(ref, name, kinds) { + const q = ref.nullable ? "?" : ""; + switch (ref.kind) { + case "ref": + // Both classes and enums expose `toJson()`, so the call is the same. + return `${name}${q}.toJson()`; + case "list": { + const inner = dartEncode(ref.item, "e", kinds); + return inner === "e" ? name : `${name}${q}.map((e) => ${inner}).toList()`; + } + case "map": { + const inner = dartEncode(ref.value, "v", kinds); + return inner === "v" ? name : `${name}${q}.map((k, v) => MapEntry(k, ${inner}))`; + } + default: + return name; + } +} + +function dartLiteral(ref, value, kinds) { + if (value === null || value === undefined) return "null"; + if (ref.kind === "ref" && kinds.get(ref.name) === "enum") { + return `${ref.name}.${dartEnumIdent(String(value))}`; + } + if (ref.kind === "list") return "const []"; + if (ref.kind === "map") return "const {}"; + if (typeof value === "string") return dartStr(value); + if (typeof value === "boolean" || typeof value === "number") return String(value); + return fail(`unsupported default ${JSON.stringify(value)}`); +} + +function dartEnum(type) { + const members = type.values.map((v) => ({ ...v, dart: dartEnumIdent(v.wire) })); + const seen = new Set(); + for (const m of members) { + if (seen.has(m.dart)) fail(`enum ${type.name}: values ${m.wire} collide on Dart name ${m.dart}`); + seen.add(m.dart); + } + const body = members + .map((m, i) => `${dartDoc(m.description, " ")} ${m.dart}(${dartStr(m.wire)})${i === members.length - 1 ? ";" : ","}`) + .join("\n"); + return `${dartDoc(type.description)}enum ${type.name} { +${body} + + const ${type.name}(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static ${type.name} fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown ${type.name}: $value'), + ); + + static ${type.name}? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +}`; +} + +/** How a property is modelled in Dart: nullable unless required or defaulted. */ +function dartFieldRef(prop) { + if (prop.type.kind === "any") return { ...prop.type, nullable: true }; + // A non-required field with a serde default is still always meaningful — the + // default fills in — so it stays non-nullable. + const nullable = prop.type.nullable || (!prop.required && !prop.hasDefault); + return { ...prop.type, nullable }; +} + +function dartClass(type, kinds) { + const ctorParams = type.props + .map((p) => { + const ref = dartFieldRef(p); + if (!ref.nullable && p.hasDefault && !p.required) { + return ` this.${p.dart} = ${dartLiteral(p.type, p.default, kinds)},`; + } + return ref.nullable ? ` this.${p.dart},` : ` required this.${p.dart},`; + }) + .join("\n"); + + const decode = type.props + .map((p) => { + const ref = dartFieldRef(p); + const raw = `json[${dartStr(p.wire)}]`; + if (p.type.kind === "any") return ` ${p.dart}: ${raw},`; + if (!ref.nullable && p.hasDefault && !p.required) { + // Absent means "take the default", which is what the Rust side does. + return ` ${p.dart}: ${raw} == null\n ? ${dartLiteral(p.type, p.default, kinds)}\n : ${dartDecode(p.type, raw, kinds)},`; + } + return ` ${p.dart}: ${dartDecode(p.type, raw, kinds, ref.nullable)},`; + }) + .join("\n"); + + const fields = type.props + .map((p) => `${dartDoc(p.description, " ")} final ${dartType(dartFieldRef(p), kinds)} ${p.dart};`) + .join("\n\n"); + + // Every key is written, with an explicit null for absent optionals: serde + // decodes null into None, so the round-trip back into Rust is lossless. + const encode = type.props + .map((p) => ` ${dartStr(p.wire)}: ${dartEncode(dartFieldRef(p), p.dart, kinds)},`) + .join("\n"); + + return `${dartDoc(type.description)}class ${type.name} { + const ${type.name}({ +${ctorParams} + }); + + factory ${type.name}.fromJson(Map json) => ${type.name}( +${decode} + ); + +${fields} + + Map toJson() => { +${encode} + }; +}`; +} + +function emitDart(built) { + const { modules, home } = built; + const kinds = new Map([...built.byName].map(([name, v]) => [name, v.type.kind])); + const files = {}; + const libFiles = []; + + for (const [moduleName, module] of modules) { + if (!module.targets.includes("dart")) continue; + const imports = new Set(); + for (const type of module.types) { + const refs = type.kind === "object" ? type.props.map((p) => p.type) : []; + for (const ref of refs) collectRefs(ref).forEach((name) => { + const owner = home.get(name); + if (owner && owner !== moduleName) imports.add(dartFileName(owner)); + }); + } + const header = + BANNER.split("\n").map((line) => `// ${line}`).join("\n") + + (module.file ? `\n// Source: schemas/${module.file}` : "\n// Source: types shared by more than one schema") + + "\n"; + const importBlock = [...imports].sort().map((f) => `import '${f}';`).join("\n"); + const body = module.types + .map((type) => (type.kind === "enum" ? dartEnum(type) : dartClass(type, kinds))) + .join("\n\n"); + const file = dartFileName(moduleName); + files[`dart/lib/${file}`] = `${header}${importBlock ? `\n${importBlock}\n` : ""}\n${body}\n`; + libFiles.push(file); + } + + const barrel = + BANNER.split("\n").map((line) => `// ${line}`).join("\n") + + `\n\n/// Front-end contract types for the zed-pkg registry, generated from the\n` + + `/// JSON Schemas that \`zed-interfaces\` (Rust) is the source of truth for.\n` + + `library ${DART_PACKAGE};\n\n` + + libFiles.sort().map((f) => `export '${f}';`).join("\n") + + "\n"; + files[`dart/lib/${DART_PACKAGE}.dart`] = barrel; + return files; +} + +function collectRefs(ref, acc = []) { + if (!ref) return acc; + if (ref.kind === "ref") acc.push(ref.name); + if (ref.kind === "list") collectRefs(ref.item, acc); + if (ref.kind === "map") collectRefs(ref.value, acc); + return acc; +} + +// --- TypeScript emitter ------------------------------------------------------ + +function tsType(ref, kinds) { + const base = (() => { + switch (ref.kind) { + case "string": return "string"; + case "int": return "number"; + case "double": return "number"; + case "bool": return "boolean"; + case "any": return "unknown"; + case "ref": return ref.name; + case "list": return `readonly ${tsType(ref.item, kinds)}[]`; + case "map": return `Readonly>`; + default: return fail(`unsupported type ${ref.kind}`); + } + })(); + // `unknown` already admits null; `unknown | null` is the same type written + // twice, and tsc's own lint rules flag it. + return ref.nullable && ref.kind !== "any" ? `${base} | null` : base; +} + +function tsEnum(type) { + const union = type.values.map((v) => JSON.stringify(v.wire)).join(" | "); + const values = type.values.map((v) => JSON.stringify(v.wire)).join(", "); + const docLines = type.values + .filter((v) => v.description) + .map((v) => ` * - \`${v.wire}\`: ${oneLine(v.description).replace(/\*\//g, "*\\/")}`) + .join("\n"); + const doc = type.description || docLines + ? `/**\n${oneLine(type.description) ? ` * ${oneLine(type.description).replace(/\*\//g, "*\\/")}\n` : ""}${docLines ? `${docLines}\n` : ""} */\n` + : ""; + return `${doc}export type ${type.name} = ${union}; + +/** Every \`${type.name}\` value, in schema order — for validation and pickers. */ +export const ${snake(type.name).toUpperCase()}_VALUES = [${values}] as const;`; +} + +function tsInterface(type, kinds) { + const fields = type.props + .map((p) => { + const optional = !p.required; + return `${tsDoc(p.description, " ")} readonly ${tsKey(p.wire)}${optional ? "?" : ""}: ${tsType(p.type, kinds)};`; + }) + .join("\n"); + return `${tsDoc(type.description)}export interface ${type.name} { +${fields} +}`; +} + +function emitTs(built) { + const { modules, home } = built; + const kinds = new Map([...built.byName].map(([name, v]) => [name, v.type.kind])); + const files = {}; + const moduleFiles = []; + + for (const [moduleName, module] of modules) { + if (!module.targets.includes("ts")) continue; + const imports = new Map(); // module -> Set(names) + for (const type of module.types) { + if (type.kind !== "object") continue; + for (const prop of type.props) { + for (const name of collectRefs(prop.type)) { + const owner = home.get(name); + if (owner && owner !== moduleName) { + if (!imports.has(owner)) imports.set(owner, new Set()); + imports.get(owner).add(name); + } + } + } + } + const header = + BANNER.split("\n").map((line) => `// ${line}`).join("\n") + + (module.file ? `\n// Source: schemas/${module.file}` : "\n// Source: types shared by more than one schema") + + "\n"; + const importBlock = [...imports.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([owner, names]) => `import type { ${[...names].sort().join(", ")} } from "./${owner}";`) + .join("\n"); + const body = module.types + .map((type) => (type.kind === "enum" ? tsEnum(type) : tsInterface(type, kinds))) + .join("\n\n"); + files[`ts/${moduleName}.ts`] = `${header}${importBlock ? `\n${importBlock}\n` : ""}\n${body}\n`; + moduleFiles.push(moduleName); + } + + files["ts/index.ts"] = + BANNER.split("\n").map((line) => `// ${line}`).join("\n") + + "\n\n" + + moduleFiles.sort().map((m) => `export * from "./${m}";`).join("\n") + + "\n"; + return files; +} + +// --- build + write ----------------------------------------------------------- + +export function build() { + const built = plan(); + return { ...emitDart(built), ...emitTs(built) }; +} + +/** Exposed for codegen/generate.test.mjs — not part of any published surface. */ +export const internals = { + buildType, typeRef, emitDart, emitTs, dartIdent, dartEnumIdent, dartStr, snake, pascal, +}; + +/** Generated files that are no longer produced must not linger. */ +function staleFiles(expected) { + const stale = []; + for (const dir of ["dart/lib", "ts"]) { + const abs = path.join(outDir, dir); + if (!fs.existsSync(abs)) continue; + for (const name of fs.readdirSync(abs)) { + const rel = `${dir}/${name}`; + const full = path.join(abs, name); + if (fs.statSync(full).isDirectory()) continue; + if (!/\.(dart|ts)$/.test(name)) continue; + if (!(rel in expected)) stale.push(rel); + } + } + return stale; +} + +function main() { + const check = process.argv.includes("--check"); + let files; + try { + files = build(); + } catch (error) { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(2); + } + + let drift = 0; + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(outDir, rel); + if (check) { + const current = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null; + if (current !== content) { + console.error(`drift: src/${rel}`); + if (process.env.GITHUB_ACTIONS === "true") { + console.error(`::error file=src/${rel}::generated slice is out of date; run \`npm run codegen\``); + } + drift += 1; + } + } else { + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + console.log(`wrote src/${rel}`); + } + } + + for (const rel of staleFiles(files)) { + if (check) { + console.error(`stale: src/${rel} is no longer generated`); + drift += 1; + } else { + fs.rmSync(path.join(outDir, rel)); + console.log(`removed src/${rel}`); + } + } + + if (check && drift > 0) { + console.error(`${drift} file(s) out of date — run: npm run codegen`); + process.exit(1); + } + if (check) console.log(`generated slices up to date (${Object.keys(files).length} files)`); +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) main(); diff --git a/codegen/generate.test.mjs b/codegen/generate.test.mjs new file mode 100644 index 0000000..a12cddb --- /dev/null +++ b/codegen/generate.test.mjs @@ -0,0 +1,218 @@ +// Tests for the JSON Schema -> Dart/TS generator. +// +// node --test codegen/generate.test.mjs +// +// The generator is the only thing standing between a Rust type change and a +// front-end that silently decodes the wrong shape, so the cases below pin the +// decisions that are easy to get wrong: nullability, serde defaults, reserved +// identifiers, shared types, and the "nothing is skipped silently" rule. + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { GenError, build, internals, loadIndex, plan } from "./generate.mjs"; + +const { buildType, emitDart, emitTs, dartIdent, dartEnumIdent, dartStr, snake } = internals; + +/** Minimal single-module plan so the emitters can be exercised in isolation. */ +function planOf(name, schema, defs = {}) { + const collected = []; + for (const [defName, def] of Object.entries(defs)) collected.push(buildType(defName, def, "t.json", collected)); + collected.push(buildType(name, schema, "t.json", collected)); + const byName = new Map(collected.map((type) => [type.name, { type, modules: new Set(["t"]) }])); + const home = new Map([...byName.keys()].map((k) => [k, "t"])); + const modules = new Map([["t", { file: "t.json", targets: ["dart", "ts"], types: collected, root: name }]]); + return { modules, home, byName }; +} + +const dartOf = (...args) => emitDart(planOf(...args))["dart/lib/t.dart"]; +const tsOf = (...args) => emitTs(planOf(...args))["ts/t.ts"]; + +const OBJ = (properties, required = []) => ({ type: "object", properties, required }); + +test("required scalars are non-nullable and decoded directly", () => { + const dart = dartOf("Thing", OBJ({ org: { type: "string" } }, ["org"])); + assert.match(dart, /required this\.org,/); + assert.match(dart, /final String org;/); + assert.match(dart, /org: json\['org'\] as String,/); + assert.match(tsOf("Thing", OBJ({ org: { type: "string" } }, ["org"])), /readonly org: string;/); +}); + +test("a nullable type decodes null-aware rather than through a ternary", () => { + const dart = dartOf("Thing", OBJ({ latest: { type: ["string", "null"] } }, ["latest"])); + assert.match(dart, /final String\? latest;/); + assert.match(dart, /latest: json\['latest'\] as String\?,/); + assert.doesNotMatch(dart, /latest: json\['latest'\] == null/); +}); + +test("an optional field with no default is nullable in Dart and optional in TS", () => { + const schema = OBJ({ tags: { type: "array", items: { type: "string" } } }); + assert.match(dartOf("Thing", schema), /final List\? tags;/); + assert.match(tsOf("Thing", schema), /readonly tags\?: readonly string\[\];/); +}); + +test("a serde default keeps the Dart field non-nullable and fills the default in", () => { + const schema = OBJ({ yanked: { type: "boolean", default: false } }); + const dart = dartOf("Thing", schema); + assert.match(dart, /this\.yanked = false,/); + assert.match(dart, /final bool yanked;/); + assert.match(dart, /yanked: json\['yanked'\] == null/); + // TS models the wire, where the key really can be absent. + assert.match(tsOf("Thing", schema), /readonly yanked\?: boolean;/); +}); + +test("an enum $ref defaults to the matching Dart enum value", () => { + const dart = dartOf( + "Thing", + OBJ({ format: { $ref: "#/$defs/ArtifactFormat", default: "tar.gz" } }), + { ArtifactFormat: { type: "string", enum: ["tar.gz", "zip"] } }, + ); + assert.match(dart, /this\.format = ArtifactFormat\.tarGz,/); + assert.match(dart, /tarGz\('tar\.gz'\),/); + assert.match(dart, /format: json\['format'\] == null\n\s+\? ArtifactFormat\.tarGz/); +}); + +test("oneOf-of-const is an enum, and per-variant docs survive into both languages", () => { + const defs = { + AuditAction: { + oneOf: [ + { type: "string", const: "publish", description: "A version was published." }, + { type: "string", const: "org_claim", description: "The org was claimed." }, + ], + }, + }; + const dart = dartOf("Thing", OBJ({ action: { $ref: "#/$defs/AuditAction" } }, ["action"]), defs); + assert.match(dart, /\/\/\/ A version was published\.\n\s+publish\('publish'\),/); + assert.match(dart, /orgClaim\('org_claim'\);/); + const ts = tsOf("Thing", OBJ({ action: { $ref: "#/$defs/AuditAction" } }, ["action"]), defs); + assert.match(ts, /export type AuditAction = "publish" \| "org_claim";/); + assert.match(ts, /AUDIT_ACTION_VALUES = \["publish", "org_claim"\] as const;/); +}); + +test("a nullable enum ref uses maybeFromJson instead of an unchecked cast", () => { + const dart = dartOf( + "Thing", + OBJ({ kind: { anyOf: [{ $ref: "#/$defs/Op" }, { type: "null" }] } }, ["kind"]), + { Op: { type: "string", enum: ["upsert", "delete"] } }, + ); + assert.match(dart, /kind: Op\.maybeFromJson\(json\['kind'\] as String\?\),/); + assert.match(dart, /final Op\? kind;/); +}); + +test("lists of refs round-trip through fromJson/toJson", () => { + const dart = dartOf( + "Thing", + OBJ({ entries: { type: "array", items: { $ref: "#/$defs/Entry" } } }, ["entries"]), + { Entry: OBJ({ id: { type: "string" } }, ["id"]) }, + ); + assert.match(dart, /entries: \(json\['entries'\] as List\)\.map\(\(e\) => Entry\.fromJson\(e as Map\)\)\.toList\(\),/); + assert.match(dart, /'entries': entries\.map\(\(e\) => e\.toJson\(\)\)\.toList\(\),/); +}); + +test("maps become Map/Record of the value type", () => { + const schema = OBJ({ env: { type: "object", additionalProperties: { type: "string" } } }, ["env"]); + assert.match(dartOf("Thing", schema), /final Map env;/); + assert.match(tsOf("Thing", schema), /readonly env: Readonly>;/); +}); + +test("a `true` schema is opaque JSON, not a guessed type", () => { + const schema = OBJ({ row: true }, ["row"]); + assert.match(dartOf("Thing", schema), /final Object\? row;/); + assert.match(tsOf("Thing", schema), /readonly row: unknown;/); +}); + +test("`name` is a legal field but an illegal enum value, and only the latter is escaped", () => { + assert.equal(dartIdent("name"), "name"); + assert.equal(dartEnumIdent("name"), "name_"); + assert.equal(dartIdent("default"), "default_"); + assert.equal(dartIdent("hashCode"), "hashCode_"); + assert.equal(dartIdent("vcs_commit"), "vcsCommit"); + assert.equal(snake("VersionScheme"), "version_scheme"); +}); + +test("Dart string literals escape quotes and interpolation", () => { + assert.equal(dartStr("it's"), "'it\\'s'"); + assert.equal(dartStr("$value"), "'\\$value'"); +}); + +test("an unsupported construct fails loudly instead of emitting a wrong type", () => { + assert.throws(() => buildType("T", OBJ({ x: { type: ["string", "integer"] } }, ["x"]), "t.json", []), GenError); + assert.throws( + () => buildType("T", OBJ({ x: { anyOf: [{ type: "string" }, { type: "integer" }] } }, ["x"]), "t.json", []), + GenError, + ); + assert.throws( + () => buildType("T", OBJ({ x: { type: "object", properties: { y: { type: "string" } } } }, ["x"]), "t.json", []), + GenError, + ); +}); + +// --- the index is the demarcation, so its rules get their own coverage ------- + +function withSchemaDir(files, fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "zed-codegen-")); + try { + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), typeof content === "string" ? content : JSON.stringify(content)); + } + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test("a schema that is not classified in index.json is an error, not a silent skip", () => { + withSchemaDir( + { + "index.json": { schemas: [{ file: "a.json", targets: ["ts"] }] }, + "a.json": { title: "A", type: "object", properties: {} }, + "b.json": { title: "B", type: "object", properties: {} }, + }, + (dir) => { + assert.throws(() => loadIndex(dir), (error) => error instanceof GenError && /does not classify: b\.json/.test(error.message)); + }, + ); +}); + +test("index.json rejects unknown targets, missing files, and duplicates", () => { + withSchemaDir({ "index.json": { schemas: [{ file: "a.json", targets: ["swift"] }] }, "a.json": {} }, (dir) => + assert.throws(() => loadIndex(dir), /unknown target "swift"/)); + withSchemaDir({ "index.json": { schemas: [{ file: "gone.json", targets: [] }] } }, (dir) => + assert.throws(() => loadIndex(dir), /lists gone\.json, which does not exist/)); + withSchemaDir( + { "index.json": { schemas: [{ file: "a.json", targets: [] }, { file: "a.json", targets: [] }] }, "a.json": {} }, + (dir) => assert.throws(() => loadIndex(dir), /a\.json is listed twice/), + ); +}); + +// --- properties of the real repository --------------------------------------- + +test("every schema in the repository is classified", () => { + const entries = loadIndex(); + const files = fs.readdirSync(path.join(import.meta.dirname, "..", "schemas")).filter((f) => f.endsWith(".json") && f !== "index.json"); + assert.equal(entries.length, files.length); +}); + +test("Rust-only schemas emit nothing, front-end schemas emit both slices", () => { + const emitted = build(); + const frontEnd = loadIndex().filter((entry) => entry.targets.length); + for (const entry of frontEnd) { + const module = entry.file.replace(/\.json$/, ""); + assert.ok(`ts/${module}.ts` in emitted, `missing ts slice for ${entry.file}`); + assert.ok(`dart/lib/${snake(module)}.dart` in emitted, `missing dart slice for ${entry.file}`); + } + for (const entry of loadIndex().filter((e) => !e.targets.length)) { + const module = entry.file.replace(/\.json$/, ""); + assert.ok(!(`ts/${module}.ts` in emitted), `${entry.file} is Rust-only but emitted a TS slice`); + } +}); + +test("a type used by two schemas is defined once, in common", () => { + const { home } = plan(); + // PackageSummary is reachable from both the browse listing and search. + assert.equal(home.get("PackageSummary"), "common"); + assert.equal(home.get("AuditEntry"), "audit-log-response"); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..643a49a --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "@zed-pkg/zed-interfaces-codegen", + "version": "0.1.0", + "private": true, + "description": "Repository tooling for zed-interfaces: turns the front-end-facing JSON Schemas into the src/dart and src/ts slices. The publishable TypeScript package is src/ts, not this one.", + "type": "module", + "scripts": { + "codegen": "node codegen/generate.mjs", + "codegen:check": "node codegen/generate.mjs --check", + "test": "node --test codegen/*.test.mjs && node codegen/generate.mjs --check" + } +} diff --git a/schemas/index.json b/schemas/index.json new file mode 100644 index 0000000..ed46768 --- /dev/null +++ b/schemas/index.json @@ -0,0 +1,164 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ZedInterfacesSchemaIndex", + "description": "Demarcates which JSON Schemas are code-generated into the front-end language slices. Rust is deliberately absent: src/rust is the hand-written source of truth that schemas/*.json are generated FROM (cargo run --example generate_schemas), so every schema is already a Rust type. Dart and TypeScript are generated the other way round, from these schemas into src/dart and src/ts, and only for the subset a browser or Flutter client actually parses.", + "rule": "A schema is front-end-facing if and only if a non-Rust client decodes it directly off the registry HTTP API or the sync stream. Toolchain and on-disk formats (manifest, lockfile, environment plans, nix/oci/native adapter records, publish metadata) stay Rust-only: they are consumed by zed-cli and the servers, and generating them would put 20+ transitive types into every front-end bundle for no consumer.", + "targets": ["dart", "ts"], + "schemas": [ + { + "file": "api-error.json", + "targets": ["dart", "ts"], + "why": "Error envelope of every registry endpoint; every client renders it." + }, + { + "file": "package-metadata.json", + "targets": ["dart", "ts"], + "why": "GET /packages/{org}/{name} — the package detail page." + }, + { + "file": "version-metadata.json", + "targets": ["dart", "ts"], + "why": "GET /packages/{org}/{name}/{version} — the version detail page." + }, + { + "file": "package-list-response.json", + "targets": ["dart", "ts"], + "why": "Registry browse/index listing." + }, + { + "file": "search-response.json", + "targets": ["dart", "ts"], + "why": "Keyword search results." + }, + { + "file": "semantic-search-request.json", + "targets": ["dart", "ts"], + "why": "Semantic search is issued straight from the UI." + }, + { + "file": "semantic-search-response.json", + "targets": ["dart", "ts"], + "why": "Semantic search results, incl. per-hit scores the UI displays." + }, + { + "file": "publish-response.json", + "targets": ["dart", "ts"], + "why": "Shown after a UI-driven or extension-driven publish." + }, + { + "file": "claim-org-request.json", + "targets": ["dart", "ts"], + "why": "Org claim flow is a web form." + }, + { + "file": "claim-org-response.json", + "targets": ["dart", "ts"], + "why": "Org claim flow is a web form." + }, + { + "file": "yank-request.json", + "targets": ["dart", "ts"], + "why": "Yank/unyank is exposed in the package admin UI." + }, + { + "file": "yank-response.json", + "targets": ["dart", "ts"], + "why": "Yank/unyank is exposed in the package admin UI." + }, + { + "file": "audit-log-response.json", + "targets": ["dart", "ts"], + "why": "Org governance screen renders the audit trail." + }, + { + "file": "audit-integrity-response.json", + "targets": ["dart", "ts"], + "why": "The same screen verifies the hash chain client-side." + }, + { + "file": "sync-change-event.json", + "targets": ["dart", "ts"], + "why": "Live sync envelope decoded by browser and Flutter transports." + }, + { + "file": "sync-write-mode.json", + "targets": ["dart", "ts"], + "why": "Sync client configuration surfaced in UI settings." + }, + { + "file": "sync-error-policy.json", + "targets": ["dart", "ts"], + "why": "Sync client configuration surfaced in UI settings." + }, + { + "file": "sync-conflict-resolution.json", + "targets": ["dart", "ts"], + "why": "Sync client configuration surfaced in UI settings." + }, + { + "file": "embedding-upsert-request.json", + "targets": [], + "why": "Server-side index ingestion; never issued by a front end." + }, + { + "file": "manifest.json", + "targets": [], + "why": "On-disk .zpkg.toml model — zed-cli and the servers only." + }, + { + "file": "lockfile.json", + "targets": [], + "why": "On-disk .zpkg.lock model — zed-cli and the servers only." + }, + { + "file": "publish-meta.json", + "targets": [], + "why": "Publish-time provenance blob assembled by the CLI." + }, + { + "file": "environment-plan.json", + "targets": [], + "why": "Toolchain/environment planning — CLI only." + }, + { + "file": "environment-plan-v1.json", + "targets": [], + "why": "Toolchain/environment planning — CLI only." + }, + { + "file": "environment-lock-v1.json", + "targets": [], + "why": "Toolchain/environment planning — CLI only." + }, + { + "file": "native-dependency-lock.json", + "targets": [], + "why": "Native-registry interop record — CLI and servers only." + }, + { + "file": "native-registry-adapter-record.json", + "targets": [], + "why": "Native-registry interop record — CLI and servers only." + }, + { + "file": "nix-adapter-record.json", + "targets": [], + "why": "Nix interop record — CLI only." + }, + { + "file": "nix-export-plan.json", + "targets": [], + "why": "Nix interop record — CLI only." + }, + { + "file": "nix-export-section.json", + "targets": [], + "why": "Nix interop record — CLI only." + }, + { + "file": "oci-adapter-record.json", + "targets": [], + "why": "OCI interop record — CLI and servers only." + } + ] +} diff --git a/src/dart/analysis_options.yaml b/src/dart/analysis_options.yaml new file mode 100644 index 0000000..4e3f1dc --- /dev/null +++ b/src/dart/analysis_options.yaml @@ -0,0 +1,12 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # Everything here is generated: a warning means the generator emitted bad + # Dart, which is a bug to fix in codegen/generate.mjs, not to tolerate. + unused_import: error + dead_code: error diff --git a/src/dart/lib/api_error.dart b/src/dart/lib/api_error.dart new file mode 100644 index 0000000..4a1b4f1 --- /dev/null +++ b/src/dart/lib/api_error.dart @@ -0,0 +1,27 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/api-error.json + +/// Error body returned with any non-2xx status. +class ApiError { + const ApiError({ + required this.code, + required this.message, + }); + + factory ApiError.fromJson(Map json) => ApiError( + code: json['code'] as String, + message: json['message'] as String, + ); + + /// Stable machine-readable code, e.g. `not_found`, `sha256_mismatch`, `tag_not_found`, + /// `unauthorized`, `org_taken`. + final String code; + + final String message; + + Map toJson() => { + 'code': code, + 'message': message, + }; +} diff --git a/src/dart/lib/audit_integrity_response.dart b/src/dart/lib/audit_integrity_response.dart new file mode 100644 index 0000000..f9a74e6 --- /dev/null +++ b/src/dart/lib/audit_integrity_response.dart @@ -0,0 +1,52 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/audit-integrity-response.json + +/// The result of walking an org's audit chain end to end. +class AuditIntegrityResponse { + const AuditIntegrityResponse({ + required this.entriesChecked, + this.firstBadSeq, + this.headHash, + required this.intact, + required this.org, + this.problem, + }); + + factory AuditIntegrityResponse.fromJson(Map json) => AuditIntegrityResponse( + entriesChecked: (json['entries_checked'] as num).toInt(), + firstBadSeq: (json['first_bad_seq'] as num?)?.toInt(), + headHash: json['head_hash'] as String?, + intact: json['intact'] as bool, + org: json['org'] as String, + problem: json['problem'] as String?, + ); + + final int entriesChecked; + + /// The `seq` where verification first failed, if any. + final int? firstBadSeq; + + /// The newest entry's hash — an anchor an operator can record externally so that later + /// truncation of the whole tail is also detectable. + final String? headHash; + + /// True only when every entry's hash recomputes and every link matches. + final bool intact; + + final String org; + + /// Machine-readable failure kind: `hash_mismatch` (an entry was edited), `broken_link` (an + /// entry's `prev_hash` does not match its predecessor), or `sequence_gap` (an entry was + /// deleted). + final String? problem; + + Map toJson() => { + 'entries_checked': entriesChecked, + 'first_bad_seq': firstBadSeq, + 'head_hash': headHash, + 'intact': intact, + 'org': org, + 'problem': problem, + }; +} diff --git a/src/dart/lib/audit_log_response.dart b/src/dart/lib/audit_log_response.dart new file mode 100644 index 0000000..d6308af --- /dev/null +++ b/src/dart/lib/audit_log_response.dart @@ -0,0 +1,135 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/audit-log-response.json + +/// A state-changing action recorded in an org's audit log. Reads are never audited — only +/// mutations of published state and of the namespace itself, so the log answers "who +/// changed what" without drowning in traffic. +enum AuditAction { + /// A version was published. + publish('publish'), + /// A version was yanked (hidden from fresh resolution). + yank('yank'), + /// A previously yanked version was restored. + unyank('unyank'), + /// The org namespace was claimed. + orgClaim('org_claim'); + + const AuditAction(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static AuditAction fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown AuditAction: $value'), + ); + + static AuditAction? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} + +/// One audit-log record. The actor is identified by the *token* that acted — its name and +/// role, never its secret — which is the identity a registry actually has (zed-docs issue +/// #7 governance). +class AuditEntry { + const AuditEntry({ + required this.action, + this.actionKind, + required this.actorRole, + required this.actorTokenName, + required this.at, + this.detail, + this.entryHash, + this.prevHash, + this.seq = 0, + required this.subject, + }); + + factory AuditEntry.fromJson(Map json) => AuditEntry( + action: json['action'] as String, + actionKind: AuditAction.maybeFromJson(json['action_kind'] as String?), + actorRole: json['actor_role'] as String, + actorTokenName: json['actor_token_name'] as String, + at: json['at'] as String, + detail: json['detail'] as String?, + entryHash: json['entry_hash'] as String?, + prevHash: json['prev_hash'] as String?, + seq: json['seq'] == null + ? 0 + : (json['seq'] as num).toInt(), + subject: json['subject'] as String, + ); + + /// Raw action string; `action_kind` is the parsed form when recognized. + final String action; + + /// Parsed action, absent when this server build doesn't recognize it. + final AuditAction? actionKind; + + /// The acting token's role (`owner`/`publisher`/`reader`, or `admin` for unscoped tokens). + final String actorRole; + + /// Human-readable name of the token that acted. + final String actorTokenName; + + /// RFC 3339 timestamp of the action. + final String at; + + /// Extra context, e.g. the artifact sha256 for a publish. + final String? detail; + + /// `sha256(audit_chain_preimage(..))` for this entry, lowercase hex. Empty from a pre-chain + /// server. + final String? entryHash; + + /// The previous entry's `entry_hash`; `None` for the first entry in an org's chain. Linking + /// each entry to its predecessor is what makes a silent deletion or edit detectable. + final String? prevHash; + + /// Position in the org's append-only chain, starting at 1. Gaps mean entries were deleted. + /// Defaults to 0 when read from a server that predates the chain. + final int seq; + + /// What was acted on, e.g. `acme/http-kit@1.2.0` or the org slug. + final String subject; + + Map toJson() => { + 'action': action, + 'action_kind': actionKind?.toJson(), + 'actor_role': actorRole, + 'actor_token_name': actorTokenName, + 'at': at, + 'detail': detail, + 'entry_hash': entryHash, + 'prev_hash': prevHash, + 'seq': seq, + 'subject': subject, + }; +} + +class AuditLogResponse { + const AuditLogResponse({ + required this.entries, + required this.org, + }); + + factory AuditLogResponse.fromJson(Map json) => AuditLogResponse( + entries: (json['entries'] as List).map((e) => AuditEntry.fromJson(e as Map)).toList(), + org: json['org'] as String, + ); + + /// Most recent first. + final List entries; + + final String org; + + Map toJson() => { + 'entries': entries.map((e) => e.toJson()).toList(), + 'org': org, + }; +} diff --git a/src/dart/lib/claim_org_request.dart b/src/dart/lib/claim_org_request.dart new file mode 100644 index 0000000..d87a751 --- /dev/null +++ b/src/dart/lib/claim_org_request.dart @@ -0,0 +1,19 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/claim-org-request.json + +class ClaimOrgRequest { + const ClaimOrgRequest({ + required this.slug, + }); + + factory ClaimOrgRequest.fromJson(Map json) => ClaimOrgRequest( + slug: json['slug'] as String, + ); + + final String slug; + + Map toJson() => { + 'slug': slug, + }; +} diff --git a/src/dart/lib/claim_org_response.dart b/src/dart/lib/claim_org_response.dart new file mode 100644 index 0000000..a4645af --- /dev/null +++ b/src/dart/lib/claim_org_response.dart @@ -0,0 +1,25 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/claim-org-response.json + +class ClaimOrgResponse { + const ClaimOrgResponse({ + required this.created, + required this.slug, + }); + + factory ClaimOrgResponse.fromJson(Map json) => ClaimOrgResponse( + created: json['created'] as bool, + slug: json['slug'] as String, + ); + + /// False when the caller already owned the org. + final bool created; + + final String slug; + + Map toJson() => { + 'created': created, + 'slug': slug, + }; +} diff --git a/src/dart/lib/common.dart b/src/dart/lib/common.dart new file mode 100644 index 0000000..154f76a --- /dev/null +++ b/src/dart/lib/common.dart @@ -0,0 +1,40 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: types shared by more than one schema + +class PackageSummary { + const PackageSummary({ + this.description, + this.latest, + required this.name, + required this.org, + this.tags, + }); + + factory PackageSummary.fromJson(Map json) => PackageSummary( + description: json['description'] as String?, + latest: json['latest'] as String?, + name: json['name'] as String, + org: json['org'] as String, + tags: (json['tags'] as List?)?.map((e) => e as String).toList(), + ); + + final String? description; + + final String? latest; + + final String name; + + final String org; + + /// Free-form tags for filtering/discovery. + final List? tags; + + Map toJson() => { + 'description': description, + 'latest': latest, + 'name': name, + 'org': org, + 'tags': tags, + }; +} diff --git a/src/dart/lib/package_list_response.dart b/src/dart/lib/package_list_response.dart new file mode 100644 index 0000000..5771cd5 --- /dev/null +++ b/src/dart/lib/package_list_response.dart @@ -0,0 +1,28 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/package-list-response.json + +import 'common.dart'; + +/// Response for `GET /v1/packages` (list all). +class PackageListResponse { + const PackageListResponse({ + required this.items, + required this.total, + }); + + factory PackageListResponse.fromJson(Map json) => PackageListResponse( + items: (json['items'] as List).map((e) => PackageSummary.fromJson(e as Map)).toList(), + total: (json['total'] as num).toInt(), + ); + + final List items; + + /// Total packages matching the filter (before limit/offset). + final int total; + + Map toJson() => { + 'items': items.map((e) => e.toJson()).toList(), + 'total': total, + }; +} diff --git a/src/dart/lib/package_metadata.dart b/src/dart/lib/package_metadata.dart new file mode 100644 index 0000000..56a42b0 --- /dev/null +++ b/src/dart/lib/package_metadata.dart @@ -0,0 +1,128 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/package-metadata.json + +class PackageMetadata { + const PackageMetadata({ + this.description, + this.latest, + required this.name, + required this.org, + required this.repoUrl, + this.tags, + required this.vcs, + this.versionScheme, + required this.versions, + }); + + factory PackageMetadata.fromJson(Map json) => PackageMetadata( + description: json['description'] as String?, + latest: json['latest'] as String?, + name: json['name'] as String, + org: json['org'] as String, + repoUrl: json['repo_url'] as String, + tags: (json['tags'] as List?)?.map((e) => e as String).toList(), + vcs: Vcs.fromJson(json['vcs'] as String), + versionScheme: VersionScheme.maybeFromJson(json['version_scheme'] as String?), + versions: (json['versions'] as List).map((e) => e as String).toList(), + ); + + final String? description; + + final String? latest; + + final String name; + + final String org; + + final String repoUrl; + + /// Free-form tags for filtering/discovery (multi-tag lookup). + final List? tags; + + final Vcs vcs; + + /// How this package's versions should be interpreted (semver by default). + final VersionScheme? versionScheme; + + /// All published, non-yanked versions, newest first. + final List versions; + + Map toJson() => { + 'description': description, + 'latest': latest, + 'name': name, + 'org': org, + 'repo_url': repoUrl, + 'tags': tags, + 'vcs': vcs.toJson(), + 'version_scheme': versionScheme?.toJson(), + 'versions': versions, + }; +} + +/// Version-control systems a package's source repository can live on. zed-pkg is +/// VCS-agnostic by design: what gets installed is always a registry artifact, and the VCS +/// is where provenance (tags) is anchored. Authors must create a matching tag on their +/// declared backing repo (GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, +/// Azure DevOps, CodeCommit, Radicle, or self-hosted) before publishing. `jj` and Sapling +/// are git-compatible and push to git remotes, so their provenance is verified through git +/// tags. +enum Vcs { + git('git'), + hg('hg'), + jj('jj'), + sapling('sapling'), + fossil('fossil'), + pijul('pijul'); + + const Vcs(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static Vcs fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown Vcs: $value'), + ); + + static Vcs? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} + +/// How a package's `version` string (and its published tags) should be interpreted. +/// Defaults to [`VersionScheme::Semver`], which covers the vast majority of modern +/// ecosystems. +enum VersionScheme { + /// Semantic Versioning. `package.version` must be valid semver; ranges (`^1.2`, `>=0.2 + /// <0.5`) resolve to the max satisfying stable version. + semver('semver'), + /// Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver total order + /// (leading zeros dropped, padded to major.minor.patch) so the same range algebra applies. + /// See [`normalize_calver`]. + calver('calver'), + /// Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra: a requirement + /// must match a published version **exactly**. + opaque('opaque'); + + const VersionScheme(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static VersionScheme fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown VersionScheme: $value'), + ); + + static VersionScheme? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} diff --git a/src/dart/lib/publish_response.dart b/src/dart/lib/publish_response.dart new file mode 100644 index 0000000..8e01f57 --- /dev/null +++ b/src/dart/lib/publish_response.dart @@ -0,0 +1,34 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/publish-response.json + +class PublishResponse { + const PublishResponse({ + required this.name, + required this.org, + required this.sha256, + required this.version, + }); + + factory PublishResponse.fromJson(Map json) => PublishResponse( + name: json['name'] as String, + org: json['org'] as String, + sha256: json['sha256'] as String, + version: json['version'] as String, + ); + + final String name; + + final String org; + + final String sha256; + + final String version; + + Map toJson() => { + 'name': name, + 'org': org, + 'sha256': sha256, + 'version': version, + }; +} diff --git a/src/dart/lib/search_response.dart b/src/dart/lib/search_response.dart new file mode 100644 index 0000000..3022915 --- /dev/null +++ b/src/dart/lib/search_response.dart @@ -0,0 +1,26 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/search-response.json + +import 'common.dart'; + +class SearchResponse { + const SearchResponse({ + required this.items, + required this.query, + }); + + factory SearchResponse.fromJson(Map json) => SearchResponse( + items: (json['items'] as List).map((e) => PackageSummary.fromJson(e as Map)).toList(), + query: json['query'] as String, + ); + + final List items; + + final String query; + + Map toJson() => { + 'items': items.map((e) => e.toJson()).toList(), + 'query': query, + }; +} diff --git a/src/dart/lib/semantic_search_request.dart b/src/dart/lib/semantic_search_request.dart new file mode 100644 index 0000000..57a3aa6 --- /dev/null +++ b/src/dart/lib/semantic_search_request.dart @@ -0,0 +1,43 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/semantic-search-request.json + +/// Body of `POST /v1/search/semantic` (RAG). The caller computes the query embedding with +/// its model; the server ranks stored package embeddings from the SAME model by cosine +/// distance. Vectors up to 2050 dims are accepted and zero-padded server-side. +class SemanticSearchRequest { + const SemanticSearchRequest({ + required this.embedding, + this.limit = 20, + required this.model, + this.tags, + }); + + factory SemanticSearchRequest.fromJson(Map json) => SemanticSearchRequest( + embedding: (json['embedding'] as List).map((e) => (e as num).toDouble()).toList(), + limit: json['limit'] == null + ? 20 + : (json['limit'] as num).toInt(), + model: json['model'] as String, + tags: (json['tags'] as List?)?.map((e) => e as String).toList(), + ); + + /// The query embedding (native width; padded to 2050 server-side). + final List embedding; + + final int limit; + + /// Embedding model id, e.g. `openai/text-embedding-3-small`. Only stored embeddings from + /// this model are searched. + final String model; + + /// Optional tag filter: results must carry all of these tags. + final List? tags; + + Map toJson() => { + 'embedding': embedding, + 'limit': limit, + 'model': model, + 'tags': tags, + }; +} diff --git a/src/dart/lib/semantic_search_response.dart b/src/dart/lib/semantic_search_response.dart new file mode 100644 index 0000000..6cb8cff --- /dev/null +++ b/src/dart/lib/semantic_search_response.dart @@ -0,0 +1,56 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/semantic-search-response.json + +class SemanticHit { + const SemanticHit({ + this.description, + required this.distance, + required this.name, + required this.org, + this.tags, + }); + + factory SemanticHit.fromJson(Map json) => SemanticHit( + description: json['description'] as String?, + distance: (json['distance'] as num).toDouble(), + name: json['name'] as String, + org: json['org'] as String, + tags: (json['tags'] as List?)?.map((e) => e as String).toList(), + ); + + final String? description; + + /// Cosine distance (0 = identical direction, 2 = opposite). Lower is nearer. + final double distance; + + final String name; + + final String org; + + final List? tags; + + Map toJson() => { + 'description': description, + 'distance': distance, + 'name': name, + 'org': org, + 'tags': tags, + }; +} + +class SemanticSearchResponse { + const SemanticSearchResponse({ + required this.items, + }); + + factory SemanticSearchResponse.fromJson(Map json) => SemanticSearchResponse( + items: (json['items'] as List).map((e) => SemanticHit.fromJson(e as Map)).toList(), + ); + + final List items; + + Map toJson() => { + 'items': items.map((e) => e.toJson()).toList(), + }; +} diff --git a/src/dart/lib/sync_change_event.dart b/src/dart/lib/sync_change_event.dart new file mode 100644 index 0000000..7a3efaa --- /dev/null +++ b/src/dart/lib/sync_change_event.dart @@ -0,0 +1,107 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-change-event.json + +/// Hybrid Logical Clock stamp — the reconciliation key. Total order: `wall_ms`, then +/// `counter`, then `actor`. +class Hlc { + const Hlc({ + required this.actor, + required this.counter, + required this.wallMs, + }); + + factory Hlc.fromJson(Map json) => Hlc( + actor: json['actor'] as String, + counter: (json['counter'] as num).toInt(), + wallMs: (json['wall_ms'] as num).toInt(), + ); + + final String actor; + + final int counter; + + final int wallMs; + + Map toJson() => { + 'actor': actor, + 'counter': counter, + 'wall_ms': wallMs, + }; +} + +/// The change envelope both transports decode to (mirrors zed-sync `ChangeEvent`). +/// `version` is the ONLY reconciliation key; `sync_sequence` is the catch-up cursor and +/// never feeds reconciliation. +class SyncChangeEvent { + const SyncChangeEvent({ + required this.atMs, + required this.id, + required this.op, + this.row, + this.syncSequence, + required this.table, + required this.version, + this.writeKey, + }); + + factory SyncChangeEvent.fromJson(Map json) => SyncChangeEvent( + atMs: (json['at_ms'] as num).toInt(), + id: json['id'] as String, + op: SyncOp.fromJson(json['op'] as String), + row: json['row'], + syncSequence: (json['sync_sequence'] as num?)?.toInt(), + table: json['table'] as String, + version: Hlc.fromJson(json['version'] as Map), + writeKey: json['write_key'] as String?, + ); + + final int atMs; + + final String id; + + final SyncOp op; + + final Object? row; + + final int? syncSequence; + + final String table; + + final Hlc version; + + final String? writeKey; + + Map toJson() => { + 'at_ms': atMs, + 'id': id, + 'op': op.toJson(), + 'row': row, + 'sync_sequence': syncSequence, + 'table': table, + 'version': version.toJson(), + 'write_key': writeKey, + }; +} + +enum SyncOp { + upsert('upsert'), + delete('delete'); + + const SyncOp(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static SyncOp fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown SyncOp: $value'), + ); + + static SyncOp? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} diff --git a/src/dart/lib/sync_conflict_resolution.dart b/src/dart/lib/sync_conflict_resolution.dart new file mode 100644 index 0000000..59d095d --- /dev/null +++ b/src/dart/lib/sync_conflict_resolution.dart @@ -0,0 +1,26 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-conflict-resolution.json + +/// How a dirty-vs-newer-remote conflict resolves (mirrors zed-sync `ConflictResolution`). +enum SyncConflictResolution { + serverWins('server_wins'), + lastWriteWins('last_write_wins'); + + const SyncConflictResolution(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static SyncConflictResolution fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown SyncConflictResolution: $value'), + ); + + static SyncConflictResolution? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} diff --git a/src/dart/lib/sync_error_policy.dart b/src/dart/lib/sync_error_policy.dart new file mode 100644 index 0000000..3c9d410 --- /dev/null +++ b/src/dart/lib/sync_error_policy.dart @@ -0,0 +1,28 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-error-policy.json + +/// How write/flush errors are surfaced (mirrors zed-sync `ErrorPolicy`). +enum SyncErrorPolicy { + throwOnly('throw_only'), + emitOnly('emit_only'), + throwAndEmit('throw_and_emit'), + silent('silent'); + + const SyncErrorPolicy(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static SyncErrorPolicy fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown SyncErrorPolicy: $value'), + ); + + static SyncErrorPolicy? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} diff --git a/src/dart/lib/sync_write_mode.dart b/src/dart/lib/sync_write_mode.dart new file mode 100644 index 0000000..0c3f314 --- /dev/null +++ b/src/dart/lib/sync_write_mode.dart @@ -0,0 +1,30 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-write-mode.json + +/// Per-write optimism level (mirrors zed-sync `WriteMode`). Enum, not a boolean: a write +/// names its exact behavior. +enum SyncWriteMode { + localOnly('local_only'), + optimisticQueue('optimistic_queue'), + optimisticAwaitAck('optimistic_await_ack'), + serverFirst('server_first'), + serverOnly('server_only'); + + const SyncWriteMode(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static SyncWriteMode fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown SyncWriteMode: $value'), + ); + + static SyncWriteMode? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} diff --git a/src/dart/lib/version_metadata.dart b/src/dart/lib/version_metadata.dart new file mode 100644 index 0000000..8356b77 --- /dev/null +++ b/src/dart/lib/version_metadata.dart @@ -0,0 +1,98 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/version-metadata.json + +/// On-the-wire formats for published package artifacts. +enum ArtifactFormat { + tarGz('tar.gz'), + zip('zip'); + + const ArtifactFormat(this.wire); + + /// The value as it appears in JSON. + final String wire; + + /// Throws [FormatException] on a value this build does not know — an + /// unrecognized variant is a version skew, not something to decode past. + static ArtifactFormat fromJson(String value) => values.firstWhere( + (candidate) => candidate.wire == value, + orElse: () => throw FormatException('unknown ArtifactFormat: $value'), + ); + + static ArtifactFormat? maybeFromJson(String? value) => + value == null ? null : fromJson(value); + + String toJson() => wire; +} + +class VersionMetadata { + const VersionMetadata({ + required this.downloadUrl, + this.format = ArtifactFormat.tarGz, + required this.name, + required this.org, + required this.publishedAt, + required this.sha256, + required this.size, + this.vcsCommit, + required this.vcsTag, + required this.version, + this.yanked = false, + }); + + factory VersionMetadata.fromJson(Map json) => VersionMetadata( + downloadUrl: json['download_url'] as String, + format: json['format'] == null + ? ArtifactFormat.tarGz + : ArtifactFormat.fromJson(json['format'] as String), + name: json['name'] as String, + org: json['org'] as String, + publishedAt: json['published_at'] as String, + sha256: json['sha256'] as String, + size: (json['size'] as num).toInt(), + vcsCommit: json['vcs_commit'] as String?, + vcsTag: json['vcs_tag'] as String, + version: json['version'] as String, + yanked: json['yanked'] == null + ? false + : json['yanked'] as bool, + ); + + /// Absolute or registry-relative URL the artifact can be fetched from. + final String downloadUrl; + + final ArtifactFormat format; + + final String name; + + final String org; + + /// RFC 3339 timestamp. + final String publishedAt; + + final String sha256; + + final int size; + + final String? vcsCommit; + + final String vcsTag; + + final String version; + + final bool yanked; + + Map toJson() => { + 'download_url': downloadUrl, + 'format': format.toJson(), + 'name': name, + 'org': org, + 'published_at': publishedAt, + 'sha256': sha256, + 'size': size, + 'vcs_commit': vcsCommit, + 'vcs_tag': vcsTag, + 'version': version, + 'yanked': yanked, + }; +} diff --git a/src/dart/lib/yank_request.dart b/src/dart/lib/yank_request.dart new file mode 100644 index 0000000..f8becd2 --- /dev/null +++ b/src/dart/lib/yank_request.dart @@ -0,0 +1,20 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/yank-request.json + +/// Body for the yank route. `yanked: false` restores a yanked version. +class YankRequest { + const YankRequest({ + required this.yanked, + }); + + factory YankRequest.fromJson(Map json) => YankRequest( + yanked: json['yanked'] as bool, + ); + + final bool yanked; + + Map toJson() => { + 'yanked': yanked, + }; +} diff --git a/src/dart/lib/yank_response.dart b/src/dart/lib/yank_response.dart new file mode 100644 index 0000000..74f18a9 --- /dev/null +++ b/src/dart/lib/yank_response.dart @@ -0,0 +1,34 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/yank-response.json + +class YankResponse { + const YankResponse({ + required this.name, + required this.org, + required this.version, + required this.yanked, + }); + + factory YankResponse.fromJson(Map json) => YankResponse( + name: json['name'] as String, + org: json['org'] as String, + version: json['version'] as String, + yanked: json['yanked'] as bool, + ); + + final String name; + + final String org; + + final String version; + + final bool yanked; + + Map toJson() => { + 'name': name, + 'org': org, + 'version': version, + 'yanked': yanked, + }; +} diff --git a/src/dart/lib/zed_interfaces.dart b/src/dart/lib/zed_interfaces.dart new file mode 100644 index 0000000..244290f --- /dev/null +++ b/src/dart/lib/zed_interfaces.dart @@ -0,0 +1,26 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. + +/// Front-end contract types for the zed-pkg registry, generated from the +/// JSON Schemas that `zed-interfaces` (Rust) is the source of truth for. +library zed_interfaces; + +export 'api_error.dart'; +export 'audit_integrity_response.dart'; +export 'audit_log_response.dart'; +export 'claim_org_request.dart'; +export 'claim_org_response.dart'; +export 'common.dart'; +export 'package_list_response.dart'; +export 'package_metadata.dart'; +export 'publish_response.dart'; +export 'search_response.dart'; +export 'semantic_search_request.dart'; +export 'semantic_search_response.dart'; +export 'sync_change_event.dart'; +export 'sync_conflict_resolution.dart'; +export 'sync_error_policy.dart'; +export 'sync_write_mode.dart'; +export 'version_metadata.dart'; +export 'yank_request.dart'; +export 'yank_response.dart'; diff --git a/src/dart/pubspec.yaml b/src/dart/pubspec.yaml new file mode 100644 index 0000000..3cd6f31 --- /dev/null +++ b/src/dart/pubspec.yaml @@ -0,0 +1,16 @@ +name: zed_interfaces +description: >- + Front-end contract types for the zed-pkg registry — generated from the JSON + Schemas that the Rust crate in src/rust is the source of truth for. +version: 0.1.0 +# Published as a zed-package ([targets.dart] in ../../.zpkg.toml) and mirrored +# to pub.dev from there, never with a bare `dart pub publish` from this tree. +publish_to: none +repository: https://github.com/zed-pkg/zed-interfaces + +environment: + sdk: ^3.0.0 + +dev_dependencies: + lints: ^4.0.0 + test: ^1.25.0 diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml new file mode 100644 index 0000000..1d87d40 --- /dev/null +++ b/src/rust/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "zed-interfaces" +version = "0.1.0" +edition = "2024" +description = "Core interface definitions for the zed-pkg universal package manager" +license = "MIT" +repository = "https://github.com/zed-pkg/zed-interfaces" + +# This crate is the Rust slice of a polyglot zed-package: it sits in +# `src/rust/` beside `src/dart/` and `src/ts/`, which are generated from +# `schemas/`. `.zpkg.toml` routes this directory to crates.io as +# `[targets.rust]` — a target may not own `dir = "."`, which is why the +# manifest lives here rather than at the repository root. See +# `docs/multi-language-layout.md`. +[lib] +path = "lib.rs" + +[dependencies] +hex = "0.4" +schemars = "1.2.1" +semver = { version = "1.0.28", features = ["serde"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = "0.10" +thiserror = "2.0.19" +toml = "1.1.3" diff --git a/src/artifact.rs b/src/rust/artifact.rs similarity index 100% rename from src/artifact.rs rename to src/rust/artifact.rs diff --git a/src/environment.rs b/src/rust/environment.rs similarity index 100% rename from src/environment.rs rename to src/rust/environment.rs diff --git a/src/environment_lock.rs b/src/rust/environment_lock.rs similarity index 100% rename from src/environment_lock.rs rename to src/rust/environment_lock.rs diff --git a/src/environment_v2.rs b/src/rust/environment_v2.rs similarity index 100% rename from src/environment_v2.rs rename to src/rust/environment_v2.rs diff --git a/examples/generate_schemas.rs b/src/rust/examples/generate_schemas.rs similarity index 84% rename from examples/generate_schemas.rs rename to src/rust/examples/generate_schemas.rs index 87118b5..0eae946 100644 --- a/examples/generate_schemas.rs +++ b/src/rust/examples/generate_schemas.rs @@ -1,5 +1,8 @@ -//! Regenerates the JSON Schemas under `schemas/`, which the non-Rust -//! client libraries in `zed-clients` use for codegen and validation. +//! Regenerates the JSON Schemas under `schemas/`. They are the source of +//! truth for every non-Rust consumer: `codegen/generate.mjs` turns the +//! front-end-facing subset (see `schemas/index.json`) into `src/dart/` and +//! `src/ts/`, and the client libraries in `zed-clients` codegen/validate +//! against the same files. //! //! Run with: `cargo run --example generate_schemas` @@ -17,7 +20,10 @@ fn write(dir: &Path, name: &str) { } fn main() { - let dir = Path::new("schemas"); + // Manifest-relative (this crate is `src/rust/`, the schemas are at the + // repository root) so the output is the same from any working directory. + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas"); + let dir = dir.as_path(); fs::create_dir_all(dir).expect("schemas dir"); write::(dir, "manifest"); diff --git a/src/excludes.rs b/src/rust/excludes.rs similarity index 100% rename from src/excludes.rs rename to src/rust/excludes.rs diff --git a/src/language.rs b/src/rust/language.rs similarity index 100% rename from src/language.rs rename to src/rust/language.rs diff --git a/src/lib.rs b/src/rust/lib.rs similarity index 100% rename from src/lib.rs rename to src/rust/lib.rs diff --git a/src/lockfile.rs b/src/rust/lockfile.rs similarity index 100% rename from src/lockfile.rs rename to src/rust/lockfile.rs diff --git a/src/manifest.rs b/src/rust/manifest.rs similarity index 100% rename from src/manifest.rs rename to src/rust/manifest.rs diff --git a/src/native_dependency.rs b/src/rust/native_dependency.rs similarity index 100% rename from src/native_dependency.rs rename to src/rust/native_dependency.rs diff --git a/src/native_registry.rs b/src/rust/native_registry.rs similarity index 100% rename from src/native_registry.rs rename to src/rust/native_registry.rs diff --git a/src/nix.rs b/src/rust/nix.rs similarity index 100% rename from src/nix.rs rename to src/rust/nix.rs diff --git a/src/nix_plan.rs b/src/rust/nix_plan.rs similarity index 100% rename from src/nix_plan.rs rename to src/rust/nix_plan.rs diff --git a/src/oci.rs b/src/rust/oci.rs similarity index 100% rename from src/oci.rs rename to src/rust/oci.rs diff --git a/src/paths.rs b/src/rust/paths.rs similarity index 100% rename from src/paths.rs rename to src/rust/paths.rs diff --git a/src/registry.rs b/src/rust/registry.rs similarity index 100% rename from src/registry.rs rename to src/rust/registry.rs diff --git a/src/sync.rs b/src/rust/sync.rs similarity index 100% rename from src/sync.rs rename to src/rust/sync.rs diff --git a/tests/install_contract.rs b/src/rust/tests/install_contract.rs similarity index 100% rename from tests/install_contract.rs rename to src/rust/tests/install_contract.rs diff --git a/tests/install_contract_edges.rs b/src/rust/tests/install_contract_edges.rs similarity index 100% rename from tests/install_contract_edges.rs rename to src/rust/tests/install_contract_edges.rs diff --git a/tests/lockfile_content_addressed_provenance.rs b/src/rust/tests/lockfile_content_addressed_provenance.rs similarity index 100% rename from tests/lockfile_content_addressed_provenance.rs rename to src/rust/tests/lockfile_content_addressed_provenance.rs diff --git a/tests/native_dependency_lockfile_contract.rs b/src/rust/tests/native_dependency_lockfile_contract.rs similarity index 100% rename from tests/native_dependency_lockfile_contract.rs rename to src/rust/tests/native_dependency_lockfile_contract.rs diff --git a/tests/native_dependency_lockfile_schema_contract.rs b/src/rust/tests/native_dependency_lockfile_schema_contract.rs similarity index 90% rename from tests/native_dependency_lockfile_schema_contract.rs rename to src/rust/tests/native_dependency_lockfile_schema_contract.rs index 4fe0127..1263187 100644 --- a/tests/native_dependency_lockfile_schema_contract.rs +++ b/src/rust/tests/native_dependency_lockfile_schema_contract.rs @@ -2,7 +2,7 @@ use schemars::schema_for; use serde_json::Value; use zed_interfaces::Lockfile; -const LOCKFILE_SCHEMA: &str = include_str!("../schemas/lockfile.json"); +const LOCKFILE_SCHEMA: &str = include_str!("../../../schemas/lockfile.json"); #[test] fn checked_in_lockfile_schema_includes_native_dependency_provenance() { diff --git a/tests/native_dependency_schema_contract.rs b/src/rust/tests/native_dependency_schema_contract.rs similarity index 87% rename from tests/native_dependency_schema_contract.rs rename to src/rust/tests/native_dependency_schema_contract.rs index b3e0019..8919f92 100644 --- a/tests/native_dependency_schema_contract.rs +++ b/src/rust/tests/native_dependency_schema_contract.rs @@ -2,7 +2,8 @@ use schemars::schema_for; use serde_json::Value; use zed_interfaces::NativeDependencyLock; -const NATIVE_DEPENDENCY_LOCK_SCHEMA: &str = include_str!("../schemas/native-dependency-lock.json"); +const NATIVE_DEPENDENCY_LOCK_SCHEMA: &str = + include_str!("../../../schemas/native-dependency-lock.json"); #[test] fn checked_in_native_dependency_schema_matches_the_public_contract() { diff --git a/tests/native_registry_schema_contract.rs b/src/rust/tests/native_registry_schema_contract.rs similarity index 90% rename from tests/native_registry_schema_contract.rs rename to src/rust/tests/native_registry_schema_contract.rs index 4266928..76c9ee6 100644 --- a/tests/native_registry_schema_contract.rs +++ b/src/rust/tests/native_registry_schema_contract.rs @@ -3,7 +3,7 @@ use serde_json::Value; use zed_interfaces::NativeRegistryAdapterRecord; const NATIVE_REGISTRY_ADAPTER_SCHEMA: &str = - include_str!("../schemas/native-registry-adapter-record.json"); + include_str!("../../../schemas/native-registry-adapter-record.json"); #[test] fn checked_in_native_registry_schema_matches_the_public_contract() { diff --git a/tests/native_release.rs b/src/rust/tests/native_release.rs similarity index 100% rename from tests/native_release.rs rename to src/rust/tests/native_release.rs diff --git a/tests/nix_interop_contract.rs b/src/rust/tests/nix_interop_contract.rs similarity index 100% rename from tests/nix_interop_contract.rs rename to src/rust/tests/nix_interop_contract.rs diff --git a/tests/nix_manifest_lock.rs b/src/rust/tests/nix_manifest_lock.rs similarity index 100% rename from tests/nix_manifest_lock.rs rename to src/rust/tests/nix_manifest_lock.rs diff --git a/tests/nix_schema_contract.rs b/src/rust/tests/nix_schema_contract.rs similarity index 85% rename from tests/nix_schema_contract.rs rename to src/rust/tests/nix_schema_contract.rs index 18ac1ec..68038dc 100644 --- a/tests/nix_schema_contract.rs +++ b/src/rust/tests/nix_schema_contract.rs @@ -2,8 +2,8 @@ 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"); +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") diff --git a/src/rust/tests/own_manifest.rs b/src/rust/tests/own_manifest.rs new file mode 100644 index 0000000..14e50d6 --- /dev/null +++ b/src/rust/tests/own_manifest.rs @@ -0,0 +1,85 @@ +//! This repository's own `.zpkg.toml` must satisfy the rules this crate +//! defines. zed-interfaces is where the polyglot manifest model lives, so a +//! manifest here that its own parser rejects is the worst possible bug — and +//! the one nobody would notice, because nothing else in CI parses it. + +use std::path::Path; + +use zed_interfaces::manifest::Manifest; + +fn own_manifest() -> Manifest { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.zpkg.toml"); + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + Manifest::parse(&raw).expect("the repository manifest parses and validates") +} + +#[test] +fn the_repository_manifest_is_valid() { + let manifest = own_manifest(); + assert_eq!(manifest.package.name, "zed-interfaces"); + assert!( + manifest.is_polyglot(), + "the language slices are what make this package polyglot" + ); +} + +#[test] +fn every_language_slice_is_its_own_target_with_an_isolated_root() { + let manifest = own_manifest(); + for (target, dir, adapter) in [ + ("rust", "src/rust", "rust"), + ("dart", "src/dart", "dart"), + ("typescript", "src/ts", "node"), + ] { + let section = manifest + .targets + .get(target) + .unwrap_or_else(|| panic!("missing `[targets.{target}]`")); + assert_eq!(section.dir, dir, "target `{target}` moved"); + assert_eq!(section.adapter.as_deref(), Some(adapter)); + assert!( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(dir) + .is_dir(), + "target `{target}` points at `{dir}`, which does not exist" + ); + } +} + +#[test] +fn the_rust_slice_is_the_crate_that_publishes_to_crates_io() { + let manifest = own_manifest(); + let rust = manifest.targets.get("rust").expect("rust target"); + let native = rust + .native + .as_ref() + .expect("the Rust slice keeps the crates.io route"); + assert_eq!(native.package, "zed-interfaces"); + // A target that owned `dir = "."` could not carry this route at all, which + // is why the crate manifest lives in `src/rust/` rather than at the root. + assert_ne!(rust.dir, "."); + assert!( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("Cargo.toml") + .is_file(), + "the crate manifest must sit inside the Rust slice" + ); +} + +#[test] +fn selecting_a_target_yields_that_slice_alone() { + let manifest = own_manifest(); + assert_eq!( + manifest.target_subdir(Some("dart")).unwrap(), + Some("src/dart") + ); + assert_eq!( + manifest.target_subdir(Some("typescript")).unwrap(), + Some("src/ts") + ); + // An unpublished language must fail loudly rather than silently install the + // whole repository. + assert!(manifest.target_subdir(Some("swift")).is_err()); +} diff --git a/tests/roundtrip.rs b/src/rust/tests/roundtrip.rs similarity index 100% rename from tests/roundtrip.rs rename to src/rust/tests/roundtrip.rs diff --git a/src/vcs.rs b/src/rust/vcs.rs similarity index 100% rename from src/vcs.rs rename to src/rust/vcs.rs diff --git a/src/version.rs b/src/rust/version.rs similarity index 100% rename from src/version.rs rename to src/rust/version.rs diff --git a/src/ts/api-error.ts b/src/ts/api-error.ts new file mode 100644 index 0000000..9ae952d --- /dev/null +++ b/src/ts/api-error.ts @@ -0,0 +1,10 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/api-error.json + +/** Error body returned with any non-2xx status. */ +export interface ApiError { + /** Stable machine-readable code, e.g. `not_found`, `sha256_mismatch`, `tag_not_found`, `unauthorized`, `org_taken`. */ + readonly code: string; + readonly message: string; +} diff --git a/src/ts/audit-integrity-response.ts b/src/ts/audit-integrity-response.ts new file mode 100644 index 0000000..d4d687a --- /dev/null +++ b/src/ts/audit-integrity-response.ts @@ -0,0 +1,17 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/audit-integrity-response.json + +/** The result of walking an org's audit chain end to end. */ +export interface AuditIntegrityResponse { + readonly entries_checked: number; + /** The `seq` where verification first failed, if any. */ + readonly first_bad_seq?: number | null; + /** The newest entry's hash — an anchor an operator can record externally so that later truncation of the whole tail is also detectable. */ + readonly head_hash?: string | null; + /** True only when every entry's hash recomputes and every link matches. */ + readonly intact: boolean; + readonly org: string; + /** Machine-readable failure kind: `hash_mismatch` (an entry was edited), `broken_link` (an entry's `prev_hash` does not match its predecessor), or `sequence_gap` (an entry was deleted). */ + readonly problem?: string | null; +} diff --git a/src/ts/audit-log-response.ts b/src/ts/audit-log-response.ts new file mode 100644 index 0000000..94a4b86 --- /dev/null +++ b/src/ts/audit-log-response.ts @@ -0,0 +1,45 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/audit-log-response.json + +/** + * A state-changing action recorded in an org's audit log. Reads are never audited — only mutations of published state and of the namespace itself, so the log answers "who changed what" without drowning in traffic. + * - `publish`: A version was published. + * - `yank`: A version was yanked (hidden from fresh resolution). + * - `unyank`: A previously yanked version was restored. + * - `org_claim`: The org namespace was claimed. + */ +export type AuditAction = "publish" | "yank" | "unyank" | "org_claim"; + +/** Every `AuditAction` value, in schema order — for validation and pickers. */ +export const AUDIT_ACTION_VALUES = ["publish", "yank", "unyank", "org_claim"] as const; + +/** One audit-log record. The actor is identified by the *token* that acted — its name and role, never its secret — which is the identity a registry actually has (zed-docs issue #7 governance). */ +export interface AuditEntry { + /** Raw action string; `action_kind` is the parsed form when recognized. */ + readonly action: string; + /** Parsed action, absent when this server build doesn't recognize it. */ + readonly action_kind?: AuditAction | null; + /** The acting token's role (`owner`/`publisher`/`reader`, or `admin` for unscoped tokens). */ + readonly actor_role: string; + /** Human-readable name of the token that acted. */ + readonly actor_token_name: string; + /** RFC 3339 timestamp of the action. */ + readonly at: string; + /** Extra context, e.g. the artifact sha256 for a publish. */ + readonly detail?: string | null; + /** `sha256(audit_chain_preimage(..))` for this entry, lowercase hex. Empty from a pre-chain server. */ + readonly entry_hash?: string; + /** The previous entry's `entry_hash`; `None` for the first entry in an org's chain. Linking each entry to its predecessor is what makes a silent deletion or edit detectable. */ + readonly prev_hash?: string | null; + /** Position in the org's append-only chain, starting at 1. Gaps mean entries were deleted. Defaults to 0 when read from a server that predates the chain. */ + readonly seq?: number; + /** What was acted on, e.g. `acme/http-kit@1.2.0` or the org slug. */ + readonly subject: string; +} + +export interface AuditLogResponse { + /** Most recent first. */ + readonly entries: readonly AuditEntry[]; + readonly org: string; +} diff --git a/src/ts/claim-org-request.ts b/src/ts/claim-org-request.ts new file mode 100644 index 0000000..1f5fc7c --- /dev/null +++ b/src/ts/claim-org-request.ts @@ -0,0 +1,7 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/claim-org-request.json + +export interface ClaimOrgRequest { + readonly slug: string; +} diff --git a/src/ts/claim-org-response.ts b/src/ts/claim-org-response.ts new file mode 100644 index 0000000..a774aad --- /dev/null +++ b/src/ts/claim-org-response.ts @@ -0,0 +1,9 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/claim-org-response.json + +export interface ClaimOrgResponse { + /** False when the caller already owned the org. */ + readonly created: boolean; + readonly slug: string; +} diff --git a/src/ts/common.ts b/src/ts/common.ts new file mode 100644 index 0000000..993d2ca --- /dev/null +++ b/src/ts/common.ts @@ -0,0 +1,12 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: types shared by more than one schema + +export interface PackageSummary { + readonly description?: string | null; + readonly latest?: string | null; + readonly name: string; + readonly org: string; + /** Free-form tags for filtering/discovery. */ + readonly tags?: readonly string[]; +} diff --git a/src/ts/index.ts b/src/ts/index.ts new file mode 100644 index 0000000..b3cecd6 --- /dev/null +++ b/src/ts/index.ts @@ -0,0 +1,22 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. + +export * from "./api-error"; +export * from "./audit-integrity-response"; +export * from "./audit-log-response"; +export * from "./claim-org-request"; +export * from "./claim-org-response"; +export * from "./common"; +export * from "./package-list-response"; +export * from "./package-metadata"; +export * from "./publish-response"; +export * from "./search-response"; +export * from "./semantic-search-request"; +export * from "./semantic-search-response"; +export * from "./sync-change-event"; +export * from "./sync-conflict-resolution"; +export * from "./sync-error-policy"; +export * from "./sync-write-mode"; +export * from "./version-metadata"; +export * from "./yank-request"; +export * from "./yank-response"; diff --git a/src/ts/package-list-response.ts b/src/ts/package-list-response.ts new file mode 100644 index 0000000..a547f7e --- /dev/null +++ b/src/ts/package-list-response.ts @@ -0,0 +1,12 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/package-list-response.json + +import type { PackageSummary } from "./common"; + +/** Response for `GET /v1/packages` (list all). */ +export interface PackageListResponse { + readonly items: readonly PackageSummary[]; + /** Total packages matching the filter (before limit/offset). */ + readonly total: number; +} diff --git a/src/ts/package-metadata.ts b/src/ts/package-metadata.ts new file mode 100644 index 0000000..4bdd467 --- /dev/null +++ b/src/ts/package-metadata.ts @@ -0,0 +1,37 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/package-metadata.json + +export interface PackageMetadata { + readonly description?: string | null; + readonly latest?: string | null; + readonly name: string; + readonly org: string; + readonly repo_url: string; + /** Free-form tags for filtering/discovery (multi-tag lookup). */ + readonly tags?: readonly string[]; + readonly vcs: Vcs; + /** How this package's versions should be interpreted (semver by default). */ + readonly version_scheme?: VersionScheme; + /** All published, non-yanked versions, newest first. */ + readonly versions: readonly string[]; +} + +/** + * Version-control systems a package's source repository can live on. zed-pkg is VCS-agnostic by design: what gets installed is always a registry artifact, and the VCS is where provenance (tags) is anchored. Authors must create a matching tag on their declared backing repo (GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure DevOps, CodeCommit, Radicle, or self-hosted) before publishing. `jj` and Sapling are git-compatible and push to git remotes, so their provenance is verified through git tags. + */ +export type Vcs = "git" | "hg" | "jj" | "sapling" | "fossil" | "pijul"; + +/** Every `Vcs` value, in schema order — for validation and pickers. */ +export const VCS_VALUES = ["git", "hg", "jj", "sapling", "fossil", "pijul"] as const; + +/** + * How a package's `version` string (and its published tags) should be interpreted. Defaults to [`VersionScheme::Semver`], which covers the vast majority of modern ecosystems. + * - `semver`: Semantic Versioning. `package.version` must be valid semver; ranges (`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version. + * - `calver`: Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver total order (leading zeros dropped, padded to major.minor.patch) so the same range algebra applies. See [`normalize_calver`]. + * - `opaque`: Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra: a requirement must match a published version **exactly**. + */ +export type VersionScheme = "semver" | "calver" | "opaque"; + +/** Every `VersionScheme` value, in schema order — for validation and pickers. */ +export const VERSION_SCHEME_VALUES = ["semver", "calver", "opaque"] as const; diff --git a/src/ts/package.json b/src/ts/package.json new file mode 100644 index 0000000..937a9e6 --- /dev/null +++ b/src/ts/package.json @@ -0,0 +1,28 @@ +{ + "name": "@zed-pkg/zed-interfaces", + "version": "0.1.0", + "description": "Front-end contract types for the zed-pkg registry, generated from the JSON Schemas that the Rust crate in src/rust is the source of truth for.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/zed-pkg/zed-interfaces.git", + "directory": "src/ts" + }, + "type": "module", + "types": "./index.ts", + "exports": { + ".": "./index.ts", + "./*": "./*.ts" + }, + "files": [ + "*.ts", + "README.md" + ], + "sideEffects": false, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/src/ts/publish-response.ts b/src/ts/publish-response.ts new file mode 100644 index 0000000..f717dc6 --- /dev/null +++ b/src/ts/publish-response.ts @@ -0,0 +1,10 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/publish-response.json + +export interface PublishResponse { + readonly name: string; + readonly org: string; + readonly sha256: string; + readonly version: string; +} diff --git a/src/ts/search-response.ts b/src/ts/search-response.ts new file mode 100644 index 0000000..5888006 --- /dev/null +++ b/src/ts/search-response.ts @@ -0,0 +1,10 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/search-response.json + +import type { PackageSummary } from "./common"; + +export interface SearchResponse { + readonly items: readonly PackageSummary[]; + readonly query: string; +} diff --git a/src/ts/semantic-search-request.ts b/src/ts/semantic-search-request.ts new file mode 100644 index 0000000..4fef875 --- /dev/null +++ b/src/ts/semantic-search-request.ts @@ -0,0 +1,14 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/semantic-search-request.json + +/** Body of `POST /v1/search/semantic` (RAG). The caller computes the query embedding with its model; the server ranks stored package embeddings from the SAME model by cosine distance. Vectors up to 2050 dims are accepted and zero-padded server-side. */ +export interface SemanticSearchRequest { + /** The query embedding (native width; padded to 2050 server-side). */ + readonly embedding: readonly number[]; + readonly limit?: number; + /** Embedding model id, e.g. `openai/text-embedding-3-small`. Only stored embeddings from this model are searched. */ + readonly model: string; + /** Optional tag filter: results must carry all of these tags. */ + readonly tags?: readonly string[]; +} diff --git a/src/ts/semantic-search-response.ts b/src/ts/semantic-search-response.ts new file mode 100644 index 0000000..727686f --- /dev/null +++ b/src/ts/semantic-search-response.ts @@ -0,0 +1,16 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/semantic-search-response.json + +export interface SemanticHit { + readonly description?: string | null; + /** Cosine distance (0 = identical direction, 2 = opposite). Lower is nearer. */ + readonly distance: number; + readonly name: string; + readonly org: string; + readonly tags?: readonly string[]; +} + +export interface SemanticSearchResponse { + readonly items: readonly SemanticHit[]; +} diff --git a/src/ts/sync-change-event.ts b/src/ts/sync-change-event.ts new file mode 100644 index 0000000..df3fa89 --- /dev/null +++ b/src/ts/sync-change-event.ts @@ -0,0 +1,27 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-change-event.json + +/** Hybrid Logical Clock stamp — the reconciliation key. Total order: `wall_ms`, then `counter`, then `actor`. */ +export interface Hlc { + readonly actor: string; + readonly counter: number; + readonly wall_ms: number; +} + +/** The change envelope both transports decode to (mirrors zed-sync `ChangeEvent`). `version` is the ONLY reconciliation key; `sync_sequence` is the catch-up cursor and never feeds reconciliation. */ +export interface SyncChangeEvent { + readonly at_ms: number; + readonly id: string; + readonly op: SyncOp; + readonly row?: unknown; + readonly sync_sequence?: number | null; + readonly table: string; + readonly version: Hlc; + readonly write_key?: string | null; +} + +export type SyncOp = "upsert" | "delete"; + +/** Every `SyncOp` value, in schema order — for validation and pickers. */ +export const SYNC_OP_VALUES = ["upsert", "delete"] as const; diff --git a/src/ts/sync-conflict-resolution.ts b/src/ts/sync-conflict-resolution.ts new file mode 100644 index 0000000..e35280a --- /dev/null +++ b/src/ts/sync-conflict-resolution.ts @@ -0,0 +1,11 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-conflict-resolution.json + +/** + * How a dirty-vs-newer-remote conflict resolves (mirrors zed-sync `ConflictResolution`). + */ +export type SyncConflictResolution = "server_wins" | "last_write_wins"; + +/** Every `SyncConflictResolution` value, in schema order — for validation and pickers. */ +export const SYNC_CONFLICT_RESOLUTION_VALUES = ["server_wins", "last_write_wins"] as const; diff --git a/src/ts/sync-error-policy.ts b/src/ts/sync-error-policy.ts new file mode 100644 index 0000000..7be1b34 --- /dev/null +++ b/src/ts/sync-error-policy.ts @@ -0,0 +1,11 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-error-policy.json + +/** + * How write/flush errors are surfaced (mirrors zed-sync `ErrorPolicy`). + */ +export type SyncErrorPolicy = "throw_only" | "emit_only" | "throw_and_emit" | "silent"; + +/** Every `SyncErrorPolicy` value, in schema order — for validation and pickers. */ +export const SYNC_ERROR_POLICY_VALUES = ["throw_only", "emit_only", "throw_and_emit", "silent"] as const; diff --git a/src/ts/sync-write-mode.ts b/src/ts/sync-write-mode.ts new file mode 100644 index 0000000..f5e37c6 --- /dev/null +++ b/src/ts/sync-write-mode.ts @@ -0,0 +1,11 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/sync-write-mode.json + +/** + * Per-write optimism level (mirrors zed-sync `WriteMode`). Enum, not a boolean: a write names its exact behavior. + */ +export type SyncWriteMode = "local_only" | "optimistic_queue" | "optimistic_await_ack" | "server_first" | "server_only"; + +/** Every `SyncWriteMode` value, in schema order — for validation and pickers. */ +export const SYNC_WRITE_MODE_VALUES = ["local_only", "optimistic_queue", "optimistic_await_ack", "server_first", "server_only"] as const; diff --git a/src/ts/tsconfig.json b/src/ts/tsconfig.json new file mode 100644 index 0000000..a14bd4a --- /dev/null +++ b/src/ts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/src/ts/version-metadata.ts b/src/ts/version-metadata.ts new file mode 100644 index 0000000..449e56f --- /dev/null +++ b/src/ts/version-metadata.ts @@ -0,0 +1,27 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/version-metadata.json + +/** + * On-the-wire formats for published package artifacts. + */ +export type ArtifactFormat = "tar.gz" | "zip"; + +/** Every `ArtifactFormat` value, in schema order — for validation and pickers. */ +export const ARTIFACT_FORMAT_VALUES = ["tar.gz", "zip"] as const; + +export interface VersionMetadata { + /** Absolute or registry-relative URL the artifact can be fetched from. */ + readonly download_url: string; + readonly format?: ArtifactFormat; + readonly name: string; + readonly org: string; + /** RFC 3339 timestamp. */ + readonly published_at: string; + readonly sha256: string; + readonly size: number; + readonly vcs_commit?: string | null; + readonly vcs_tag: string; + readonly version: string; + readonly yanked?: boolean; +} diff --git a/src/ts/yank-request.ts b/src/ts/yank-request.ts new file mode 100644 index 0000000..8965ebe --- /dev/null +++ b/src/ts/yank-request.ts @@ -0,0 +1,8 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/yank-request.json + +/** Body for the yank route. `yanked: false` restores a yanked version. */ +export interface YankRequest { + readonly yanked: boolean; +} diff --git a/src/ts/yank-response.ts b/src/ts/yank-response.ts new file mode 100644 index 0000000..80aba79 --- /dev/null +++ b/src/ts/yank-response.ts @@ -0,0 +1,10 @@ +// GENERATED by codegen/generate.mjs from schemas/ — do not edit by hand. +// Regenerate with `npm run codegen` after changing the Rust types. +// Source: schemas/yank-response.json + +export interface YankResponse { + readonly name: string; + readonly org: string; + readonly version: string; + readonly yanked: boolean; +}