From eeaf9114fa0cb779ecd5452362bcefb8d3c0ac1d Mon Sep 17 00:00:00 2001 From: AlexReal Date: Sun, 23 Aug 2026 19:27:14 +0000 Subject: [PATCH 1/5] feat(install): resolve dependencies from a machine-wide local project registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `zed install` could only ever ask the configured registry where a package lives. That is the wrong answer while the registry itself is being built, on an offline or air-gapped machine, and whenever the dependency is a checkout sitting next to the project being edited. Workspace members solve this inside one tree; nothing solved it across trees. Add `zed local`, a shared index of on-disk projects under `ZED_PKG_HOME` that installs consult before the network. Entries are keyed by canonical filesystem path, not by package name. One package legitimately exists in several checkouts at once — a release clone, a feature worktree, a bisect tree — and a name-keyed registry would have to silently pick one. Several registrations of one package are therefore normal; `--priority` chooses between them, and a tie on both priority and version is a hard error naming both paths rather than a coin flip. Resolution is live: the recorded version is a registration-time snapshot for reporting and drift detection, while the manifest on disk is authoritative on every install. Selected projects are materialized through the same source-link path as workspace members, so symlink mode writes an absolute canonical directory symlink into the checkout (edits are visible to consumers with no reinstall) and copy mode produces a standalone tree via the existing guarded copier. Transitive dependencies of a linked project are read from that checkout's manifest, so a whole local graph links at once. `--local-registry` sets how much authority registrations have: `off`, `auto` (default; ordinary installs only), `prefer` (also `--frozen`), or `only` (fully offline — an unsatisfiable dependency errors instead of reaching the network). `--frozen` opts out by default because the index is machine-global ambient state and letting it override a pin would make "frozen" mean something different on every laptop. Requirements satisfied from live source are exempt from the frozen lockfile-pin check, exactly as workspace members already are. Hardening. Registration refuses a missing path, a non-directory, a missing or symlinked `.zpkg.toml`, a manifest that fails to validate, a non-UTF-8 path, and anything inside (or containing) the Zed home directory — the store holds extracted artifacts, and registering one would feed materialized output back in as source. The index must be a regular non-symlink file under 8 MiB with a known schema, at most 4096 entries, each slug-valid, versioned, absolute, and unique by path; a duplicate is reported rather than silently collapsed. Every read-modify-write runs under an exclusive zed-lock guard and is staged-then-renamed at 0600 under a 0700 directory, so concurrent `zed local register` runs cannot lose one another's entry. `zed local scan` is depth- and visit-bounded, follows no symlinks, and never descends into dependency, build, or VCS trees. At install time an entry whose path, manifest, or identity broke is reported by path and skipped; one that overlaps the consuming project in either direction is refused before it can put `zed_modules//` inside its own link target; and a package already pinned to a registry artifact is never switched to a source link behind the version-conflict check. Tests cover the index unit-level, the CLI end to end against an empty `file://` registry (so any install that succeeds did so locally), and the adversarial surface: 16 concurrent registering processes, hand-corrupted and symlinked index files, oversized indexes, symlinked manifests, symlinked project spellings, ambiguous ties, and overlapping registrations. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fvi78rjurJXbT9XYJv72by --- .cli-flags.toml | 119 +++ README.md | 26 + docs/local-registry.md | 184 ++++ src/cli.rs | 105 +++ src/dev.rs | 1 + src/lib.rs | 2 + src/local_cli.rs | 249 +++++ src/local_registry.rs | 1364 +++++++++++++++++++++++++++ src/main.rs | 4 + src/manifestless.rs | 1 + src/ops.rs | 230 ++++- tests/local_registry_adversarial.rs | 370 ++++++++ tests/local_registry_cli.rs | 505 ++++++++++ 13 files changed, 3127 insertions(+), 33 deletions(-) create mode 100644 docs/local-registry.md create mode 100644 src/local_cli.rs create mode 100644 src/local_registry.rs create mode 100644 tests/local_registry_adversarial.rs create mode 100644 tests/local_registry_cli.rs diff --git a/.cli-flags.toml b/.cli-flags.toml index 25596e78f..487de705c 100644 --- a/.cli-flags.toml +++ b/.cli-flags.toml @@ -362,6 +362,13 @@ help = "Remove a dependency." aliases = ["i"] help = "Install dependencies." +[commands.install.flags.local_registry] +env = "ZED_PKG_LOCAL_REGISTRY" +aliases = ["local-registry"] +type = "string" +default = "auto" +help = "Authority of `zed local` registrations over resolution: off, auto, prefer, or only." + [commands.overtake] help = "Adopt Git submodule packages into .zpkg.toml and .zpkg.lock." @@ -369,6 +376,118 @@ help = "Adopt Git submodule packages into .zpkg.toml and .zpkg.lock." aliases = ["un"] help = "Remove materialized dependencies while retaining the lockfile." +[commands.local] +help = "Register local project directories so installs resolve them from this filesystem." + +[commands.local.commands.register] +help = "Register or refresh a project directory containing .zpkg.toml." + +[commands.local.commands.register.flags.local_register_priority] +env = "ZED_PKG_LOCAL_REGISTER_PRIORITY" +aliases = ["priority"] +type = "int" +help = "Tie-breaking priority between checkouts of one package; higher wins." + +[commands.local.commands.register.flags.local_register_disabled] +env = "ZED_PKG_LOCAL_REGISTER_DISABLED" +aliases = ["disabled"] +type = "bool" +default = "false" +help = "Record the registration without making it selectable." + +[commands.local.commands.unregister] +help = "Forget a registration by path, org/name, or entry id." + +[commands.local.commands.unregister.flags.local_unregister_all] +env = "ZED_PKG_LOCAL_UNREGISTER_ALL" +aliases = ["all"] +type = "bool" +default = "false" +help = "Allow a selector that matches several registrations." + +[commands.local.commands.list] +help = "Show every registration and whether it still resolves." + +[commands.local.commands.list.flags.local_list_json] +env = "ZED_PKG_LOCAL_LIST_JSON" +aliases = ["json"] +type = "bool" +default = "false" +help = "Emit deterministic machine-readable local registry output." + +[commands.local.commands.enable] +help = "Make a registration selectable again." + +[commands.local.commands.enable.flags.local_enable_all] +env = "ZED_PKG_LOCAL_ENABLE_ALL" +aliases = ["all"] +type = "bool" +default = "false" +help = "Allow a selector that matches several registrations." + +[commands.local.commands.disable] +help = "Keep a registration but stop selecting it." + +[commands.local.commands.disable.flags.local_disable_all] +env = "ZED_PKG_LOCAL_DISABLE_ALL" +aliases = ["all"] +type = "bool" +default = "false" +help = "Allow a selector that matches several registrations." + +[commands.local.commands.prune] +help = "Drop registrations whose directory, manifest, or identity broke." + +[commands.local.commands.prune.flags.local_prune_dry_run] +env = "ZED_PKG_LOCAL_PRUNE_DRY_RUN" +aliases = ["dry-run"] +type = "bool" +default = "false" +help = "Report what would be dropped without writing the index." + +[commands.local.commands.scan] +help = "Discover and register every project beneath a directory." + +[commands.local.commands.scan.flags.local_scan_max_depth] +env = "ZED_PKG_LOCAL_SCAN_MAX_DEPTH" +aliases = ["max-depth"] +type = "int" +default = "6" +help = "How deep to descend below the scanned root." + +[commands.local.commands.scan.flags.local_scan_priority] +env = "ZED_PKG_LOCAL_SCAN_PRIORITY" +aliases = ["priority"] +type = "int" +help = "Priority applied to every registration this scan creates." + +[commands.local.commands.scan.flags.local_scan_dry_run] +env = "ZED_PKG_LOCAL_SCAN_DRY_RUN" +aliases = ["dry-run"] +type = "bool" +default = "false" +help = "List discovered projects without writing the index." + +[commands.local.commands.resolve] +help = "Show which registered local project would satisfy a dependency." + +[commands.local.commands.resolve.flags.local_resolve_require] +env = "ZED_PKG_LOCAL_RESOLVE_REQUIRE" +aliases = ["require"] +type = "string" +default = "*" +help = "Version requirement the local project must satisfy." + +[commands.local.commands.resolve.flags.local_resolve_json] +env = "ZED_PKG_LOCAL_RESOLVE_JSON" +aliases = ["json"] +type = "bool" +default = "false" +help = "Emit deterministic machine-readable local registry output." + +[commands.local.commands.path] +help = "Print the path of the shared local registry index." + [commands.env] help = "Import, verify, and export project-local developer environments." diff --git a/README.md b/README.md index 95942d327..2c977728f 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ the legacy version route by default or the additive target-qualified route with | `zed install --cli [--cli ...]` | Resolve exact project-owned CLI runtimes into `.zed/environment.lock.toml` and copy their complete runtime roots below `.zed/tools`; built-ins are `nodejs` and `python3` | | `zed install --frozen` | Install exactly what the manifest/lock pair pins; a manifestless lock-only restore additionally requires `--do-not-write-new-manifest` | | `zed uninstall [/ ...]` (`zed un`) | Transactionally remove all or selected materialized packages while retaining the manifest and lockfile for a frozen reinstall | +| `zed local register\|unregister\|list\|enable\|disable\|prune\|scan\|resolve\|path` | Manage the machine-wide registry of local project checkouts that `zed install` may resolve from this filesystem instead of the network; entries are keyed by canonical path, so several checkouts of one package can coexist and are chosen by explicit priority | +| `zed install --local-registry off\|auto\|prefer\|only` | Choose how much authority those registrations have: remote only, local-first for ordinary installs (default), local-first including `--frozen`, or fully offline | | `zed inspect --root ABSOLUTE_PATH [--format json]` | Fully offline, read-only manifest, lock, store, Git-submodule, mise, and Nix analysis for IDEs and automation | | `zed env import mise [--config PATH] [--lock PATH] [--frozen] [--json]` | Import the supported project-local mise tool/lock subset as the shared normalized `EnvironmentPlan`; never loads parent/global config or executes hooks | | `zed env verify mise [--config PATH] [--lock PATH] --frozen [--json]` | Fail closed on missing lock coverage, drift, malformed checksums, unsupported semantics, or non-portable frozen state and report the stable plan digest | @@ -400,6 +402,30 @@ prepared in a writable staging copy and copied into the consumer instead, so its finalized files never point at an ephemeral staging directory. Members are not pinned in `.zpkg.lock` (there is no published artifact). +### Local project registry (offline installs) + +Workspace members only help inside one tree. A checkout somewhere else on the +same machine — or a dependency you need while the registry is down or the +laptop is offline — is registered instead: + +```sh +cd ~/src/widget && zed local register # this checkout provides acme/widget +cd ~/src/app && zed install # acme/widget resolves from the filesystem +``` + +Registrations live in a shared index under `ZED_PKG_HOME` and are keyed by +**canonical filesystem path**, not by package name, so several checkouts of one +package can be registered at once; `--priority` chooses between them and an +unbreakable tie is an error rather than a guess. Selected projects are linked +from source through exactly the same path as workspace members, so symlink mode +gives a live link into the checkout and copy mode gives a standalone tree. + +`--local-registry` decides how much authority those registrations have: `off`, +`auto` (default; ordinary installs only), `prefer` (also `--frozen`), or `only` +(fully offline — a dependency with no healthy local entry is an error instead of +a network lookup). Full behavior, precedence against workspace members, and the +index hardening rules are in [local-registry.md](docs/local-registry.md). + ### Native dependencies, install hooks, and builds A package declares host prerequisites by package-manager name. Package specs diff --git a/docs/local-registry.md b/docs/local-registry.md new file mode 100644 index 000000000..0c17000fb --- /dev/null +++ b/docs/local-registry.md @@ -0,0 +1,184 @@ +# The local project registry + +`zed install` normally asks the configured registry where a package lives. +That is the wrong answer in three ordinary situations: the registry is down +(including while it is being built), the machine is offline or air-gapped, and +the dependency you need is a checkout sitting next to the project you are +editing. + +The local project registry is the answer for all three. A directory containing +a `.zpkg.toml` can be *registered* into a shared index under the Zed home +directory, and installs then resolve that package from the filesystem — no +HTTP, no store round trip, and with symlink install mode, no copy either. + +```sh +cd ~/src/widget && zed local register # this checkout provides acme/widget +cd ~/src/app && zed install # acme/widget resolves to ~/src/widget +``` + +## Identity is the path, not the package name + +Registrations are keyed by **canonical filesystem path**. One package may +legitimately exist in several checkouts at once — a release clone, a feature +worktree, a bisect tree — and a registry keyed on `org/name` alone would have +to silently pick one of them. So: + +- registering the same path twice refreshes that entry instead of adding one; +- registering a second path for the same package adds a second entry; +- the path is canonicalized first, so a symlinked spelling of a directory and + the directory itself can never both be registered, and an entry keeps + meaning the same tree after a convenience symlink is repointed. + +When several registrations provide the same package, selection is: highest +`--priority`, then highest version, then path order. A tie on **both** priority +and version between two different paths is a hard error, not a coin flip: + +``` +error: local registry cannot choose between 2 registrations of acme/widget@1.2.0 + at equal priority: + /Users/dev/src/widget + /Users/dev/src/widget-experiment +Break the tie with `zed local register --priority N` or +`zed local unregister `. +``` + +## Resolution is live; the snapshot is only for reporting + +An entry records the package identity and version observed at registration +time. That snapshot is used by `zed local list` and to report drift. It is +**not** used to resolve: every install re-reads the manifest on disk, because +the point of a local registration is that the source is alive. + +An entry is skipped, with a warning naming it, when the directory is gone, the +manifest stopped parsing, or the manifest now declares a different package. +`zed local prune` drops those. A registration you disabled on purpose is never +pruned — being shelved is not being broken. + +## How much authority registrations have + +`--local-registry` (or `ZED_PKG_LOCAL_REGISTRY`) picks the mode: + +| Mode | Behavior | +| --- | --- | +| `off` | Registrations are ignored; every dependency comes from the remote registry. | +| `auto` (default) | Registrations satisfy ordinary installs before the network is consulted. `--frozen` installs ignore them. | +| `prefer` | As `auto`, and registrations may also satisfy `--frozen` installs. | +| `only` | Registrations are the only source. A dependency with no healthy local entry is an error, so the install cannot reach the network at all. | + +`auto` is the default because a registration is never accidental: it exists +only because someone ran `zed local register` on that exact directory. Every +locally resolved dependency is still announced on stdout: + +``` +local acme/widget@1.2.0 -> /Users/dev/src/widget +``` + +### Why `--frozen` opts out by default + +A frozen install replays an exact lockfile so two machines produce the same +tree. The local registry is machine-global ambient state; letting it silently +override a pin would make "frozen" mean something different on every laptop. +`prefer` and `only` say *use my machine's registrations anyway*, and the +resulting install is reproducible only with respect to this machine. + +Requirements satisfied from live source have no immutable pin, so they are +exempt from the frozen "must be in `.zpkg.lock`" check — exactly as workspace +members already are. + +## Precedence against workspace members + +Workspace members win. They are declared by the root manifest of the tree being +installed, so they are part of the project; a registration is external state +someone configured on this machine. The order is: + +1. workspace member (`[workspace] members`), +2. local registration (subject to the mode above), +3. the configured registry. + +A dependency on the project's own package identity is never source-linked, in +either mechanism: it is a deliberate request for the published artifact. See +[install-resolution-hardening.md](install-resolution-hardening.md). + +## Materialization and symlinks + +Local registrations are materialized through the same source-link path as +workspace members, so install modes behave identically: + +- **symlink mode** writes an absolute canonical directory symlink from + `zed_modules//` into the checkout. Edits in the checkout are + visible to consumers immediately, with no reinstall. +- **copy mode** produces a standalone, symlink-free tree, using the guarded + copier that rejects escaping symlinks, cycles, and special files. + +A registration is refused at install time if it overlaps the project being +installed into, in either direction — linking a parent or a child of the +consumer would put `zed_modules//` inside its own link target. +Transitive dependencies of a linked project are read from that checkout's +manifest and resolved the same way, so a whole local graph links at once. + +## Hardening + +Everything the index can influence reaches a filesystem path, so all of it is +validated on the way in and again on the way out. + +Registration refuses: + +- a path that does not exist, is not a directory, or has no `.zpkg.toml`; +- a `.zpkg.toml` that is a symlink — the identity of a tree must be decided + inside that tree; +- a manifest that does not parse or does not validate; +- a path that is not valid UTF-8 (the index is JSON); +- any path inside the Zed home directory, or containing it. The store holds + extracted *artifacts*; registering one would feed materialized output back in + as source. The comparison uses the resolved home, so a symlinked + `ZED_PKG_HOME` cannot slip past it. + +The index itself is bounded and fail-closed: + +- an unknown `schema` value is rejected rather than best-effort parsed; +- the file must be a regular, non-symlink file of at most **8 MiB**; +- at most **4096** entries, each with a slug-valid identity, a non-empty + version, an absolute path, and no duplicate paths; +- every read-modify-write happens under an exclusive `zed-lock` guard beside + the index, so two concurrent `zed local register` runs cannot lose one + another's entry; +- writes are staged in the same directory and renamed into place, at mode + `0600` under a `0700` directory. + +`zed local scan` is bounded too: at most 32 levels and 200,000 directories, it +follows no symlinks, and it never descends into dependency, build, or VCS trees +(`node_modules`, `zed_modules`, `target`, `vendor`, `.git`, `.zed`, …) where a +manifest is materialized output rather than a checkout. Nested projects *are* +registered, so a workspace root and its members all land in the index. + +## Commands + +| Command | Purpose | +| --- | --- | +| `zed local register [PATH] [--priority N] [--disabled]` | Register or refresh a project directory. | +| `zed local unregister [--all]` | Forget a registration. | +| `zed local list [--json]` | Every registration with its current health. | +| `zed local enable\|disable [--all]` | Shelve a registration without losing its priority. | +| `zed local prune [--dry-run]` | Drop registrations that no longer resolve. | +| `zed local scan [PATH] [--max-depth N] [--priority N] [--dry-run]` | Discover and register a whole tree. | +| `zed local resolve [--require REQ] [--json]` | Show what would be selected, and what was skipped. | +| `zed local path` | Print the index file location. | + +A `SELECTOR` is an existing directory, an `org/name` key, or the 16-character +entry id from `zed local list --json`. A key selector that matches several +registrations requires `--all`, so `zed local unregister acme/widget` can never +quietly drop one you did not have in mind. + +`ZED_PKG_LOCAL_REGISTRY_FILE` relocates the index wholesale, which is how +hermetic tests and throwaway sandboxes avoid touching real registrations. + +## Verification + +```sh +cargo test --locked --lib local_registry::tests -- --nocapture +cargo test --locked --test local_registry_cli -- --nocapture +cargo clippy --locked --all-targets -- -D warnings +``` + +The CLI test points the registry at an empty `file://` directory, so any +install that succeeds there succeeded because the local registry satisfied it. diff --git a/src/cli.rs b/src/cli.rs index ea5134b93..d7f3b757d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand, ValueEnum}; +use crate::local_registry::LocalRegistryMode; + /// Every flag can also be set through a `ZED_PKG_*` environment variable, /// following the flags-2-env convention (github.com/flags-2-env/flags-2-env). #[derive(Debug, Parser)] @@ -301,6 +303,17 @@ pub enum Cmd { /// mismatch is almost always a mistake worth failing on #[arg(long, env = "ZED_PKG_ALLOW_ECOSYSTEM_MISMATCH")] allow_ecosystem_mismatch: bool, + /// How much authority `zed local` registrations have over resolution: + /// `off` uses the remote registry only, `auto` lets registered local + /// checkouts satisfy ordinary installs, `prefer` extends that to + /// `--frozen`, and `only` refuses to reach the network at all + #[arg( + long = "local-registry", + value_enum, + env = "ZED_PKG_LOCAL_REGISTRY", + default_value = "auto" + )] + local_registry: LocalRegistryMode, }, /// Remove installed dependency trees while retaining .zpkg.toml and /// .zpkg.lock so `zed install --frozen` can restore them exactly. @@ -311,6 +324,12 @@ pub enum Cmd { #[arg(value_name = "PACKAGE")] specs: Vec, }, + /// Register local project directories so installs can resolve them from + /// this filesystem instead of the remote registry. + Local { + #[command(subcommand)] + cmd: LocalCmd, + }, /// Import or verify project-local developer-environment configuration. Env { #[command(subcommand)] @@ -758,6 +777,92 @@ pub enum CacheCmd { Clean, } +/// The shared, machine-wide registry of local project checkouts. +/// +/// Entries are identified by canonical filesystem path, not by package name +/// alone, so several checkouts of one package can be registered at once and +/// selected explicitly. +#[derive(Debug, Subcommand)] +pub enum LocalCmd { + /// Register (or refresh) a project directory containing .zpkg.toml + Register { + /// Project directory. Defaults to the current directory. + #[arg(value_name = "PATH", env = "ZED_PKG_LOCAL_REGISTER_PATH")] + path: Option, + /// Break ties between checkouts of the same package; higher wins + #[arg(long, env = "ZED_PKG_LOCAL_REGISTER_PRIORITY")] + priority: Option, + /// Register without making the entry selectable yet + #[arg(long, env = "ZED_PKG_LOCAL_REGISTER_DISABLED")] + disabled: bool, + }, + /// Forget a registration, by path, `org/name`, or entry id + Unregister { + #[arg(value_name = "SELECTOR", env = "ZED_PKG_LOCAL_UNREGISTER_SELECTOR")] + selector: String, + /// Allow a selector that matches several registrations + #[arg(long, env = "ZED_PKG_LOCAL_UNREGISTER_ALL")] + all: bool, + }, + /// Show every registration and whether it still resolves + List { + /// Emit deterministic machine-readable JSON + #[arg(long, env = "ZED_PKG_LOCAL_LIST_JSON")] + json: bool, + }, + /// Make a registration selectable again + Enable { + #[arg(value_name = "SELECTOR", env = "ZED_PKG_LOCAL_ENABLE_SELECTOR")] + selector: String, + /// Allow a selector that matches several registrations + #[arg(long, env = "ZED_PKG_LOCAL_ENABLE_ALL")] + all: bool, + }, + /// Keep a registration but stop selecting it + Disable { + #[arg(value_name = "SELECTOR", env = "ZED_PKG_LOCAL_DISABLE_SELECTOR")] + selector: String, + /// Allow a selector that matches several registrations + #[arg(long, env = "ZED_PKG_LOCAL_DISABLE_ALL")] + all: bool, + }, + /// Drop registrations whose directory, manifest, or identity broke + Prune { + /// Report what would be dropped without writing the index + #[arg(long, env = "ZED_PKG_LOCAL_PRUNE_DRY_RUN")] + dry_run: bool, + }, + /// Discover and register every project beneath a directory + Scan { + /// Directory to walk. Defaults to the current directory. + #[arg(value_name = "PATH", env = "ZED_PKG_LOCAL_SCAN_PATH")] + path: Option, + /// How deep to descend below the root + #[arg(long, env = "ZED_PKG_LOCAL_SCAN_MAX_DEPTH", default_value_t = 6)] + max_depth: usize, + /// Priority applied to every registration this scan creates + #[arg(long, env = "ZED_PKG_LOCAL_SCAN_PRIORITY")] + priority: Option, + /// List discovered projects without writing the index + #[arg(long, env = "ZED_PKG_LOCAL_SCAN_DRY_RUN")] + dry_run: bool, + }, + /// Show which local project would satisfy a dependency + Resolve { + /// Package to resolve, as `org/name` + #[arg(value_name = "PACKAGE")] + package: String, + /// Version requirement to satisfy + #[arg(long, env = "ZED_PKG_LOCAL_RESOLVE_REQUIRE", default_value = "*")] + require: String, + /// Emit deterministic machine-readable JSON + #[arg(long, env = "ZED_PKG_LOCAL_RESOLVE_JSON")] + json: bool, + }, + /// Print the path of the shared index file + Path, +} + #[cfg(test)] mod tests { use std::collections::BTreeSet; diff --git a/src/dev.rs b/src/dev.rs index 67daa95ca..ff781daf8 100644 --- a/src/dev.rs +++ b/src/dev.rs @@ -890,6 +890,7 @@ fn install_declared_tools(root: &Path, cfg: &Config, options: &DevelopArgs) -> R allow_native_deps: options.allow_native_deps, allow_install_hooks: options.allow_install_hooks, native_manager: options.native_manager.clone(), + local_registry: crate::local_registry::LocalRegistryMode::from_env()?, }; ops::install_with_permissions( root, diff --git a/src/lib.rs b/src/lib.rs index d1dd0dbe4..06fa6adfc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,8 @@ pub mod host_downloads; pub mod inspect; pub mod install_graph; pub mod interactive; +pub mod local_cli; +pub mod local_registry; pub mod lock_waiter; pub mod managed_install; pub mod manifestless; diff --git a/src/local_cli.rs b/src/local_cli.rs new file mode 100644 index 000000000..03f9b6392 --- /dev/null +++ b/src/local_cli.rs @@ -0,0 +1,249 @@ +//! Presentation layer for `zed local`. +//! +//! [`crate::local_registry`] owns the index and every decision it encodes. +//! This module only turns the typed command model into calls against it and +//! renders the result, so the same operations stay usable from library code +//! and integration tests without going through stdout. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Serialize; +use zed_interfaces::version::Requirement; + +use crate::cli::LocalCmd; +use crate::config::Config; +use crate::local_registry::{ + self, EntryStatus, LocalEntry, RegisterAction, Selector, parse_selector, +}; + +/// Machine-readable shape of one registration. Kept separate from the on-disk +/// [`LocalEntry`] so the index format and the reporting format can evolve +/// independently. +#[derive(Debug, Serialize)] +struct EntryReport { + id: String, + package: String, + path: String, + registered_version: String, + priority: i64, + enabled: bool, + health: String, + selectable: bool, +} + +impl EntryReport { + fn new(status: &EntryStatus) -> Self { + Self { + id: status.entry.id(), + package: status.entry.key(), + path: status.entry.path.clone(), + registered_version: status.entry.version.clone(), + priority: status.entry.priority, + enabled: status.entry.enabled, + health: status.health.label(), + selectable: status.health.is_selectable(), + } + } +} + +#[derive(Debug, Serialize)] +struct ResolveReport { + package: String, + requirement: String, + resolved: Option, + skipped: Vec, +} + +#[derive(Debug, Serialize)] +struct ResolvedReport { + path: String, + version: String, + priority: i64, + id: String, +} + +#[derive(Debug, Serialize)] +struct SkippedReport { + path: String, + reason: String, +} + +pub fn dispatch(cwd: &Path, cfg: &Config, cmd: LocalCmd) -> Result<()> { + match cmd { + LocalCmd::Register { + path, + priority, + disabled, + } => { + let target = resolve_path(cwd, path); + let (action, entry) = local_registry::register(cfg, &target, priority, !disabled)?; + let verb = match action { + RegisterAction::Added => "registered", + RegisterAction::Updated => "refreshed", + }; + println!("{verb} {}@{} -> {}", entry.key(), entry.version, entry.path); + println!("id: {}", entry.id()); + if !entry.enabled { + println!("note: entry is disabled and will not be selected"); + } + Ok(()) + } + LocalCmd::Unregister { selector, all } => { + let selector = parse_selector(&selector)?; + let removed = local_registry::unregister(cfg, &selector, all)?; + for entry in &removed { + println!("unregistered {} ({})", entry.key(), entry.path); + } + Ok(()) + } + LocalCmd::List { json } => { + let statuses = local_registry::status(cfg)?; + if json { + let reports: Vec = statuses.iter().map(EntryReport::new).collect(); + println!("{}", serde_json::to_string_pretty(&reports)?); + return Ok(()); + } + if statuses.is_empty() { + println!( + "no local projects registered (index: {})", + local_registry::index_path(cfg)?.display() + ); + return Ok(()); + } + for status in &statuses { + println!( + "{} {} priority {} [{}]", + status.entry.key(), + status.entry.path, + status.entry.priority, + status.health.label() + ); + } + Ok(()) + } + LocalCmd::Enable { selector, all } => set_enabled(cfg, &selector, true, all), + LocalCmd::Disable { selector, all } => set_enabled(cfg, &selector, false, all), + LocalCmd::Prune { dry_run } => { + let removed = local_registry::prune(cfg, dry_run)?; + if removed.is_empty() { + println!("nothing to prune"); + return Ok(()); + } + for status in &removed { + let verb = if dry_run { "would drop" } else { "dropped" }; + println!( + "{verb} {} ({}) — {}", + status.entry.key(), + status.entry.path, + status.health.label() + ); + } + Ok(()) + } + LocalCmd::Scan { + path, + max_depth, + priority, + dry_run, + } => { + let root = resolve_path(cwd, path); + let hits = local_registry::scan(cfg, &root, max_depth, priority, dry_run)?; + if hits.is_empty() { + println!("no Zed projects found below {}", root.display()); + return Ok(()); + } + for hit in &hits { + let verb = match hit.action { + Some(RegisterAction::Added) => "registered", + Some(RegisterAction::Updated) => "refreshed", + None => "found", + }; + println!( + "{verb} {}@{} -> {}", + hit.key, + hit.version, + hit.dir.display() + ); + } + println!("{} project(s)", hits.len()); + Ok(()) + } + LocalCmd::Resolve { + package, + require, + json, + } => { + let key = normalize_key(&package)?; + let requirement = Requirement::parse(&require); + let index = local_registry::load(cfg)?; + let (selected, skipped) = local_registry::select(&index, &key, &requirement)?; + if json { + let report = ResolveReport { + package: key.clone(), + requirement: require.clone(), + resolved: selected.as_ref().map(|selection| ResolvedReport { + path: selection.entry.path.clone(), + version: selection.manifest.package.version.clone(), + priority: selection.entry.priority, + id: selection.entry.id(), + }), + skipped: skipped + .iter() + .map(|(entry, health)| SkippedReport { + path: entry.path.clone(), + reason: health.label(), + }) + .collect(), + }; + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + match selected { + Some(selection) => println!( + "{key} {} satisfied by {} ({})", + require, selection.entry.path, selection.manifest.package.version + ), + None => println!("no registered local project satisfies {key} {require}"), + } + for (entry, health) in &skipped { + println!("skipped {} — {}", entry.path, health.label()); + } + Ok(()) + } + LocalCmd::Path => { + println!("{}", local_registry::index_path(cfg)?.display()); + Ok(()) + } + } +} + +fn set_enabled(cfg: &Config, selector: &str, enabled: bool, all: bool) -> Result<()> { + let selector: Selector = parse_selector(selector)?; + let changed = local_registry::set_enabled(cfg, &selector, enabled, all)?; + let verb = if enabled { "enabled" } else { "disabled" }; + for entry in &changed { + print_entry(verb, entry); + } + Ok(()) +} + +fn print_entry(verb: &str, entry: &LocalEntry) { + println!("{verb} {} ({})", entry.key(), entry.path); +} + +/// Resolve an optional path operand against the invocation directory so a +/// relative `zed local register ../sibling` means what the shell means. +fn resolve_path(cwd: &Path, path: Option) -> PathBuf { + match path { + Some(path) if path.is_absolute() => path, + Some(path) => cwd.join(path), + None => cwd.to_path_buf(), + } +} + +fn normalize_key(raw: &str) -> Result { + let (org, name) = crate::ops::split_key(raw.trim()) + .with_context(|| format!("`{raw}` is not an `org/name` package key"))?; + Ok(format!("{org}/{name}")) +} diff --git a/src/local_registry.rs b/src/local_registry.rs new file mode 100644 index 000000000..05fa2cd8c --- /dev/null +++ b/src/local_registry.rs @@ -0,0 +1,1364 @@ +//! Machine-wide registry of local, on-disk Zed projects. +//! +//! The HTTP registry is one source of packages; a developer's own checkouts +//! are another. During development of the registry itself — or on an +//! air-gapped machine, or simply while `registry.zpkg.net` is down — a +//! dependency that already exists as a directory on this filesystem should be +//! resolvable without a network round trip. +//! +//! This module owns that second source. A project directory containing a +//! [`MANIFEST_FILE`] is *registered* into a shared index under the Zed home +//! directory. Registration is per **path**, not per package name: the same +//! `org/name` may legitimately exist in several checkouts (a release clone, a +//! feature worktree, a bisect tree), and collapsing them onto the package name +//! alone would silently build the wrong one. Every entry therefore carries its +//! canonical filesystem path, the index is keyed by that path, and a +//! name-level tie that cannot be broken deterministically is a hard error +//! rather than a coin flip. +//! +//! Resolution is *live*: the recorded version is a registration-time snapshot +//! used for reporting and drift detection, while the manifest on disk is +//! authoritative every time a dependency is resolved. Selected entries are +//! materialized with the same source-link path used for workspace members, so +//! `--install-mode=symlink` produces a canonical absolute directory symlink +//! into the developer's checkout and edits are visible to consumers +//! immediately. +//! +//! Everything here is fail-closed. An entry whose path disappeared, whose +//! manifest stopped parsing, or whose package identity changed under it is +//! reported as unhealthy and never selected. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zed_interfaces::manifest::{Manifest, is_slug}; +use zed_interfaces::paths::MANIFEST_FILE; +use zed_interfaces::version::Requirement; +use zed_lock::{LockClass, LockManager, LockRequest}; + +use crate::config::{Config, read_manifest}; + +/// Directory under `ZED_PKG_HOME` that holds the shared local-project index. +pub const LOCAL_REGISTRY_DIR: &str = "local-registry"; +/// Index file name inside [`LOCAL_REGISTRY_DIR`]. +pub const INDEX_FILE: &str = "index.json"; +/// Lock file name inside [`LOCAL_REGISTRY_DIR`]. +const LOCK_FILE: &str = ".lock"; +/// Schema tag written into (and required by) the index. +pub const INDEX_SCHEMA: &str = "zed.local-registry.v1"; + +/// Selects the local-registry mode without a `--local-registry` flag. +pub const MODE_ENV: &str = "ZED_PKG_LOCAL_REGISTRY"; +/// Relocates the index file itself (hermetic tests, throwaway sandboxes). +pub const INDEX_PATH_ENV: &str = "ZED_PKG_LOCAL_REGISTRY_FILE"; + +/// Ceiling on the index file size. The index is operator-owned state, but it +/// is still parsed on every install, so a corrupted or hostile file must not +/// be able to turn `zed install` into an out-of-memory abort. +const MAX_INDEX_BYTES: u64 = 8 * 1024 * 1024; +/// Ceiling on registered projects. Far above any real developer machine while +/// keeping the per-install linear scans trivially bounded. +const MAX_ENTRIES: usize = 4096; +/// Default `zed local scan` recursion depth below the requested root. +pub const DEFAULT_SCAN_DEPTH: usize = 6; +/// Hard ceiling on `--max-depth`, and on directories visited by one scan. +const MAX_SCAN_DEPTH: usize = 32; +const MAX_SCAN_VISITS: usize = 200_000; + +/// Directory names never descended into by `zed local scan`. These are +/// dependency, build, and VCS trees: a manifest inside one is a *copy* of some +/// package, never a checkout the developer edits. +const SCAN_SKIP_DIRS: &[&str] = &[ + ".git", + ".hg", + ".svn", + ".zed", + ".zed-pack", + ".vendor", + "zed_modules", + "node_modules", + "target", + "vendor", + "dist", + "build", + ".venv", + "venv", + "__pycache__", + ".direnv", + ".terraform", +]; + +// --------------------------------------------------------------------------- +// mode + +/// How much authority the local registry has over dependency resolution. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] +pub enum LocalRegistryMode { + /// Never consult the local registry; every dependency comes from the + /// configured remote registry. + Off, + /// Consult the local registry before the remote one for ordinary + /// (non-frozen) installs. Frozen installs stay byte-reproducible and + /// ignore ambient machine state. This is the default: an entry only + /// exists because someone ran `zed local register` on that exact path. + #[default] + Auto, + /// Like `auto`, and additionally allowed to satisfy `--frozen` installs. + /// Reproducibility then depends on this machine's registrations. + Prefer, + /// The local registry is the only source. A dependency with no healthy + /// local entry is an error instead of a remote lookup — a fully offline + /// install. + Only, +} + +impl LocalRegistryMode { + /// Parse the value of [`MODE_ENV`]. Unset or empty means [`Self::Auto`]. + pub fn parse(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" => Ok(Self::Auto), + "off" | "none" | "never" | "false" | "0" => Ok(Self::Off), + "auto" => Ok(Self::Auto), + "prefer" | "true" | "1" => Ok(Self::Prefer), + "only" | "offline" => Ok(Self::Only), + other => { + bail!("invalid {MODE_ENV} value `{other}` (expected off, auto, prefer, or only)") + } + } + } + + /// Resolve the mode from the process environment. + pub fn from_env() -> Result { + match std::env::var(MODE_ENV) { + Ok(value) => Self::parse(&value), + Err(std::env::VarError::NotPresent) => Ok(Self::Auto), + Err(std::env::VarError::NotUnicode(_)) => { + bail!("{MODE_ENV} is not valid UTF-8") + } + } + } + + /// Is the local registry consulted at all for this install? + pub fn enabled(self) -> bool { + !matches!(self, Self::Off) + } + + /// May the local registry satisfy an install with the given frozen policy? + /// + /// A frozen install replays an exact lockfile. Ambient, machine-local + /// registrations are deliberately excluded from that replay unless the + /// operator asked for them by name. + pub fn applies_to_frozen(self, frozen: bool) -> bool { + match self { + Self::Off => false, + Self::Auto => !frozen, + Self::Prefer | Self::Only => true, + } + } + + /// Is a dependency with no healthy local entry an error? + pub fn requires_local(self) -> bool { + matches!(self, Self::Only) + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Off => "off", + Self::Auto => "auto", + Self::Prefer => "prefer", + Self::Only => "only", + } + } +} + +// --------------------------------------------------------------------------- +// on-disk format + +/// One registered local project. +/// +/// `path` is the identity. `org`/`name`/`version` are a registration-time +/// snapshot: they make `zed local list` readable and let drift be reported, +/// but the manifest on disk wins whenever a dependency is actually resolved. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalEntry { + pub org: String, + pub name: String, + pub version: String, + /// Canonical absolute path to the directory holding [`MANIFEST_FILE`]. + pub path: String, + /// Higher wins when several entries provide the same package. Equal + /// priority plus equal version is an ambiguity error, never a guess. + #[serde(default)] + pub priority: i64, + /// Retained but never selected while false, so a checkout can be shelved + /// without losing its priority. + #[serde(default = "default_true")] + pub enabled: bool, + /// Digest of the manifest bytes at registration time. Reported by + /// `zed local list` as drift; never used to reject a resolution, because + /// living source is the point. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_sha256: Option, + /// Unix epoch seconds at registration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registered_at: Option, +} + +fn default_true() -> bool { + true +} + +impl LocalEntry { + /// `org/name` for this entry. + pub fn key(&self) -> String { + format!("{}/{}", self.org, self.name) + } + + /// Stable short handle derived from the canonical path. Convenient for + /// `zed local unregister ` when two checkouts differ deep in a long + /// path. + pub fn id(&self) -> String { + let digest = Sha256::digest(self.path.as_bytes()); + hex::encode(digest)[..16].to_string() + } + + pub fn path_buf(&self) -> PathBuf { + PathBuf::from(&self.path) + } + + /// Deterministic total order: package identity first, then path. + fn sort_key(&self) -> (&str, &str, &str) { + (&self.org, &self.name, &self.path) + } +} + +/// The complete shared index. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalIndex { + pub schema: String, + #[serde(default)] + pub entries: Vec, +} + +impl Default for LocalIndex { + fn default() -> Self { + Self { + schema: INDEX_SCHEMA.to_string(), + entries: Vec::new(), + } + } +} + +impl LocalIndex { + /// Canonical order for deterministic bytes on disk. + /// + /// Duplicates are deliberately *not* collapsed here. `register` guarantees + /// one entry per path by replacing in place; a duplicate can therefore only + /// come from a hand-edited or corrupted file, and quietly discarding one of + /// two conflicting claims on the same directory would hide exactly the + /// state an operator needs to see. [`Self::validate`] rejects it instead. + fn normalize(&mut self) { + self.entries.sort_by(|left, right| { + left.sort_key() + .cmp(&right.sort_key()) + .then_with(|| left.version.cmp(&right.version)) + }); + } + + /// Validate everything that could otherwise reach a filesystem path, a + /// registry route, or an unbounded loop later on. + fn validate(&self) -> Result<()> { + ensure!( + self.schema == INDEX_SCHEMA, + "unsupported local registry schema `{}` (expected `{INDEX_SCHEMA}`); \ + this index was written by a different zed version", + self.schema + ); + ensure!( + self.entries.len() <= MAX_ENTRIES, + "local registry has {} entries, above the {MAX_ENTRIES} ceiling", + self.entries.len() + ); + let mut seen: BTreeMap<&str, ()> = BTreeMap::new(); + for entry in &self.entries { + ensure!( + is_slug(&entry.org) && is_slug(&entry.name), + "local registry entry `{}` has an invalid package identity `{}/{}`", + entry.path, + entry.org, + entry.name + ); + ensure!( + !entry.version.is_empty(), + "local registry entry `{}` has an empty version", + entry.path + ); + let path = Path::new(&entry.path); + ensure!( + path.is_absolute(), + "local registry entry `{}` is not an absolute path", + entry.path + ); + ensure!( + seen.insert(entry.path.as_str(), ()).is_none(), + "local registry has duplicate entries for `{}`", + entry.path + ); + } + Ok(()) + } + + /// Every enabled entry providing `key`, in deterministic order. + pub fn candidates_for(&self, key: &str) -> Vec<&LocalEntry> { + self.entries + .iter() + .filter(|entry| entry.enabled && entry.key() == key) + .collect() + } +} + +// --------------------------------------------------------------------------- +// paths and locking + +pub fn registry_dir(cfg: &Config) -> PathBuf { + cfg.home.join(LOCAL_REGISTRY_DIR) +} + +/// Where the index lives. [`INDEX_PATH_ENV`] relocates it wholesale so tests +/// and sandboxes never touch a developer's real registrations. +pub fn index_path(cfg: &Config) -> Result { + match std::env::var_os(INDEX_PATH_ENV) { + Some(value) if !value.is_empty() => { + let path = PathBuf::from(value); + ensure!( + path.is_absolute(), + "{INDEX_PATH_ENV} must be an absolute path, got `{}`", + path.display() + ); + Ok(path) + } + _ => Ok(registry_dir(cfg).join(INDEX_FILE)), + } +} + +fn index_parent(cfg: &Config) -> Result { + let path = index_path(cfg)?; + path.parent() + .map(Path::to_path_buf) + .context("local registry index path has no parent directory") +} + +fn secure_create_dir(path: &Path) -> Result<()> { + fs::create_dir_all(path) + .with_context(|| format!("creating local registry directory {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +/// Serialize one read-modify-write of the shared index. +/// +/// Two `zed local register` runs racing on the same index would otherwise +/// last-writer-wins away one of the registrations. The lock lives beside the +/// index so processes with different `ZED_PKG_HOME` values that were pointed +/// at the same index by [`INDEX_PATH_ENV`] still coordinate. +fn with_locked_index( + cfg: &Config, + operation: &str, + body: impl FnOnce(&mut LocalIndex) -> Result, +) -> Result { + let parent = index_parent(cfg)?; + secure_create_dir(&parent)?; + let lock_path = parent.join(LOCK_FILE); + let _lock = LockManager::global() + .acquire_blocking( + LockRequest::exclusive(&lock_path) + .operation(operation) + .class(LockClass::Custom(5)) + .queue_same_process(), + ) + .with_context(|| format!("locking {}", lock_path.display()))?; + + let mut index = load(cfg)?; + let result = body(&mut index)?; + index.normalize(); + index.validate()?; + save(cfg, &index)?; + Ok(result) +} + +/// Read the index. A missing file is an empty registry, not an error. +pub fn load(cfg: &Config) -> Result { + let path = index_path(cfg)?; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(LocalIndex::default()); + } + Err(error) => { + return Err(error) + .with_context(|| format!("reading local registry index {}", path.display())); + } + }; + ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "local registry index {} is not a regular, non-symlink file", + path.display() + ); + ensure!( + metadata.len() <= MAX_INDEX_BYTES, + "local registry index {} is {} bytes, above the {MAX_INDEX_BYTES} byte ceiling", + path.display(), + metadata.len() + ); + let text = fs::read_to_string(&path) + .with_context(|| format!("reading local registry index {}", path.display()))?; + if text.trim().is_empty() { + return Ok(LocalIndex::default()); + } + let mut index: LocalIndex = serde_json::from_str(&text) + .with_context(|| format!("parsing local registry index {}", path.display()))?; + index.normalize(); + index + .validate() + .with_context(|| format!("validating local registry index {}", path.display()))?; + Ok(index) +} + +/// Write the index atomically: staged beside the final path, then renamed. +fn save(cfg: &Config, index: &LocalIndex) -> Result<()> { + let path = index_path(cfg)?; + let parent = index_parent(cfg)?; + secure_create_dir(&parent)?; + let text = format!("{}\n", serde_json::to_string_pretty(index)?); + ensure!( + text.len() as u64 <= MAX_INDEX_BYTES, + "refusing to write a {} byte local registry index", + text.len() + ); + let temporary = parent.join(format!(".{INDEX_FILE}.tmp-{}", std::process::id())); + { + let mut options = fs::OpenOptions::new(); + options.create(true).write(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .with_context(|| format!("creating {}", temporary.display()))?; + file.write_all(text.as_bytes()) + .with_context(|| format!("writing {}", temporary.display()))?; + file.sync_all() + .with_context(|| format!("flushing {}", temporary.display()))?; + } + if let Err(error) = fs::rename(&temporary, &path) { + let _ = fs::remove_file(&temporary); + return Err(error) + .with_context(|| format!("installing local registry index {}", path.display())); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// path validation + +/// Resolve a user-supplied path to the canonical directory that will become an +/// entry's identity. +/// +/// Canonicalization is deliberate: registering `~/src/current` where `current` +/// is a symlink records the real checkout, so the entry keeps meaning the same +/// tree after the symlink is repointed, and two spellings of one directory can +/// never both be registered. +pub fn canonical_project_dir(raw: &Path) -> Result { + let canonical = fs::canonicalize(raw) + .with_context(|| format!("resolving project path {}", raw.display()))?; + ensure!( + canonical.is_dir(), + "{} is not a directory", + canonical.display() + ); + ensure!( + canonical.to_str().is_some(), + "project path {} is not valid UTF-8; the local registry index is JSON", + canonical.display() + ); + Ok(canonical) +} + +/// True when `inner` is `outer` or lies beneath it. +fn contains(outer: &Path, inner: &Path) -> bool { + inner == outer || inner.starts_with(outer) +} + +/// Read and validate the manifest of a candidate project directory. +/// +/// The manifest itself must be a regular non-symlink file. A symlinked +/// manifest means the identity of the tree is decided somewhere outside it, +/// which is exactly the ambiguity this registry exists to remove. +fn read_project_manifest_bytes(dir: &Path) -> Result<(Manifest, String)> { + let manifest_path = dir.join(MANIFEST_FILE); + let metadata = fs::symlink_metadata(&manifest_path).with_context(|| { + format!( + "{} has no {MANIFEST_FILE}; only Zed projects can be registered", + dir.display() + ) + })?; + ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "{} must be a regular, non-symlink file", + manifest_path.display() + ); + let bytes = + fs::read(&manifest_path).with_context(|| format!("reading {}", manifest_path.display()))?; + let text = std::str::from_utf8(&bytes) + .with_context(|| format!("{} is not UTF-8", manifest_path.display()))?; + let manifest = Manifest::parse(text) + .with_context(|| format!("invalid manifest {}", manifest_path.display()))?; + Ok((manifest, hex::encode(Sha256::digest(&bytes)))) +} + +/// Everything that must hold before a directory may enter the index. +fn validate_registrable(cfg: &Config, dir: &Path) -> Result<(Manifest, String)> { + // The content-addressed store holds extracted *artifacts*. Registering one + // would feed materialized output back in as source and let an install link + // the store into itself. Compare against the resolved home when it exists + // so a symlinked `ZED_PKG_HOME` cannot be used to slip past the check. + let home = fs::canonicalize(&cfg.home).unwrap_or_else(|_| cfg.home.clone()); + ensure!( + !contains(&home, dir), + "refusing to register {}: it is inside the Zed home directory {}", + dir.display(), + home.display() + ); + ensure!( + !contains(dir, &home), + "refusing to register {}: it contains the Zed home directory {}", + dir.display(), + home.display() + ); + read_project_manifest_bytes(dir) +} + +// --------------------------------------------------------------------------- +// health + +/// Why an entry is (or is not) usable right now. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryHealth { + /// Path, manifest, and identity all still agree with the index. + Ok { version: String, drifted: bool }, + /// Deliberately shelved with `zed local disable`. + Disabled, + /// The directory is gone. + MissingPath, + /// The directory exists but is no longer a Zed project. + MissingManifest, + /// The manifest stopped parsing or validating. + InvalidManifest(String), + /// The manifest now declares a different package than the one registered. + IdentityChanged { found: String }, +} + +impl EntryHealth { + pub fn is_selectable(&self) -> bool { + matches!(self, Self::Ok { .. }) + } + + pub fn label(&self) -> String { + match self { + Self::Ok { + version, + drifted: false, + } => format!("ok ({version})"), + Self::Ok { + version, + drifted: true, + } => format!("ok ({version}, manifest changed since registration)"), + Self::Disabled => "disabled".to_string(), + Self::MissingPath => "stale: directory is gone".to_string(), + Self::MissingManifest => format!("stale: no {MANIFEST_FILE}"), + Self::InvalidManifest(reason) => format!("stale: invalid manifest ({reason})"), + Self::IdentityChanged { found } => { + format!("stale: manifest now declares {found}") + } + } + } +} + +/// Inspect one entry against the filesystem. Never mutates the index. +pub fn health(entry: &LocalEntry) -> (EntryHealth, Option) { + if !entry.enabled { + return (EntryHealth::Disabled, None); + } + let dir = entry.path_buf(); + if !dir.is_dir() { + return (EntryHealth::MissingPath, None); + } + if !dir.join(MANIFEST_FILE).exists() { + return (EntryHealth::MissingManifest, None); + } + let manifest = match read_manifest(&dir) { + Ok(manifest) => manifest, + Err(error) => { + return (EntryHealth::InvalidManifest(format!("{error:#}")), None); + } + }; + if manifest.full_name() != entry.key() { + return ( + EntryHealth::IdentityChanged { + found: manifest.full_name(), + }, + None, + ); + } + let drifted = match (&entry.manifest_sha256, current_manifest_sha256(&dir)) { + (Some(recorded), Some(current)) => recorded != ¤t, + _ => false, + }; + let version = manifest.package.version.clone(); + (EntryHealth::Ok { version, drifted }, Some(manifest)) +} + +fn current_manifest_sha256(dir: &Path) -> Option { + let bytes = fs::read(dir.join(MANIFEST_FILE)).ok()?; + Some(hex::encode(Sha256::digest(&bytes))) +} + +/// An entry paired with its current filesystem health, for reporting. +#[derive(Debug, Clone)] +pub struct EntryStatus { + pub entry: LocalEntry, + pub health: EntryHealth, +} + +// --------------------------------------------------------------------------- +// selection + +/// A local project chosen to satisfy one dependency. +#[derive(Debug, Clone)] +pub struct LocalSelection { + pub entry: LocalEntry, + pub manifest: Manifest, + pub dir: PathBuf, +} + +/// Pick the entry that satisfies `requirement` for `key`, if any. +/// +/// Ordering is priority, then version, then path — but a tie on *both* +/// priority and version between two different paths is an error. Two checkouts +/// of `acme/widget@1.4.0` are indistinguishable to this resolver, and quietly +/// linking whichever sorted first is the kind of bug that costs an afternoon. +/// The operator breaks the tie with `--priority` or by unregistering one. +/// +/// Unhealthy entries are skipped and returned in `skipped` so the caller can +/// warn once, at the point where it matters, instead of on every command. +pub fn select( + index: &LocalIndex, + key: &str, + requirement: &Requirement, +) -> Result<(Option, Vec<(LocalEntry, EntryHealth)>)> { + let mut skipped = Vec::new(); + let mut matching: Vec = Vec::new(); + + for entry in index.candidates_for(key) { + let (state, manifest) = health(entry); + let Some(manifest) = manifest else { + skipped.push((entry.clone(), state)); + continue; + }; + if !requirement.matches(&manifest.package.version) { + continue; + } + matching.push(LocalSelection { + entry: entry.clone(), + dir: entry.path_buf(), + manifest, + }); + } + + if matching.is_empty() { + return Ok((None, skipped)); + } + + matching.sort_by(|left, right| { + right + .entry + .priority + .cmp(&left.entry.priority) + .then_with(|| { + compare_versions( + &right.manifest.package.version, + &left.manifest.package.version, + ) + }) + .then_with(|| left.entry.path.cmp(&right.entry.path)) + }); + + let best = &matching[0]; + let tied: Vec<&LocalSelection> = matching + .iter() + .filter(|candidate| { + candidate.entry.priority == best.entry.priority + && candidate.manifest.package.version == best.manifest.package.version + }) + .collect(); + if tied.len() > 1 { + let paths = tied + .iter() + .map(|candidate| { + format!( + " {} (priority {})", + candidate.entry.path, candidate.entry.priority + ) + }) + .collect::>() + .join("\n"); + bail!( + "local registry cannot choose between {} registrations of {key}@{} \ + at equal priority:\n{paths}\n\ + Break the tie with `zed local register --priority N` or \ + `zed local unregister `.", + tied.len(), + best.manifest.package.version + ); + } + + Ok((Some(matching.swap_remove(0)), skipped)) +} + +/// Compare two version strings, newest first when both are semver. Non-semver +/// (`opaque`) versions fall back to a byte comparison so ordering stays total +/// and deterministic. +fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering { + match (semver::Version::parse(left), semver::Version::parse(right)) { + (Ok(left), Ok(right)) => left.cmp(&right), + _ => left.cmp(right), + } +} + +// --------------------------------------------------------------------------- +// mutating operations + +/// What one registration did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegisterAction { + Added, + Updated, +} + +/// Register (or refresh) one project directory. +pub fn register( + cfg: &Config, + raw_path: &Path, + priority: Option, + enabled: bool, +) -> Result<(RegisterAction, LocalEntry)> { + let dir = canonical_project_dir(raw_path)?; + let (manifest, manifest_sha256) = validate_registrable(cfg, &dir)?; + let path = dir + .to_str() + .context("canonical project path is not valid UTF-8")? + .to_string(); + + with_locked_index(cfg, "local registry register", |index| { + let existing = index.entries.iter().position(|entry| entry.path == path); + let now = epoch_seconds(); + let entry = LocalEntry { + org: manifest.package.org.clone(), + name: manifest.package.name.clone(), + version: manifest.package.version.clone(), + path: path.clone(), + priority: priority.unwrap_or_else(|| { + existing + .map(|position| index.entries[position].priority) + .unwrap_or(0) + }), + enabled, + manifest_sha256: Some(manifest_sha256.clone()), + registered_at: existing + .and_then(|position| index.entries[position].registered_at) + .or(now), + }; + match existing { + Some(position) => { + index.entries[position] = entry.clone(); + Ok((RegisterAction::Updated, entry)) + } + None => { + ensure!( + index.entries.len() < MAX_ENTRIES, + "local registry already holds {MAX_ENTRIES} projects; \ + run `zed local prune` or unregister something first" + ); + index.entries.push(entry.clone()); + Ok((RegisterAction::Added, entry)) + } + } + }) +} + +fn epoch_seconds() -> Option { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|elapsed| elapsed.as_secs()) +} + +/// A selector names either one path or one `org/name`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Selector { + Path(String), + Key(String), + Id(String), +} + +/// Interpret a CLI selector. +/// +/// `org/name` is a package key; anything that resolves to a directory on this +/// filesystem is a path; a 16-character hex string is an entry id. Paths are +/// tried before keys so a relative directory literally named `a/b` still wins, +/// and an unresolvable path is reported as such instead of silently becoming a +/// key that matches nothing. +pub fn parse_selector(raw: &str) -> Result { + let raw = raw.trim(); + ensure!(!raw.is_empty(), "empty local registry selector"); + if let Ok(canonical) = fs::canonicalize(raw) + && canonical.is_dir() + { + let path = canonical + .to_str() + .context("selector path is not valid UTF-8")? + .to_string(); + return Ok(Selector::Path(path)); + } + if raw.len() == 16 && raw.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Ok(Selector::Id(raw.to_ascii_lowercase())); + } + if let Some((org, name)) = raw.split_once('/') + && is_slug(org) + && is_slug(name) + { + return Ok(Selector::Key(format!("{org}/{name}"))); + } + bail!( + "`{raw}` is neither an existing directory, a 16-character entry id, \ + nor an `org/name` package key" + ) +} + +fn matches_selector(entry: &LocalEntry, selector: &Selector) -> bool { + match selector { + Selector::Path(path) => &entry.path == path, + Selector::Key(key) => &entry.key() == key, + Selector::Id(id) => &entry.id() == id, + } +} + +/// Remove entries matching `selector`. +/// +/// A key selector that matches several checkouts requires `all`, so +/// `zed local unregister acme/widget` can never quietly drop a registration +/// the operator did not have in mind. +pub fn unregister(cfg: &Config, selector: &Selector, all: bool) -> Result> { + with_locked_index(cfg, "local registry unregister", |index| { + let matched: Vec = index + .entries + .iter() + .filter(|entry| matches_selector(entry, selector)) + .cloned() + .collect(); + if matched.is_empty() { + bail!("no local registry entry matches that selector"); + } + if matched.len() > 1 && !all { + let paths = matched + .iter() + .map(|entry| format!(" {}", entry.path)) + .collect::>() + .join("\n"); + bail!( + "{} registrations match; pass --all or name one path:\n{paths}", + matched.len() + ); + } + index + .entries + .retain(|entry| !matches_selector(entry, selector)); + Ok(matched) + }) +} + +/// Enable or disable entries matching `selector` without forgetting them. +pub fn set_enabled( + cfg: &Config, + selector: &Selector, + enabled: bool, + all: bool, +) -> Result> { + with_locked_index(cfg, "local registry enable", |index| { + let matches = index + .entries + .iter() + .filter(|entry| matches_selector(entry, selector)) + .count(); + if matches == 0 { + bail!("no local registry entry matches that selector"); + } + if matches > 1 && !all { + bail!("{matches} registrations match; pass --all or name one path"); + } + let mut changed = Vec::new(); + for entry in &mut index.entries { + if matches_selector(entry, selector) { + entry.enabled = enabled; + changed.push(entry.clone()); + } + } + Ok(changed) + }) +} + +/// Report every entry with its current health. +pub fn status(cfg: &Config) -> Result> { + let index = load(cfg)?; + Ok(index + .entries + .into_iter() + .map(|entry| { + let (health, _) = health(&entry); + EntryStatus { entry, health } + }) + .collect()) +} + +/// Drop entries whose path, manifest, or identity no longer holds up. +/// +/// Disabled entries are never pruned: being shelved is not being broken. +pub fn prune(cfg: &Config, dry_run: bool) -> Result> { + let removable = |entry: &LocalEntry| -> Option { + let (state, _) = health(entry); + match state { + EntryHealth::Ok { .. } | EntryHealth::Disabled => None, + other => Some(other), + } + }; + + if dry_run { + let index = load(cfg)?; + return Ok(index + .entries + .into_iter() + .filter_map(|entry| removable(&entry).map(|health| EntryStatus { entry, health })) + .collect()); + } + + with_locked_index(cfg, "local registry prune", |index| { + let mut removed = Vec::new(); + index.entries.retain(|entry| match removable(entry) { + Some(health) => { + removed.push(EntryStatus { + entry: entry.clone(), + health, + }); + false + } + None => true, + }); + Ok(removed) + }) +} + +/// One project found by [`scan`]. +#[derive(Debug, Clone)] +pub struct ScanHit { + pub dir: PathBuf, + pub key: String, + pub version: String, + pub action: Option, +} + +/// Discover Zed projects beneath `root` and register them. +/// +/// Nested projects are expected — a workspace root and its members are all +/// registrable — so a directory with a manifest is still descended into. +/// Dependency, build, and VCS trees are skipped wholesale: a manifest inside +/// `node_modules` or `target` is materialized output, not a checkout. +pub fn scan( + cfg: &Config, + root: &Path, + max_depth: usize, + priority: Option, + dry_run: bool, +) -> Result> { + ensure!( + max_depth <= MAX_SCAN_DEPTH, + "--max-depth {max_depth} is above the {MAX_SCAN_DEPTH} ceiling" + ); + let root = canonical_project_dir(root)?; + let mut hits = Vec::new(); + let mut visits = 0usize; + let mut queue = vec![(root, 0usize)]; + + while let Some((dir, depth)) = queue.pop() { + visits += 1; + ensure!( + visits <= MAX_SCAN_VISITS, + "scan visited more than {MAX_SCAN_VISITS} directories; narrow the root or --max-depth" + ); + if dir.join(MANIFEST_FILE).is_file() + && let Ok((manifest, _)) = read_project_manifest_bytes(&dir) + && validate_registrable(cfg, &dir).is_ok() + { + hits.push(ScanHit { + dir: dir.clone(), + key: manifest.full_name(), + version: manifest.package.version.clone(), + action: None, + }); + } + if depth >= max_depth { + continue; + } + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if SCAN_SKIP_DIRS.contains(&name) { + continue; + } + // Follow no symlinks: a scan must not wander out of the requested + // subtree, and a symlink loop must not turn it into a hang. + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + continue; + } + queue.push((entry.path(), depth + 1)); + } + } + + hits.sort_by(|left, right| left.dir.cmp(&right.dir)); + if dry_run { + return Ok(hits); + } + for hit in &mut hits { + let (action, _) = register(cfg, &hit.dir, priority, true)?; + hit.action = Some(action); + } + Ok(hits) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_for(home: &Path) -> Config { + Config { + registry: "https://registry.invalid".to_string(), + home: home.to_path_buf(), + token: None, + auth_url: "https://registry.invalid/shared-auth".to_string(), + supabase_url: None, + supabase_key: None, + interactive: false, + } + } + + fn write_project(root: &Path, org: &str, name: &str, version: &str) -> PathBuf { + let dir = root.join(format!("{org}-{name}-{version}")); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(MANIFEST_FILE), + format!( + "[package]\norg = \"{org}\"\nname = \"{name}\"\nversion = \"{version}\"\n\n\ + [package.repository]\nvcs = \"git\"\nurl = \"https://localhost/{org}/{name}\"\n" + ), + ) + .unwrap(); + dir + } + + #[test] + fn mode_parsing_covers_every_documented_spelling() { + assert_eq!( + LocalRegistryMode::parse("").unwrap(), + LocalRegistryMode::Auto + ); + assert_eq!( + LocalRegistryMode::parse(" OFF ").unwrap(), + LocalRegistryMode::Off + ); + assert_eq!( + LocalRegistryMode::parse("only").unwrap(), + LocalRegistryMode::Only + ); + assert!(LocalRegistryMode::parse("maybe").is_err()); + } + + #[test] + fn frozen_installs_ignore_ambient_registrations_until_asked() { + assert!(!LocalRegistryMode::Auto.applies_to_frozen(true)); + assert!(LocalRegistryMode::Auto.applies_to_frozen(false)); + assert!(LocalRegistryMode::Prefer.applies_to_frozen(true)); + assert!(LocalRegistryMode::Only.applies_to_frozen(true)); + assert!(!LocalRegistryMode::Off.applies_to_frozen(false)); + } + + #[test] + fn registration_is_keyed_by_path_not_package_name() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let first = write_project(temp.path(), "acme", "widget", "1.0.0"); + let second = write_project(temp.path(), "acme", "widget", "2.0.0"); + + let (action, _) = register(&cfg, &first, None, true).unwrap(); + assert_eq!(action, RegisterAction::Added); + let (action, _) = register(&cfg, &second, None, true).unwrap(); + assert_eq!(action, RegisterAction::Added); + // Re-registering the same path refreshes rather than duplicating. + let (action, _) = register(&cfg, &first, None, true).unwrap(); + assert_eq!(action, RegisterAction::Updated); + + let index = load(&cfg).unwrap(); + assert_eq!(index.entries.len(), 2); + assert_eq!(index.candidates_for("acme/widget").len(), 2); + } + + #[test] + fn selection_prefers_priority_then_version() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let old = write_project(temp.path(), "acme", "widget", "1.0.0"); + let new = write_project(temp.path(), "acme", "widget", "1.5.0"); + register(&cfg, &old, None, true).unwrap(); + register(&cfg, &new, None, true).unwrap(); + + let index = load(&cfg).unwrap(); + let (selected, skipped) = select(&index, "acme/widget", &Requirement::parse("^1")).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(selected.unwrap().dir, fs::canonicalize(&new).unwrap()); + + // Priority outranks a newer version. + register(&cfg, &old, Some(10), true).unwrap(); + let index = load(&cfg).unwrap(); + let (selected, _) = select(&index, "acme/widget", &Requirement::parse("^1")).unwrap(); + assert_eq!(selected.unwrap().dir, fs::canonicalize(&old).unwrap()); + } + + #[test] + fn an_unbreakable_tie_fails_closed() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let left = temp.path().join("left"); + let right = temp.path().join("right"); + for dir in [&left, &right] { + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join(MANIFEST_FILE), + "[package]\norg = \"acme\"\nname = \"widget\"\nversion = \"1.0.0\"\n\n\ + [package.repository]\nvcs = \"git\"\nurl = \"https://localhost/acme/widget\"\n", + ) + .unwrap(); + register(&cfg, dir, None, true).unwrap(); + } + let index = load(&cfg).unwrap(); + let error = select(&index, "acme/widget", &Requirement::parse("^1")) + .expect_err("an ambiguous local selection must fail closed"); + let rendered = format!("{error:#}"); + assert!(rendered.contains("cannot choose between"), "{rendered}"); + } + + #[test] + fn requirement_mismatch_falls_through_instead_of_failing() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let project = write_project(temp.path(), "acme", "widget", "1.0.0"); + register(&cfg, &project, None, true).unwrap(); + let index = load(&cfg).unwrap(); + let (selected, skipped) = select(&index, "acme/widget", &Requirement::parse("^2")).unwrap(); + assert!(selected.is_none()); + assert!(skipped.is_empty()); + } + + #[test] + fn disabled_and_deleted_entries_are_never_selected() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let project = write_project(temp.path(), "acme", "widget", "1.0.0"); + register(&cfg, &project, None, true).unwrap(); + let selector = parse_selector(project.to_str().unwrap()).unwrap(); + set_enabled(&cfg, &selector, false, false).unwrap(); + + let index = load(&cfg).unwrap(); + let (selected, _) = select(&index, "acme/widget", &Requirement::parse("^1")).unwrap(); + assert!(selected.is_none()); + + set_enabled(&cfg, &selector, true, false).unwrap(); + fs::remove_dir_all(&project).unwrap(); + let index = load(&cfg).unwrap(); + let (selected, skipped) = select(&index, "acme/widget", &Requirement::parse("^1")).unwrap(); + assert!(selected.is_none()); + assert_eq!(skipped.len(), 1); + assert_eq!(skipped[0].1, EntryHealth::MissingPath); + } + + #[test] + fn prune_removes_broken_entries_but_keeps_disabled_ones() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let broken = write_project(temp.path(), "acme", "gone", "1.0.0"); + let shelved = write_project(temp.path(), "acme", "shelved", "1.0.0"); + register(&cfg, &broken, None, true).unwrap(); + register(&cfg, &shelved, None, true).unwrap(); + let selector = parse_selector(shelved.to_str().unwrap()).unwrap(); + set_enabled(&cfg, &selector, false, false).unwrap(); + fs::remove_dir_all(&broken).unwrap(); + + let planned = prune(&cfg, true).unwrap(); + assert_eq!(planned.len(), 1); + let removed = prune(&cfg, false).unwrap(); + assert_eq!(removed.len(), 1); + let index = load(&cfg).unwrap(); + assert_eq!(index.entries.len(), 1); + assert_eq!(index.entries[0].name, "shelved"); + } + + #[test] + fn the_zed_home_directory_can_never_be_registered() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let inside = home.join("store").join("pkg"); + fs::create_dir_all(&inside).unwrap(); + fs::write( + inside.join(MANIFEST_FILE), + "[package]\norg = \"acme\"\nname = \"widget\"\nversion = \"1.0.0\"\n\n\ + [package.repository]\nvcs = \"git\"\nurl = \"https://localhost/acme/widget\"\n", + ) + .unwrap(); + let error = register(&cfg, &inside, None, true) + .expect_err("registering inside the store must fail closed"); + assert!(format!("{error:#}").contains("Zed home directory")); + } + + #[test] + fn a_symlinked_manifest_is_refused() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let real = write_project(temp.path(), "acme", "widget", "1.0.0"); + let fake = temp.path().join("fake"); + fs::create_dir_all(&fake).unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(real.join(MANIFEST_FILE), fake.join(MANIFEST_FILE)).unwrap(); + let error = register(&cfg, &fake, None, true) + .expect_err("a symlinked manifest must fail closed"); + assert!(format!("{error:#}").contains("non-symlink")); + } + #[cfg(not(unix))] + let _ = (real, fake); + } + + #[test] + fn an_identity_change_under_the_index_is_reported_not_selected() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let project = write_project(temp.path(), "acme", "widget", "1.0.0"); + register(&cfg, &project, None, true).unwrap(); + fs::write( + project.join(MANIFEST_FILE), + "[package]\norg = \"acme\"\nname = \"other\"\nversion = \"1.0.0\"\n\n\ + [package.repository]\nvcs = \"git\"\nurl = \"https://localhost/acme/other\"\n", + ) + .unwrap(); + let index = load(&cfg).unwrap(); + let (selected, skipped) = select(&index, "acme/widget", &Requirement::parse("^1")).unwrap(); + assert!(selected.is_none()); + assert!(matches!(skipped[0].1, EntryHealth::IdentityChanged { .. })); + } + + #[test] + fn the_index_round_trips_through_deterministic_json() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let first = write_project(temp.path(), "acme", "beta", "1.0.0"); + let second = write_project(temp.path(), "acme", "alpha", "1.0.0"); + register(&cfg, &first, None, true).unwrap(); + register(&cfg, &second, None, true).unwrap(); + + let path = index_path(&cfg).unwrap(); + let first_bytes = fs::read(&path).unwrap(); + let index = load(&cfg).unwrap(); + save(&cfg, &index).unwrap(); + assert_eq!(first_bytes, fs::read(&path).unwrap()); + // Sorted by package identity, so `alpha` precedes `beta`. + assert_eq!(index.entries[0].name, "alpha"); + } + + #[test] + fn an_oversized_or_foreign_index_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let path = index_path(&cfg).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "{\"schema\":\"zed.local-registry.v99\",\"entries\":[]}", + ) + .unwrap(); + let error = load(&cfg).expect_err("a foreign schema must fail closed"); + assert!(format!("{error:#}").contains("unsupported local registry schema")); + } + + #[test] + fn scan_finds_nested_projects_and_skips_dependency_trees() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let cfg = config_for(&home); + let root = temp.path().join("tree"); + let member = root.join("packages").join("member"); + let vendored = root.join("node_modules").join("copy"); + for (dir, name) in [(&root, "root"), (&member, "member"), (&vendored, "copy")] { + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join(MANIFEST_FILE), + format!( + "[package]\norg = \"acme\"\nname = \"{name}\"\nversion = \"1.0.0\"\n\n\ + [package.repository]\nvcs = \"git\"\nurl = \"https://localhost/acme/{name}\"\n" + ), + ) + .unwrap(); + } + let hits = scan(&cfg, &root, DEFAULT_SCAN_DEPTH, None, false).unwrap(); + let names: Vec<&str> = hits.iter().map(|hit| hit.key.as_str()).collect(); + assert!(names.contains(&"acme/root")); + assert!(names.contains(&"acme/member")); + assert!(!names.contains(&"acme/copy")); + } +} diff --git a/src/main.rs b/src/main.rs index 796462dae..81f875f35 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use zed_cli::fetch; use zed_cli::git_submodules as submodules; use zed_cli::global; use zed_cli::graph_export; +use zed_cli::local_cli; use zed_cli::managed_install; use zed_cli::mise_export::{self, MiseExportMode}; use zed_cli::nix_bundle_write; @@ -253,6 +254,7 @@ fn run(cli: Cli) -> anyhow::Result<()> { target, allow_no_manifest, allow_ecosystem_mismatch, + local_registry, } => { if !cli.is_empty() { anyhow::ensure!( @@ -284,6 +286,7 @@ fn run(cli: Cli) -> anyhow::Result<()> { allow_native_deps, allow_install_hooks, native_manager, + local_registry, }; if sync_git_submodules { // Git synchronization mutates the submodule worktrees and must @@ -332,6 +335,7 @@ fn run(cli: Cli) -> anyhow::Result<()> { } } Cmd::Uninstall { specs } => ops::uninstall(&cwd, &cfg, &specs), + Cmd::Local { cmd } => local_cli::dispatch(&cwd, &cfg, cmd), Cmd::Env { cmd } => match cmd { EnvCmd::Import { manager, diff --git a/src/manifestless.rs b/src/manifestless.rs index 4e08ce4b2..f01be2546 100644 --- a/src/manifestless.rs +++ b/src/manifestless.rs @@ -95,6 +95,7 @@ pub fn install( allow_native_deps, allow_install_hooks, native_manager: native_manager.map(str::to_owned), + local_registry: crate::local_registry::LocalRegistryMode::from_env()?, }; if selection.has_manifest { diff --git a/src/ops.rs b/src/ops.rs index 0d4082d6f..3d505d5a8 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -22,6 +22,7 @@ use zed_interfaces::version::{self, Requirement}; use crate::cli::{Adapter, InstallMode}; use crate::config::{Config, Credentials, read_manifest, write_manifest}; use crate::interactive; +use crate::local_registry::{self, LocalIndex, LocalRegistryMode}; use crate::native::{self, NativeInstallOutcome, NativeRequirement}; use crate::pack::{self, PackResult}; use crate::registry::{Registry, registry_for}; @@ -239,14 +240,27 @@ fn workspace_member_for_dependency<'a>( workspace?.members.get(key) } -fn collect_workspace_links_for_frozen( +/// Reconstruct every dependency that is materialized from live source rather +/// than from an immutable registry artifact. +/// +/// Two kinds qualify, in this order of authority. A **workspace member** is +/// declared by the root manifest of the tree being installed, so it is part of +/// the project. A **local registration** is machine-global state created by +/// `zed local register`; it is consulted only when `local` is `Some`, which +/// the caller decides from [`LocalRegistryMode`] and the frozen policy. +/// +/// Source links are transitive: linking a package means its own dependencies +/// are read from that checkout's manifest, not from the lockfile. +fn collect_source_links_for_frozen( project: &Path, manifest: &Manifest, workspace: Option<&WorkspaceInfo>, + local: Option<&LocalIndex>, ) -> Result> { - let Some(workspace) = workspace else { + if workspace.is_none() && local.is_none() { return Ok(BTreeMap::new()); - }; + } + let project_root = fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf()); let mut links = BTreeMap::new(); let mut pending: VecDeque<(String, String)> = manifest @@ -258,33 +272,101 @@ fn collect_workspace_links_for_frozen( while let Some((raw_key, requirement_text)) = pending.pop_front() { let (org, name) = split_key(&raw_key)?; let key = format!("{org}/{name}"); - let Some(member_dir) = workspace_member_for_dependency(manifest, Some(workspace), &key) - else { + let requirement = Requirement::parse(&requirement_text); + + if let Some(member_dir) = workspace_member_for_dependency(manifest, workspace, &key) { + let member_manifest = read_manifest(member_dir).with_context(|| { + format!( + "reading workspace member `{key}` from {}", + member_dir.display() + ) + })?; + if !requirement.matches(&member_manifest.package.version) { + bail!( + "workspace member {key}@{} does not satisfy `{requirement_text}`", + member_manifest.package.version + ); + } + if member_dir == project || links.contains_key(&key) { + continue; + } + links.insert(key, member_dir.clone()); + pending.extend(member_manifest.dependencies); + continue; + } + + let Some(index) = local else { continue; }; - let member_manifest = read_manifest(member_dir).with_context(|| { - format!( - "reading workspace member `{key}` from {}", - member_dir.display() - ) - })?; - let requirement = Requirement::parse(&requirement_text); - if !requirement.matches(&member_manifest.package.version) { - bail!( - "workspace member {key}@{} does not satisfy `{requirement_text}`", - member_manifest.package.version - ); + // A dependency on this project's own identity is a request for the + // published artifact, exactly as it is for workspace members. + if key == manifest.full_name() || links.contains_key(&key) { + continue; } - if member_dir == project || links.contains_key(&key) { + let (selection, skipped) = local_registry::select(index, &key, &requirement)?; + warn_about_unusable_local_entries(&key, &skipped); + let Some(selection) = selection else { + continue; + }; + if source_link_overlaps_project(&project_root, &selection.dir) { + eprintln!( + "warning: ignoring the local registration of {key} at {} because it overlaps \ + the project being installed into", + selection.dir.display() + ); continue; } - links.insert(key, member_dir.clone()); - pending.extend(member_manifest.dependencies); + println!( + "local {key}@{} -> {}", + selection.manifest.package.version, + selection.dir.display() + ); + links.insert(key, selection.dir.clone()); + pending.extend(selection.manifest.dependencies.clone()); } Ok(links) } +/// A registration that no longer resolves is reported once, where it would +/// otherwise have been used, and then ignored. Silence would let a renamed or +/// deleted checkout quietly send the install back to the network. +fn warn_about_unusable_local_entries( + key: &str, + skipped: &[(local_registry::LocalEntry, local_registry::EntryHealth)], +) { + for (entry, health) in skipped { + eprintln!( + "warning: local registration of {key} at {} is unusable: {}", + entry.path, + health.label() + ); + } +} + +/// A source link must not overlap the project being installed into, in either +/// direction. Linking the project itself, an ancestor of it, or a directory +/// beneath it would put `zed_modules//` inside its own link target; +/// materialization rejects that too, but catching it here names the +/// registration that caused it. +fn source_link_overlaps_project(project_root: &Path, source: &Path) -> bool { + source.starts_with(project_root) || project_root.starts_with(source) +} + +/// Load the local project index when this install is allowed to use it. +fn local_index_for( + cfg: &Config, + mode: LocalRegistryMode, + frozen: bool, +) -> Result> { + if !mode.enabled() || !mode.applies_to_frozen(frozen) { + return Ok(None); + } + local_registry::load(cfg) + .context("reading the local project registry") + .map(Some) +} + // --------------------------------------------------------------------------- // install @@ -293,16 +375,22 @@ pub struct InstallOutcome { pub installed: Vec<(String, String)>, } -/// Independent trust decisions for package-authored lifecycle behavior. +/// Independent trust decisions for one install: which package-authored +/// lifecycle code may run, and which sources dependencies may come from. /// Existing library callers that use [`install`] retain the historical /// build-only opt-in while the CLI uses [`install_with_permissions`] to pass -/// native-dependency and install-hook consent explicitly. +/// native-dependency, install-hook, and dependency-source policy explicitly. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct InstallPermissions { pub allow_build: bool, pub allow_native_deps: bool, pub allow_install_hooks: bool, pub native_manager: Option, + /// How much authority machine-local `zed local` registrations have over + /// dependency resolution. Defaults to [`LocalRegistryMode::Auto`], which + /// consults registered checkouts for ordinary installs and leaves frozen + /// replays reading only the lockfile. + pub local_registry: LocalRegistryMode, } #[derive(Debug, Clone)] @@ -1046,10 +1134,14 @@ fn install_with_frozen_policy( ) } +/// A frozen install checks that every manifest requirement is pinned by the +/// lockfile — except the ones satisfied from live source, which by definition +/// have no immutable pin. `source_links` carries exactly those: workspace +/// members and, when the mode allows it, local registrations. fn validate_frozen_manifest_requirements( manifest: &Manifest, lock: &Lockfile, - workspace: Option<&WorkspaceInfo>, + source_links: &BTreeMap, enforce: bool, ) -> Result<()> { if !enforce { @@ -1058,7 +1150,7 @@ fn validate_frozen_manifest_requirements( let root_key = manifest.full_name(); for (key, req_str) in &manifest.dependencies { let (org, name) = split_key(key)?; - if key != &root_key && workspace.is_some_and(|ws| ws.members.contains_key(key)) { + if key != &root_key && source_links.contains_key(key) { continue; } let entry = lock @@ -1215,6 +1307,9 @@ fn install_locked( let lock_path = project.join(LOCKFILE_FILE); let workspace = find_workspace(project); + let local_mode = permissions.local_registry; + let local_index = local_index_for(cfg, local_mode, frozen)?; + let project_root = fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf()); let mut workspace_links: BTreeMap = BTreeMap::new(); let mut resolved: BTreeMap = BTreeMap::new(); @@ -1222,14 +1317,20 @@ fn install_locked( let text = fs::read_to_string(&lock_path) .with_context(|| format!("--frozen requires {LOCKFILE_FILE}"))?; let lock = Lockfile::parse(&text)?; + // Source links are reconstructed first: they decide which manifest + // requirements are exempt from needing a lockfile pin. + workspace_links = collect_source_links_for_frozen( + project, + &manifest, + workspace.as_ref(), + local_index.as_ref(), + )?; validate_frozen_manifest_requirements( &manifest, &lock, - workspace.as_ref(), + &workspace_links, validate_manifest_requirements, )?; - workspace_links = - collect_workspace_links_for_frozen(project, &manifest, workspace.as_ref())?; for locked in &lock.packages { if !is_slug(&locked.org) || !is_slug(&locked.name) { bail!( @@ -1297,6 +1398,59 @@ fn install_locked( continue; } let req = Requirement::parse(&req_str); + // A registered local checkout satisfies the dependency without a + // network round trip, and is materialized through the same + // source-link path as a workspace member so `--install-mode=symlink` + // points straight at the developer's tree. + // Once a key is pinned to a registry artifact, a later edge for + // the same package must not silently switch it to a source link; + // fall through so the version-conflict check below sees it. + if let Some(index) = local_index.as_ref() + && key != manifest.full_name() + && !resolved.contains_key(&key) + { + let (selection, skipped) = local_registry::select(index, &key, &req)?; + warn_about_unusable_local_entries(&key, &skipped); + let usable = selection.filter(|selection| { + if source_link_overlaps_project(&project_root, &selection.dir) { + eprintln!( + "warning: ignoring the local registration of {key} at {} because \ + it overlaps the project being installed into", + selection.dir.display() + ); + return false; + } + true + }); + if let Some(selection) = usable { + if !workspace_links.contains_key(&key) { + println!( + "local {key}@{} -> {}", + selection.manifest.package.version, + selection.dir.display() + ); + workspace_links.insert(key.clone(), selection.dir.clone()); + for (sub_key, sub_req) in selection.manifest.dependencies.clone() { + let (sub_org, sub_name) = split_key(&sub_key)?; + queue.push_back((sub_org, sub_name, sub_req)); + } + } + continue; + } + if !index.candidates_for(&key).is_empty() { + eprintln!( + "note: {key} is registered locally but no registration satisfies \ + `{req_str}`; falling back to {}", + cfg.registry + ); + } + if local_mode.requires_local() { + bail!( + "--local-registry=only: no registered local project satisfies {key} \ + `{req_str}`; register one with `zed local register `" + ); + } + } if let Some(existing) = resolved.get(&key) { if req.matches(&existing.version) { continue; @@ -1615,7 +1769,10 @@ fn install_locked( shas.push(vm.sha256.clone()); } for (key, member_dir) in &workspace_links { - interactive::confirm(cfg.interactive, &format!("link workspace package {key}"))?; + interactive::confirm( + cfg.interactive, + &format!("link {key} from source at {}", member_dir.display()), + )?; let member_manifest = workspace_manifests .get(key) .with_context(|| format!("workspace package `{key}` has no parsed manifest"))?; @@ -1836,10 +1993,14 @@ pub fn uninstall(project: &Path, cfg: &Config, specs: &[String]) -> Result<()> { // still uninstall and later restore its exact materialized graph. let manifest = read_manifest(project).ok(); let workspace = find_workspace(project); + let local_index = local_index_for(cfg, LocalRegistryMode::from_env()?, false)?; let workspace_links = match manifest.as_ref() { - Some(manifest) => { - collect_workspace_links_for_frozen(project, manifest, workspace.as_ref())? - } + Some(manifest) => collect_source_links_for_frozen( + project, + manifest, + workspace.as_ref(), + local_index.as_ref(), + )?, None => BTreeMap::new(), }; let total_materialized = lock.packages.len() + workspace_links.len(); @@ -2199,6 +2360,7 @@ fn install_build_dependencies( .manager .clone() .or_else(|| permissions.native_manager.clone()), + local_registry: permissions.local_registry, }; install_locked( &deps_dir, @@ -2593,6 +2755,7 @@ pub fn build_cmd( allow_native_deps, allow_install_hooks, native_manager: native_manager.map(str::to_owned), + local_registry: LocalRegistryMode::from_env()?, }; let mut packages = Vec::new(); let mut native_requirements = vec![NativeRequirement::new( @@ -3660,7 +3823,8 @@ url = "https://example.invalid/ws-cli" let manifest = read_manifest(&app).unwrap(); let workspace = find_workspace(&app).unwrap(); - let links = collect_workspace_links_for_frozen(&app, &manifest, Some(&workspace)).unwrap(); + let links = + collect_source_links_for_frozen(&app, &manifest, Some(&workspace), None).unwrap(); assert_eq!( links.keys().cloned().collect::>(), vec![ @@ -3677,7 +3841,7 @@ url = "https://example.invalid/ws-cli" .replace("\"^1\"", "\"^2\""); fs::write(app.join(MANIFEST_FILE), incompatible).unwrap(); let manifest = read_manifest(&app).unwrap(); - let error = collect_workspace_links_for_frozen(&app, &manifest, Some(&workspace)) + let error = collect_source_links_for_frozen(&app, &manifest, Some(&workspace), None) .unwrap_err() .to_string(); assert!(error.contains("ws-utils@1.1.0"), "{error}"); diff --git a/tests/local_registry_adversarial.rs b/tests/local_registry_adversarial.rs new file mode 100644 index 000000000..fb438f255 --- /dev/null +++ b/tests/local_registry_adversarial.rs @@ -0,0 +1,370 @@ +//! Failure modes of the shared local-project index. +//! +//! The index is operator-owned state, but it is read on every install and its +//! contents become filesystem paths and symlink targets. Everything here is a +//! way that state can be wrong — concurrent writers, hand-edited or hostile +//! files, a swapped index — and asserts that Zed fails closed rather than +//! resolving something it cannot justify. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; + +fn command(cwd: &Path, home: &Path, args: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_zed")); + command + .current_dir(cwd) + .args(args) + .env("ZED_PKG_HOME", home) + .env("ZED_PKG_REGISTRY", "https://registry.invalid") + .env_remove("ZED_PKG_LOCAL_REGISTRY") + .env_remove("ZED_PKG_LOCAL_REGISTRY_FILE") + .env_remove("ZED_PKG_INTERACTIVE") + .env_remove("ZED_PKG_TOKEN"); + command +} + +fn run(cwd: &Path, home: &Path, args: &[&str]) -> Output { + command(cwd, home, args).output().unwrap() +} + +fn spawn(cwd: &Path, home: &Path, args: &[&str]) -> Child { + command(cwd, home, args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn project(dir: &Path, org: &str, name: &str, version: &str) { + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join(".zpkg.toml"), + format!( + "[package]\norg = \"{org}\"\nname = \"{name}\"\nversion = \"{version}\"\n\n\ + [package.repository]\nvcs = \"git\"\nurl = \"https://localhost/{org}/{name}\"\n" + ), + ) + .unwrap(); +} + +struct Fixture { + _temp: tempfile::TempDir, + root: PathBuf, + home: PathBuf, +} + +fn fixture() -> Fixture { + let temp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let home = root.join("zed-home"); + fs::create_dir_all(&home).unwrap(); + Fixture { + _temp: temp, + root, + home, + } +} + +impl Fixture { + fn run(&self, args: &[&str]) -> Output { + run(&self.root, &self.home, args) + } + + fn index(&self) -> PathBuf { + PathBuf::from(stdout(&self.run(&["local", "path"])).trim()) + } + + fn write_index(&self, contents: &str) { + let path = self.index(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, contents).unwrap(); + } +} + +/// Sixteen processes registering different projects at once must all survive. +/// A plain read-modify-write would last-writer-wins most of these away. +#[test] +fn concurrent_registrations_do_not_lose_entries() { + let fx = fixture(); + let count = 16; + let mut names = Vec::new(); + for index in 0..count { + let name = format!("pkg-{index:02}"); + project(&fx.root.join(&name), "acme", &name, "1.0.0"); + names.push(name); + } + + let children: Vec = names + .iter() + .map(|name| spawn(&fx.root, &fx.home, &["local", "register", name])) + .collect(); + for mut child in children { + let status = child.wait().unwrap(); + assert!(status.success(), "a concurrent registration failed"); + } + + let listed = fx.run(&["local", "list", "--json"]); + assert!(listed.status.success(), "{}", stderr(&listed)); + let report: serde_json::Value = serde_json::from_str(&stdout(&listed)).unwrap(); + assert_eq!( + report.as_array().unwrap().len(), + count, + "every concurrent registration must survive:\n{}", + stdout(&listed) + ); +} + +/// Racing writers on one path must converge on a single entry, not duplicates. +#[test] +fn concurrent_registrations_of_one_path_converge() { + let fx = fixture(); + project(&fx.root.join("library"), "acme", "widget", "1.0.0"); + + let children: Vec = (0..8) + .map(|_| spawn(&fx.root, &fx.home, &["local", "register", "library"])) + .collect(); + for mut child in children { + assert!(child.wait().unwrap().success()); + } + + let listed = fx.run(&["local", "list", "--json"]); + let report: serde_json::Value = serde_json::from_str(&stdout(&listed)).unwrap(); + assert_eq!(report.as_array().unwrap().len(), 1); +} + +#[test] +fn an_unknown_schema_is_refused_rather_than_best_effort_parsed() { + let fx = fixture(); + fx.write_index(r#"{"schema":"zed.local-registry.v2","entries":[]}"#); + let output = fx.run(&["local", "list"]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("unsupported local registry schema"), + "{}", + stderr(&output) + ); +} + +#[test] +fn a_relative_entry_path_is_refused() { + let fx = fixture(); + fx.write_index( + r#"{"schema":"zed.local-registry.v1","entries":[ + {"org":"acme","name":"widget","version":"1.0.0","path":"relative/widget"}]}"#, + ); + let output = fx.run(&["local", "list"]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("not an absolute path"), + "{}", + stderr(&output) + ); +} + +#[test] +fn an_invalid_package_identity_is_refused() { + let fx = fixture(); + fx.write_index( + r#"{"schema":"zed.local-registry.v1","entries":[ + {"org":"../escape","name":"widget","version":"1.0.0","path":"/tmp/widget"}]}"#, + ); + let output = fx.run(&["local", "list"]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("invalid package identity"), + "{}", + stderr(&output) + ); +} + +#[test] +fn duplicate_paths_are_refused() { + let fx = fixture(); + fx.write_index( + r#"{"schema":"zed.local-registry.v1","entries":[ + {"org":"acme","name":"widget","version":"1.0.0","path":"/tmp/widget"}, + {"org":"acme","name":"other","version":"1.0.0","path":"/tmp/widget"}]}"#, + ); + let output = fx.run(&["local", "list"]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("duplicate entries"), + "{}", + stderr(&output) + ); +} + +#[test] +fn an_oversized_index_is_refused_before_it_is_parsed() { + let fx = fixture(); + let padding = "x".repeat(9 * 1024 * 1024); + fx.write_index(&format!( + r#"{{"schema":"zed.local-registry.v1","entries":[],"padding":"{padding}"}}"# + )); + let output = fx.run(&["local", "list"]); + assert!(!output.status.success()); + assert!(stderr(&output).contains("ceiling"), "{}", stderr(&output)); +} + +#[cfg(unix)] +#[test] +fn a_symlinked_index_file_is_refused() { + let fx = fixture(); + let real = fx.root.join("elsewhere.json"); + fs::write(&real, r#"{"schema":"zed.local-registry.v1","entries":[]}"#).unwrap(); + let path = fx.index(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&real, &path).unwrap(); + + let output = fx.run(&["local", "list"]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("non-symlink"), + "{}", + stderr(&output) + ); +} + +#[test] +fn an_empty_index_file_is_treated_as_an_empty_registry() { + let fx = fixture(); + fx.write_index(""); + let output = fx.run(&["local", "list"]); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains("no local projects registered")); +} + +#[test] +fn the_index_location_can_be_relocated_for_hermetic_runs() { + let fx = fixture(); + let elsewhere = fx.root.join("sandbox").join("index.json"); + project(&fx.root.join("library"), "acme", "widget", "1.0.0"); + + let registered = command(&fx.root, &fx.home, &["local", "register", "library"]) + .env("ZED_PKG_LOCAL_REGISTRY_FILE", &elsewhere) + .output() + .unwrap(); + assert!(registered.status.success(), "{}", stderr(®istered)); + assert!(elsewhere.is_file(), "the relocated index must be written"); + assert!( + !fx.index().exists(), + "the default index must stay untouched" + ); + + // Without the override the registration is invisible. + assert!(stdout(&fx.run(&["local", "list"])).contains("no local projects registered")); +} + +#[test] +fn a_relative_index_override_is_refused() { + let fx = fixture(); + let output = command(&fx.root, &fx.home, &["local", "list"]) + .env("ZED_PKG_LOCAL_REGISTRY_FILE", "relative/index.json") + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("must be an absolute path"), + "{}", + stderr(&output) + ); +} + +#[test] +fn an_unparseable_local_registry_mode_fails_closed() { + let fx = fixture(); + let app = fx.root.join("app"); + project(&app, "acme", "app", "0.1.0"); + let output = command(&app, &fx.home, &["install"]) + .env("ZED_PKG_LOCAL_REGISTRY", "sometimes") + .output() + .unwrap(); + assert!( + !output.status.success(), + "an unrecognized mode must never silently become the default" + ); + assert!( + stderr(&output).to_lowercase().contains("sometimes"), + "the rejected value must be named:\n{}", + stderr(&output) + ); +} + +#[test] +fn a_selector_that_matches_nothing_is_an_error() { + let fx = fixture(); + let output = fx.run(&["local", "unregister", "acme/absent"]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("no local registry entry matches"), + "{}", + stderr(&output) + ); + + let nonsense = fx.run(&["local", "unregister", "Not A Selector"]); + assert!(!nonsense.status.success()); + assert!( + stderr(&nonsense).contains("neither an existing directory"), + "{}", + stderr(&nonsense) + ); +} + +#[cfg(unix)] +#[test] +fn a_symlinked_manifest_cannot_claim_a_package_identity() { + let fx = fixture(); + let real = fx.root.join("real"); + project(&real, "acme", "widget", "1.0.0"); + let impostor = fx.root.join("impostor"); + fs::create_dir_all(&impostor).unwrap(); + std::os::unix::fs::symlink(real.join(".zpkg.toml"), impostor.join(".zpkg.toml")).unwrap(); + + let output = fx.run(&["local", "register", "impostor"]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("non-symlink"), + "{}", + stderr(&output) + ); +} + +#[cfg(unix)] +#[test] +fn a_symlinked_project_directory_is_recorded_by_its_real_path() { + let fx = fixture(); + let real = fx.root.join("real"); + project(&real, "acme", "widget", "1.0.0"); + let alias = fx.root.join("alias"); + std::os::unix::fs::symlink(&real, &alias).unwrap(); + + assert!( + fx.run(&["local", "register", "alias"]).status.success(), + "registering through a symlinked spelling must work" + ); + // Registering the real path is the same entry, not a second one. + assert!(fx.run(&["local", "register", "real"]).status.success()); + + let listed = fx.run(&["local", "list", "--json"]); + let report: serde_json::Value = serde_json::from_str(&stdout(&listed)).unwrap(); + let entries = report.as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["path"], real.to_str().unwrap()); +} + +#[test] +fn scan_refuses_a_depth_above_the_ceiling() { + let fx = fixture(); + let output = fx.run(&["local", "scan", ".", "--max-depth", "64"]); + assert!(!output.status.success()); + assert!(stderr(&output).contains("ceiling"), "{}", stderr(&output)); +} diff --git a/tests/local_registry_cli.rs b/tests/local_registry_cli.rs new file mode 100644 index 000000000..ed094a1a9 --- /dev/null +++ b/tests/local_registry_cli.rs @@ -0,0 +1,505 @@ +//! End-to-end behavior of `zed local` and of installs resolved from it. +//! +//! These tests never reach the network. The configured registry is an +//! unroutable `file://` path that holds no packages, so any install that +//! succeeds here succeeded *because* the local registry satisfied it — which +//! is the property the feature exists to provide. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn zed(root: &Path, home: &Path, registry: &str, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_zed")) + .current_dir(root) + .args(args) + .env("ZED_PKG_HOME", home) + .env("ZED_PKG_REGISTRY", registry) + .env_remove("ZED_PKG_LOCAL_REGISTRY") + .env_remove("ZED_PKG_LOCAL_REGISTRY_FILE") + .env_remove("ZED_PKG_INTERACTIVE") + .env_remove("ZED_PKG_TOKEN") + .env_remove("ZED_PKG_FROZEN") + .output() + .unwrap() +} + +fn assert_ok(output: &Output, what: &str) { + assert!( + output.status.success(), + "{what} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +/// Write a minimal, valid project. `dependencies` is a list of +/// `("org/name", "requirement")` pairs. +fn project(dir: &Path, org: &str, name: &str, version: &str, dependencies: &[(&str, &str)]) { + fs::create_dir_all(dir).unwrap(); + let mut manifest = format!( + "[package]\norg = \"{org}\"\nname = \"{name}\"\nversion = \"{version}\"\n\n\ + [package.repository]\nvcs = \"git\"\nurl = \"https://localhost/{org}/{name}\"\n" + ); + if !dependencies.is_empty() { + manifest.push_str("\n[dependencies]\n"); + for (key, requirement) in dependencies { + manifest.push_str(&format!("\"{key}\" = \"{requirement}\"\n")); + } + } + fs::write(dir.join(".zpkg.toml"), manifest).unwrap(); + fs::write(dir.join("marker.txt"), format!("{org}/{name}@{version}\n")).unwrap(); +} + +/// A syntactically valid registry URL that resolves to an empty directory, so +/// every remote lookup fails and only local resolution can succeed. +fn empty_registry(root: &Path) -> String { + let dir = root.join("empty-registry"); + fs::create_dir_all(&dir).unwrap(); + format!("file://{}", dir.display()) +} + +struct Fixture { + _temp: tempfile::TempDir, + root: PathBuf, + home: PathBuf, + registry: String, +} + +fn fixture() -> Fixture { + let temp = tempfile::tempdir().unwrap(); + // Canonicalize: macOS hands out `/var/...` temp paths that canonicalize to + // `/private/var/...`, and the index stores canonical paths. + let root = fs::canonicalize(temp.path()).unwrap(); + let home = root.join("zed-home"); + fs::create_dir_all(&home).unwrap(); + let registry = empty_registry(&root); + Fixture { + _temp: temp, + root, + home, + registry, + } +} + +impl Fixture { + fn run(&self, cwd: &Path, args: &[&str]) -> Output { + zed(cwd, &self.home, &self.registry, args) + } +} + +#[test] +fn register_list_and_unregister_round_trip() { + let fx = fixture(); + let library = fx.root.join("library"); + project(&library, "acme", "widget", "1.2.0", &[]); + + let registered = fx.run(&fx.root, &["local", "register", "library"]); + assert_ok(®istered, "zed local register"); + assert!(stdout(®istered).contains("acme/widget@1.2.0")); + + let listed = fx.run(&fx.root, &["local", "list", "--json"]); + assert_ok(&listed, "zed local list --json"); + let report: serde_json::Value = serde_json::from_str(&stdout(&listed)).unwrap(); + let entries = report.as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["package"], "acme/widget"); + assert_eq!(entries[0]["selectable"], true); + assert_eq!(entries[0]["path"], library.to_str().unwrap()); + + // Registering the same directory again refreshes rather than duplicating. + assert_ok( + &fx.run(&library, &["local", "register"]), + "re-register from inside the project", + ); + let listed = fx.run(&fx.root, &["local", "list", "--json"]); + let report: serde_json::Value = serde_json::from_str(&stdout(&listed)).unwrap(); + assert_eq!(report.as_array().unwrap().len(), 1); + + let removed = fx.run(&fx.root, &["local", "unregister", "library"]); + assert_ok(&removed, "zed local unregister"); + let listed = fx.run(&fx.root, &["local", "list"]); + assert!(stdout(&listed).contains("no local projects registered")); +} + +#[test] +fn registering_a_directory_without_a_manifest_fails_closed() { + let fx = fixture(); + let plain = fx.root.join("not-a-project"); + fs::create_dir_all(&plain).unwrap(); + let output = fx.run(&fx.root, &["local", "register", "not-a-project"]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains(".zpkg.toml"), + "{}", + stderr(&output) + ); +} + +#[test] +fn the_index_lives_under_zed_home_and_is_reported() { + let fx = fixture(); + let output = fx.run(&fx.root, &["local", "path"]); + assert_ok(&output, "zed local path"); + let reported = stdout(&output); + assert!(reported.contains("local-registry"), "{reported}"); + assert!(reported.contains("index.json"), "{reported}"); +} + +#[test] +fn scan_registers_every_project_below_a_root() { + let fx = fixture(); + let tree = fx.root.join("tree"); + project(&tree, "acme", "root", "1.0.0", &[]); + project( + &tree.join("packages").join("core"), + "acme", + "core", + "1.0.0", + &[], + ); + project( + &tree.join("node_modules").join("vendored"), + "acme", + "vendored", + "1.0.0", + &[], + ); + + let scanned = fx.run(&fx.root, &["local", "scan", "tree"]); + assert_ok(&scanned, "zed local scan"); + let listed = stdout(&fx.run(&fx.root, &["local", "list"])); + assert!(listed.contains("acme/root"), "{listed}"); + assert!(listed.contains("acme/core"), "{listed}"); + assert!( + !listed.contains("acme/vendored"), + "materialized dependency trees must not be scanned: {listed}" + ); +} + +#[cfg(unix)] +#[test] +fn install_links_a_registered_project_with_no_registry_available() { + let fx = fixture(); + let library = fx.root.join("library"); + project(&library, "acme", "widget", "1.2.0", &[]); + assert_ok( + &fx.run(&fx.root, &["local", "register", "library"]), + "register the dependency", + ); + + let app = fx.root.join("app"); + project(&app, "acme", "app", "0.1.0", &[("acme/widget", "^1")]); + + let installed = fx.run(&app, &["install"]); + assert_ok(&installed, "install resolved from the local registry"); + assert!( + stdout(&installed).contains("local acme/widget@1.2.0"), + "the install must say where the package came from:\n{}", + stdout(&installed) + ); + + let linked = app.join("zed_modules").join("acme").join("widget"); + let metadata = fs::symlink_metadata(&linked).unwrap(); + assert!( + metadata.file_type().is_symlink(), + "symlink mode must produce a symlink, not a copy" + ); + assert_eq!(fs::read_link(&linked).unwrap(), library); + + // The link is live: an edit in the checkout is visible to the consumer + // without reinstalling. That is the whole point of source linking. + fs::write(library.join("marker.txt"), "edited\n").unwrap(); + assert_eq!( + fs::read_to_string(linked.join("marker.txt")).unwrap(), + "edited\n" + ); +} + +#[cfg(unix)] +#[test] +fn transitive_dependencies_of_a_local_project_resolve_locally_too() { + let fx = fixture(); + let core = fx.root.join("core"); + project(&core, "acme", "core", "1.0.0", &[]); + let widget = fx.root.join("widget"); + project(&widget, "acme", "widget", "1.2.0", &[("acme/core", "^1")]); + for name in ["core", "widget"] { + assert_ok( + &fx.run(&fx.root, &["local", "register", name]), + "register a dependency", + ); + } + + let app = fx.root.join("app"); + project(&app, "acme", "app", "0.1.0", &[("acme/widget", "^1")]); + let installed = fx.run(&app, &["install"]); + assert_ok(&installed, "install a transitive local graph"); + + for name in ["widget", "core"] { + let linked = app.join("zed_modules").join("acme").join(name); + assert!( + fs::symlink_metadata(&linked) + .unwrap() + .file_type() + .is_symlink(), + "{name} must be linked from source" + ); + } +} + +#[test] +fn copy_mode_produces_a_standalone_tree_from_a_local_project() { + let fx = fixture(); + let library = fx.root.join("library"); + project(&library, "acme", "widget", "1.2.0", &[]); + assert_ok( + &fx.run(&fx.root, &["local", "register", "library"]), + "register the dependency", + ); + + let app = fx.root.join("app"); + project(&app, "acme", "app", "0.1.0", &[("acme/widget", "^1")]); + assert_ok( + &fx.run(&app, &["install", "--install-mode", "copy"]), + "copy-mode install from the local registry", + ); + + let materialized = app.join("zed_modules").join("acme").join("widget"); + let metadata = fs::symlink_metadata(&materialized).unwrap(); + assert!(metadata.is_dir() && !metadata.file_type().is_symlink()); + assert!(materialized.join("marker.txt").is_file()); +} + +#[test] +fn a_requirement_the_local_checkout_cannot_satisfy_falls_through() { + let fx = fixture(); + let library = fx.root.join("library"); + project(&library, "acme", "widget", "1.2.0", &[]); + assert_ok( + &fx.run(&fx.root, &["local", "register", "library"]), + "register the dependency", + ); + + let app = fx.root.join("app"); + project(&app, "acme", "app", "0.1.0", &[("acme/widget", "^9")]); + let output = fx.run(&app, &["install"]); + assert!( + !output.status.success(), + "the empty registry cannot satisfy ^9 either" + ); + assert!( + stderr(&output).contains("no registration satisfies"), + "the fall-through must be explained:\n{}", + stderr(&output) + ); +} + +#[test] +fn local_registry_off_ignores_registrations_entirely() { + let fx = fixture(); + let library = fx.root.join("library"); + project(&library, "acme", "widget", "1.2.0", &[]); + assert_ok( + &fx.run(&fx.root, &["local", "register", "library"]), + "register the dependency", + ); + + let app = fx.root.join("app"); + project(&app, "acme", "app", "0.1.0", &[("acme/widget", "^1")]); + let output = fx.run(&app, &["install", "--local-registry", "off"]); + assert!( + !output.status.success(), + "with the local registry off the empty remote registry must be consulted" + ); + assert!(!app.join("zed_modules").join("acme").join("widget").exists()); +} + +#[test] +fn local_registry_only_names_the_dependency_it_cannot_satisfy() { + let fx = fixture(); + let app = fx.root.join("app"); + project(&app, "acme", "app", "0.1.0", &[("acme/widget", "^1")]); + let output = fx.run(&app, &["install", "--local-registry", "only"]); + assert!(!output.status.success()); + let rendered = stderr(&output); + assert!(rendered.contains("--local-registry=only"), "{rendered}"); + assert!(rendered.contains("acme/widget"), "{rendered}"); +} + +#[test] +fn two_equal_checkouts_of_one_package_fail_closed_until_a_priority_breaks_the_tie() { + let fx = fixture(); + let left = fx.root.join("left"); + let right = fx.root.join("right"); + project(&left, "acme", "widget", "1.2.0", &[]); + project(&right, "acme", "widget", "1.2.0", &[]); + for name in ["left", "right"] { + assert_ok( + &fx.run(&fx.root, &["local", "register", name]), + "register a checkout", + ); + } + + let ambiguous = fx.run(&fx.root, &["local", "resolve", "acme/widget"]); + assert!(!ambiguous.status.success()); + assert!( + stderr(&ambiguous).contains("cannot choose between"), + "{}", + stderr(&ambiguous) + ); + + assert_ok( + &fx.run( + &fx.root, + &["local", "register", "right", "--priority", "10"], + ), + "break the tie", + ); + let resolved = fx.run(&fx.root, &["local", "resolve", "acme/widget", "--json"]); + assert_ok(&resolved, "zed local resolve"); + let report: serde_json::Value = serde_json::from_str(&stdout(&resolved)).unwrap(); + assert_eq!(report["resolved"]["path"], right.to_str().unwrap()); +} + +#[test] +fn a_key_selector_matching_several_checkouts_requires_all() { + let fx = fixture(); + for name in ["left", "right"] { + let dir = fx.root.join(name); + project(&dir, "acme", "widget", "1.2.0", &[]); + assert_ok( + &fx.run(&fx.root, &["local", "register", name]), + "register a checkout", + ); + } + + let refused = fx.run(&fx.root, &["local", "unregister", "acme/widget"]); + assert!(!refused.status.success()); + assert!(stderr(&refused).contains("--all"), "{}", stderr(&refused)); + + assert_ok( + &fx.run(&fx.root, &["local", "unregister", "acme/widget", "--all"]), + "unregister both checkouts", + ); + assert!(stdout(&fx.run(&fx.root, &["local", "list"])).contains("no local projects registered")); +} + +#[test] +fn disabled_entries_are_kept_but_never_selected() { + let fx = fixture(); + let library = fx.root.join("library"); + project(&library, "acme", "widget", "1.2.0", &[]); + assert_ok( + &fx.run(&fx.root, &["local", "register", "library"]), + "register the dependency", + ); + assert_ok( + &fx.run(&fx.root, &["local", "disable", "library"]), + "disable the registration", + ); + + let resolved = fx.run(&fx.root, &["local", "resolve", "acme/widget"]); + assert_ok(&resolved, "resolve with everything disabled"); + assert!(stdout(&resolved).contains("no registered local project satisfies")); + + // Pruning must not forget a deliberately shelved checkout. + assert_ok(&fx.run(&fx.root, &["local", "prune"]), "prune"); + assert!(stdout(&fx.run(&fx.root, &["local", "list"])).contains("acme/widget")); + + assert_ok( + &fx.run(&fx.root, &["local", "enable", "library"]), + "re-enable the registration", + ); + let resolved = fx.run(&fx.root, &["local", "resolve", "acme/widget"]); + assert!(stdout(&resolved).contains("satisfied by")); +} + +#[test] +fn prune_drops_registrations_whose_checkout_disappeared() { + let fx = fixture(); + let library = fx.root.join("library"); + project(&library, "acme", "widget", "1.2.0", &[]); + assert_ok( + &fx.run(&fx.root, &["local", "register", "library"]), + "register the dependency", + ); + fs::remove_dir_all(&library).unwrap(); + + let listed = stdout(&fx.run(&fx.root, &["local", "list"])); + assert!(listed.contains("stale"), "{listed}"); + + let planned = fx.run(&fx.root, &["local", "prune", "--dry-run"]); + assert_ok(&planned, "prune --dry-run"); + assert!(stdout(&planned).contains("would drop")); + assert!(stdout(&fx.run(&fx.root, &["local", "list"])).contains("acme/widget")); + + assert_ok(&fx.run(&fx.root, &["local", "prune"]), "prune"); + assert!(stdout(&fx.run(&fx.root, &["local", "list"])).contains("no local projects registered")); +} + +#[test] +fn a_stale_registration_is_reported_and_skipped_during_install() { + let fx = fixture(); + let library = fx.root.join("library"); + project(&library, "acme", "widget", "1.2.0", &[]); + assert_ok( + &fx.run(&fx.root, &["local", "register", "library"]), + "register the dependency", + ); + // The checkout is repurposed for a different package under the index. + project(&library, "acme", "other", "1.2.0", &[]); + + let app = fx.root.join("app"); + project(&app, "acme", "app", "0.1.0", &[("acme/widget", "^1")]); + let output = fx.run(&app, &["install"]); + assert!(!output.status.success(), "the empty registry has no widget"); + assert!( + stderr(&output).contains("unusable"), + "the broken registration must be named:\n{}", + stderr(&output) + ); +} + +#[test] +fn a_registration_overlapping_the_consumer_is_refused() { + let fx = fixture(); + let app = fx.root.join("app"); + project(&app, "acme", "app", "0.1.0", &[("acme/inner", "^1")]); + let inner = app.join("inner"); + project(&inner, "acme", "inner", "1.0.0", &[]); + assert_ok( + &fx.run(&fx.root, &["local", "register", "app/inner"]), + "register a directory inside the consumer", + ); + + let output = fx.run(&app, &["install"]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("overlaps the project"), + "{}", + stderr(&output) + ); +} + +#[test] +fn the_zed_home_directory_cannot_be_registered() { + let fx = fixture(); + let inside = fx.home.join("store").join("candidate"); + project(&inside, "acme", "widget", "1.0.0", &[]); + let output = fx.run(&fx.root, &["local", "register", inside.to_str().unwrap()]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("Zed home directory"), + "{}", + stderr(&output) + ); +} From 8730f73cf63d2ac49aef333b662a73d615df7ec9 Mon Sep 17 00:00:00 2001 From: AlexReal Date: Sun, 23 Aug 2026 19:52:04 +0000 Subject: [PATCH 2/5] feat(local-registry): make registrations portable across volumes and containers The local project registry assumed every registered checkout lives on the system disk and keeps the same absolute path forever. Neither holds where it is most useful: a checkout on an external SSD, a bind mount inside a container, or a BuildKit mount that exists for one `docker build` step. * Volume classification. Registration records where the checkout lives (`fixed`, `removable`, `network`, `container-mount`) from `/proc/self/mountinfo` where readable and from the conventional mount layout (`/Volumes/