diff --git a/.cli-flags.toml b/.cli-flags.toml index f01fb7098..67f29b6d8 100644 --- a/.cli-flags.toml +++ b/.cli-flags.toml @@ -80,6 +80,25 @@ type = "bool" default = "false" help = "Allow signed mirror metadata to answer resolution requests." +[flags.local_path_map] +env = "ZED_PKG_LOCAL_REGISTRY_PATH_MAP" +aliases = ["local-path-map"] +type = "string" +help = "host=container path rewrites for local registry entries, comma separated." + +[flags.local_link_policy] +env = "ZED_PKG_LOCAL_LINK_POLICY" +aliases = ["local-link-policy"] +type = "string" +help = "How a local checkout reaches zed_modules/: auto, symlink, or copy." + +[flags.local_ephemeral] +env = "ZED_PKG_LOCAL_REGISTRY_EPHEMERAL" +aliases = ["local-ephemeral"] +type = "bool" +default = "false" +help = "Copy every local checkout because its media will not outlive this process." + [flags.frozen] env = "ZED_PKG_FROZEN" aliases = ["frozen"] @@ -394,6 +413,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." @@ -401,6 +427,134 @@ 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.register.flags.local_register_link] +env = "ZED_PKG_LOCAL_REGISTER_LINK" +aliases = ["link"] +type = "string" +help = "Materialization preference for this checkout: auto, symlink, or copy." + +[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.local.commands.doctor] +help = "Explain the index location, container detection, path mapping, and every registration's volume." + +[commands.local.commands.doctor.flags.local_doctor_json] +env = "ZED_PKG_LOCAL_DOCTOR_JSON" +aliases = ["json"] +type = "bool" +default = "false" +help = "Emit deterministic machine-readable local registry output." + [commands.env] help = "Import, verify, and export project-local developer environments." diff --git a/.dev-cli-flags.toml b/.dev-cli-flags.toml index b1801a396..3894d72b6 100644 --- a/.dev-cli-flags.toml +++ b/.dev-cli-flags.toml @@ -54,6 +54,25 @@ type = "bool" default = "false" help = "Confirm each mutating lifecycle step in a real terminal." +[flags.local_path_map] +env = "ZED_PKG_LOCAL_REGISTRY_PATH_MAP" +aliases = ["local-path-map"] +type = "string" +help = "host=container path rewrites for local registry entries, comma separated." + +[flags.local_link_policy] +env = "ZED_PKG_LOCAL_LINK_POLICY" +aliases = ["local-link-policy"] +type = "string" +help = "How a local checkout reaches zed_modules/: auto, symlink, or copy." + +[flags.local_ephemeral] +env = "ZED_PKG_LOCAL_REGISTRY_EPHEMERAL" +aliases = ["local-ephemeral"] +type = "bool" +default = "false" +help = "Copy every local checkout because its media will not outlive this process." + [flags.git_submodules] env = "ZED_PKG_GIT_SUBMODULES" aliases = ["git-submodules"] diff --git a/README.md b/README.md index 95d90a585..922128abc 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\|doctor` | 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 | @@ -446,6 +448,49 @@ 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). + +Registrations record the volume they live on. A checkout on an external disk, a +network share, or a container bind mount is **copied** into `zed_modules/` +rather than symlinked, because a link into media that can disappear is a project +that breaks when it does; `--local-link-policy` overrides that either way. When +such a volume is unmounted the entry reports `unavailable` rather than +`missing`, so `zed local prune` never forgets a checkout that is merely +unplugged, and resolution falls through to the registry with a reason instead of +failing. + +For containers, `ZED_PKG_LOCAL_REGISTRY_FILE` points at an index shared through +a volume and `--local-path-map /host/path=/container/path` translates registered +paths across the bind mount, in both directions, so one index serves a host and +the containers it runs. `--local-ephemeral` copies everything, which is what a +`docker build` step with a BuildKit mount needs. `zed local doctor` prints that +whole picture — index, container detection, mapping, policy, and each entry's +volume — when an install went to the network and you expected it not to. + +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..f5058eec6 --- /dev/null +++ b/docs/local-registry.md @@ -0,0 +1,331 @@ +# 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. + +## Where resolution happens + +Two components have to agree about where a package comes from, and both consult +the registrations. + +The **dependency solver** runs first and is where the network would be +contacted, so it is where `only` is enforced and where the "no registration +satisfies this" fall-through is reported. A key satisfied locally is treated +exactly like a workspace member: recorded from live source, never fetched, and +never prefetched. The **installer** then materializes what the solver decided, +consulting the index again to find the directory to link. + +Both use the same rule — highest priority, then highest version, ties are +errors — so a package cannot be solved from one checkout and linked from +another. + +## 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. | +| `zed local doctor [--json]` | Explain this machine's whole view: index, container detection, path mapping, link policy, and every entry's volume. | + +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. + +## External and virtual disks + +A checkout on an external SSD, a mounted disk image, or a network share is +registered like any other, and the volume it lives on is recorded with it: + +| Volume | Recognized by | +| --- | --- | +| `removable` | `/Volumes/*` (macOS), `/media/*`, `/mnt/*`, `/run/media/*/*` | +| `network` | NFS, SMB/CIFS, sshfs, WebDAV | +| `container-mount` | virtiofs, 9p, grpcfuse, or any non-system bind mount seen from inside a container | +| `fixed` | everything else | + +On Linux this comes from `/proc/self/mountinfo`; elsewhere — macOS in +particular — the volume root is recovered from the conventional layout, so an +ejected disk is still recognized as *a disk that is not here* rather than as a +directory that was deleted. + +That distinction is the point of a separate `unavailable` state: + +* `unavailable` — the volume is not mounted. `zed local prune` keeps the entry, + because an external drive that happened not to be attached this afternoon is + not a mistake to clean up. +* `stale: directory is gone` — the volume is mounted and the directory is not + there. That registration really is broken, and prune drops it. + +Neither state fails an install. A dependency whose local entry is unusable +falls through to the configured registry, with the reason printed, unless +`--local-registry=only` forbids the network. + +### Removable media is copied, not linked + +`zed_modules//` is a symlink into the checkout on ordinary media. +That is wrong for media that can go away: the link dangles the moment the disk +is ejected or the container exits, and the breakage surfaces far from the +install that caused it. So a checkout on `removable`, `network`, or +`container-mount` storage is **copied** even in symlink install mode. + +The exception is automatic: when the consuming project lives on the *same* +volume as the checkout, linking is allowed again — nothing outlives that volume +either way, so the link costs nothing. + +Overrides, from most to least specific: + +* `zed local register --link symlink|copy|auto` records a per-entry + preference; +* `--local-link-policy symlink|copy` (`ZED_PKG_LOCAL_LINK_POLICY`) is a + process-wide operator decision and wins over the per-entry preference; +* `--local-ephemeral` (`ZED_PKG_LOCAL_REGISTRY_EPHEMERAL`) copies everything. + +## Docker: `docker run` with a shared volume + +Two things differ inside a container: the index lives somewhere else, and the +same bytes have a different absolute path. + +```console +$ docker run --rm \ + -v "$HOME/codes:/work" \ + -v "$HOME/.zed-pkg/local-registry:/zed-local" \ + -e ZED_PKG_LOCAL_REGISTRY_FILE=/zed-local/index.json \ + -e ZED_PKG_LOCAL_REGISTRY_PATH_MAP="$HOME/codes=/work" \ + my-image zed install +``` + +`ZED_PKG_LOCAL_REGISTRY_FILE` points at the index that came in through a volume, +so the host's registrations are visible. `ZED_PKG_LOCAL_REGISTRY_PATH_MAP` +rewrites host paths into container paths: rules are `from=to` separated by +commas, the longest matching source prefix wins (so a nested rule refines a +broader one regardless of declaration order), both sides must be absolute, and +whichever side exists on this machine is canonicalized — a mount described +through a symlink (`/tmp` on macOS is really `/private/tmp`) still matches. + +The map applies in both directions. A `zed local register` performed *inside* +the container writes the **host** path into the index, so one shared index stays +meaningful on both sides of the boundary. + +Without the map, host-shaped entries simply do not resolve in the container: the +install says which path it looked for and falls back to the registry, which is +exactly the message that tells you to add the mapping. + +Entries reached through a bind mount classify as `container-mount` and are +therefore copied, not linked — a symlink to `/work/...` inside `zed_modules/` +means nothing once the container exits. + +## Docker: `docker build` + +A build step has no volumes in the ordinary sense. Sources arrive through +BuildKit mounts that exist for one `RUN` and are absent from the resulting +image, so **nothing may be symlinked**: + +```dockerfile +# syntax=docker/dockerfile:1.7 +FROM ghcr.io/zed-pkg/zed-oci:0.2.0 AS build +WORKDIR /app +COPY .zpkg.toml .zpkg.lock ./ + +RUN --mount=type=bind,source=.,target=/src,ro \ + --mount=type=bind,source=.zed-local,target=/zed-local,ro \ + ZED_PKG_LOCAL_REGISTRY_FILE=/zed-local/index.json \ + ZED_PKG_LOCAL_REGISTRY_PATH_MAP="/home/alex/codes=/src" \ + ZED_PKG_LOCAL_REGISTRY_EPHEMERAL=1 \ + zed install +``` + +`ZED_PKG_LOCAL_REGISTRY_EPHEMERAL=1` declares that every registered checkout +lives on media that will not outlive this process, so all of them are copied and +the layer is self-contained. `--install-mode copy` remains the blunter +instrument that also detaches registry packages from the global store; the two +compose. If you would rather not rely on classification at all, +`--local-link-policy copy` is the explicit form and needs no BuildKit-specific +reasoning. + +## Diagnosing a surprise + +```console +$ zed local doctor +index /Users/alex/.zed-pkg/local-registry/index.json +container no +link policy auto +ephemeral no +path map (none) +entries 2 + ok acme/widget /Users/alex/src/widget [fixed] + unusable acme/proto /Volumes/Scratch/proto [removable] + unavailable: /Volumes/Scratch is not mounted +``` + +`--json` emits the same content for scripts. + +## Verification + +```sh +cargo test --locked --lib local_registry::tests -- --nocapture +cargo test --locked --test local_registry_cli -- --nocapture +cargo test --locked --test local_registry_portability -- --nocapture +cargo test --locked --test local_registry_vs_remote -- --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/auth.rs b/src/auth.rs index 58788978e..9f4119658 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -932,6 +932,7 @@ mod tests { interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), } } diff --git a/src/binary_archive/tests.rs b/src/binary_archive/tests.rs index d8bf36b64..1991dfb07 100644 --- a/src/binary_archive/tests.rs +++ b/src/binary_archive/tests.rs @@ -165,6 +165,7 @@ hello = "bin/hello" interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), }; publish_binary_zip_with_route( diff --git a/src/cli.rs b/src/cli.rs index 3fa7cde9b..6043eea53 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::{LinkPolicy, LocalRegistryMode}; + /// Every user-facing 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). Secret-bearing values stay env-only. @@ -151,6 +153,65 @@ pub struct Globals { action = clap::ArgAction::Set )] pub source_fallback: bool, + + /// `host=container` path rewrites applied to local registry entries, + /// separated by commas. With `-v /Users/me/codes:/work`, pass + /// `--local-path-map /Users/me/codes=/work` so one shared index serves both + /// the host and the containers it runs. + #[arg( + long, + global = true, + env = "ZED_PKG_LOCAL_REGISTRY_PATH_MAP", + value_name = "FROM=TO[,FROM=TO]" + )] + pub local_path_map: Option, + + /// How a registered local checkout reaches zed_modules/. `auto` symlinks + /// from stable media and copies from removable or container mounts. + #[arg( + long, + global = true, + env = "ZED_PKG_LOCAL_LINK_POLICY", + value_enum, + value_name = "POLICY" + )] + pub local_link_policy: Option, + + /// Treat every local checkout as living on media that will not outlive this + /// process, so nothing is symlinked. Container image builds set this. + #[arg( + long, + global = true, + env = "ZED_PKG_LOCAL_REGISTRY_EPHEMERAL", + num_args = 0..=1, + require_equals = true, + default_missing_value = "true", + default_value = "false", + value_parser = clap::builder::BoolishValueParser::new(), + action = clap::ArgAction::Set + )] + pub local_ephemeral: bool, +} + +/// CLI spelling of [`crate::local_registry::LinkPolicy`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum LocalLinkPolicy { + /// Symlink from stable media, copy from removable or container mounts + Auto, + /// Always symlink + Symlink, + /// Always copy; what a container image build needs + Copy, +} + +impl From for LinkPolicy { + fn from(value: LocalLinkPolicy) -> Self { + match value { + LocalLinkPolicy::Auto => LinkPolicy::Auto, + LocalLinkPolicy::Symlink => LinkPolicy::Symlink, + LocalLinkPolicy::Copy => LinkPolicy::Copy, + } + } } /// Contextual adapters translate zed's universal layout into what a @@ -400,6 +461,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. @@ -410,6 +482,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)] @@ -965,6 +1043,104 @@ 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, + /// How this checkout should reach zed_modules/. Defaults to `auto`, + /// which copies from removable and container media so the installed + /// tree survives the volume going away. + #[arg(long, value_enum, env = "ZED_PKG_LOCAL_REGISTER_LINK")] + link: Option, + }, + /// 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, + /// Explain this machine's view: index location, container detection, path + /// mapping, link policy, and every registration's volume and status + Doctor { + /// Emit deterministic machine-readable JSON + #[arg(long, env = "ZED_PKG_LOCAL_DOCTOR_JSON")] + json: bool, + }, +} + #[cfg(test)] mod tests { use std::collections::BTreeSet; diff --git a/src/config.rs b/src/config.rs index 8e9c4f892..5ffb97337 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,7 @@ use zed_interfaces::paths::{MANIFEST_FILE, ZED_HOME_DIR_NAME}; use zed_interfaces::mirror::{MirrorDescriptorV1, MirrorKindV1, default_public_mirrors}; use crate::cli::Globals; +use crate::local_registry::LocalPortability; use crate::mirrored_registry::{FallbackPolicy, MirrorContext, TrustAnchors}; use crate::publisher_keys::TrustCache; use crate::registry::Registry; @@ -48,6 +49,10 @@ pub struct Config { /// dependency's manifest. pub mirrors: Vec, pub fallback: FallbackPolicy, + /// This machine's filesystem view for the local project registry: path + /// rewrites across a container boundary, and how registered checkouts are + /// materialized. + pub local: LocalPortability, } impl Config { @@ -94,6 +99,11 @@ impl Config { .map(str::to_owned), interactive: globals.interactive, mirrors, + local: LocalPortability { + path_map: globals.local_path_map.clone(), + link_policy: globals.local_link_policy.map(Into::into), + ephemeral: globals.local_ephemeral, + }, fallback: if globals.no_mirrors { FallbackPolicy::Disabled } else if globals.trust_mirror_metadata { @@ -414,7 +424,11 @@ pub(crate) fn with_manifest_override( let prefetch_cfg = INSTALL_PREFETCH_CONFIG.with(|slot| slot.borrow().clone()); let result = match prefetch_cfg { Some(cfg) => { - let prepared = crate::install_graph::prepare(&project, &cfg)?; + let prepared = crate::install_graph::prepare( + &project, + &cfg, + crate::local_registry::LocalRegistryMode::from_env()?, + )?; with_resolved_requirements(&project, prepared.exact_requirements(), operation) } None => operation(), @@ -609,6 +623,7 @@ url = "https://localhost/manifestless/consumer" interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), }; with_install_prefetch(&cfg, || { @@ -640,6 +655,9 @@ url = "https://localhost/manifestless/consumer" r2_public_base: None, r2_public_key: None, source_fallback: true, + local_path_map: None, + local_link_policy: None, + local_ephemeral: false, }; let cfg = Config::from_globals(&globals).unwrap(); @@ -743,6 +761,7 @@ url = "https://localhost/manifestless/consumer" interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), }; assert_eq!( explicit.resolve_token().unwrap().as_deref(), @@ -768,6 +787,7 @@ url = "https://localhost/manifestless/consumer" interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), }; assert_eq!(unknown_registry.resolve_token().unwrap(), None); } @@ -786,6 +806,7 @@ url = "https://localhost/manifestless/consumer" interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), }; // A corrupt file must degrade to "no saved token", not a panic/err. assert_eq!(cfg.resolve_token().unwrap(), None); diff --git a/src/dev.rs b/src/dev.rs index 2c70bcf7d..ea90777ca 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/fetch.rs b/src/fetch.rs index ea054aa57..99d774667 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -881,6 +881,7 @@ mod tests { interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), } } diff --git a/src/git_submodules/tests.rs b/src/git_submodules/tests.rs index f5d890c2c..cf1203651 100644 --- a/src/git_submodules/tests.rs +++ b/src/git_submodules/tests.rs @@ -242,6 +242,7 @@ fn overtake_imports_manifest_workspace_and_git_lock() { interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), }; let report = overtake(root.path(), &cfg).unwrap(); assert_eq!(report.adopted, 1); @@ -354,6 +355,7 @@ url = "https://example.invalid/acme/root.git" interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), }; let error = overtake(root.path(), &cfg).unwrap_err(); assert!( diff --git a/src/global.rs b/src/global.rs index 988c7b8b0..5984209b6 100644 --- a/src/global.rs +++ b/src/global.rs @@ -1194,6 +1194,7 @@ mod tests { interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), } } diff --git a/src/install_graph.rs b/src/install_graph.rs index 277df23e5..d374dc973 100644 --- a/src/install_graph.rs +++ b/src/install_graph.rs @@ -24,6 +24,7 @@ use zed_interfaces::version; use zed_lock::{LockClass, LockGuard, LockManager, LockRequest}; use crate::config::{Config, read_manifest}; +use crate::local_registry::{LocalIndex, LocalRegistryMode}; use crate::mirrored_registry::{MirrorContext, TrustAnchors}; use crate::pack::sha256_file; use crate::registry::Registry; @@ -227,4 +228,4 @@ mod tests; pub(crate) use artifact::ensure_artifact; use artifact::worker_loop; pub use resolver::prefetch; -pub(crate) use resolver::prepare; +pub(crate) use resolver::{prefetch_with_mode, prepare}; diff --git a/src/install_graph/resolver.rs b/src/install_graph/resolver.rs index 11ab7cf59..f918d2825 100644 --- a/src/install_graph/resolver.rs +++ b/src/install_graph/resolver.rs @@ -5,16 +5,30 @@ use super::*; /// Solve the complete active constraint set before the transactional installer /// mutates project output. Candidate manifests are still loaded through the /// bounded worker pool and the shared per-artifact process locks. -pub(crate) fn prepare(project: &Path, cfg: &Config) -> Result { +pub(crate) fn prepare( + project: &Path, + cfg: &Config, + local_mode: LocalRegistryMode, +) -> Result { let concurrency = install_concurrency(); let context = cfg.mirror_context(project_trust_anchors(project)); let registry = context.open(&cfg.registry)?; let manifest = read_manifest(project)?; + // Solving is where the network would be contacted, so the local registry + // has to be visible here and not only to the materializer downstream. + let local = local_index_for_solving(cfg, local_mode)?; let prepared = if manifest.dependencies.is_empty() { PreparedInstall::default() } else { run_with_pool(cfg, &context, concurrency, |pool| { - solve_install(project, &manifest, registry.as_ref(), pool) + solve_install( + project, + &manifest, + registry.as_ref(), + pool, + local.as_ref(), + local_mode, + ) })? }; report_prefetch(prepared.report, concurrency); @@ -25,16 +39,39 @@ pub(crate) fn prepare(project: &Path, cfg: &Config) -> Result { /// same complete graph returned to the normal installer facade. Frozen replay /// remains lock-authoritative and never re-solves or rewrites the graph. pub fn prefetch(project: &Path, cfg: &Config, frozen: bool) -> Result { + prefetch_with_mode(project, cfg, frozen, LocalRegistryMode::from_env()?) +} + +/// [`prefetch`] with the dependency-source policy passed explicitly, for +/// callers that already hold one instead of inheriting it from the process +/// environment. +pub(crate) fn prefetch_with_mode( + project: &Path, + cfg: &Config, + frozen: bool, + local_mode: LocalRegistryMode, +) -> Result { if !frozen { - return prepare(project, cfg).map(|prepared| prepared.report); + return prepare(project, cfg, local_mode).map(|prepared| prepared.report); } + // Frozen replay is lock-authoritative: the lockfile never pins a live + // source link, so there is nothing here for the local registry to answer. let concurrency = install_concurrency(); let report = prefetch_locked(project, cfg, concurrency)?; report_prefetch(report, concurrency); Ok(report) } +fn local_index_for_solving(cfg: &Config, mode: LocalRegistryMode) -> Result> { + if !mode.enabled() { + return Ok(None); + } + crate::local_registry::load(cfg) + .context("reading the local project registry") + .map(Some) +} + fn report_prefetch(report: PrefetchReport, concurrency: usize) { if report.resolved > 0 { eprintln!( diff --git a/src/install_graph/solver.rs b/src/install_graph/solver.rs index 3388fb059..2549ba50e 100644 --- a/src/install_graph/solver.rs +++ b/src/install_graph/solver.rs @@ -1,5 +1,6 @@ use super::artifact::{split_key, validate_version_identity}; use super::*; +use crate::local_registry::{self, LocalIndex}; use zed_interfaces::registry::PackageMetadata; use zed_interfaces::version::VersionScheme; use zed_lib::requirement_matches; @@ -463,6 +464,73 @@ fn exact_requirement(scheme: VersionScheme, raw: &str) -> String { struct GraphSolver<'a, S> { source: &'a mut S, workspace: &'a SolverWorkspace, + /// Machine-local project registrations, when this install may use them. + /// They are resolved lazily — only for keys the solver actually reaches — + /// so an ambiguous or broken registration for an unrelated package never + /// affects this graph. + local: Option<&'a LocalIndex>, + /// How much authority those registrations have. The solver is where the + /// network would first be contacted, so `only` has to be enforced here. + local_mode: LocalRegistryMode, + /// Keys whose local-registry situation has already been reported, so a + /// backtracking search cannot repeat the same diagnostic. + warned: BTreeSet, +} + +impl GraphSolver<'_, S> { + /// The local project that satisfies every accumulated constraint on `key`, + /// expressed in the same shape as a workspace member: both are live source + /// the solver must not try to download. + /// + /// A registration that satisfies nothing returns `None` and the key falls + /// through to the registry, matching the installer's own rule. + fn local_member( + &mut self, + key: &str, + constraints: &[Constraint], + ) -> Result> { + let Some(index) = self.local else { + return Ok(None); + }; + let (selection, skipped) = local_registry::select_with(index, key, |manifest| { + constraints.iter().all(|constraint| { + requirement_matches( + manifest.package.version_scheme, + &constraint.requirement, + &manifest.package.version, + ) + }) + })?; + let first_report = self.warned.insert(key.to_string()); + if first_report { + for (entry, health) in &skipped { + eprintln!( + "warning: local registration of {key} at {} is unusable: {}", + entry.path, + health.label() + ); + } + } + if selection.is_none() { + if first_report && !index.candidates_for(key).is_empty() { + eprintln!( + "note: {key} is registered locally but no registration satisfies \ + the requirements on it; falling back to the configured registry" + ); + } + if self.local_mode.requires_local() { + bail!( + "--local-registry=only: no registered local project satisfies {key}; \ + register one with `zed local register `" + ); + } + } + Ok(selection.map(|selection| WorkspaceMember { + version: selection.manifest.package.version.clone(), + scheme: selection.manifest.package.version_scheme, + dependencies: selection.manifest.dependencies.clone(), + })) + } } impl GraphSolver<'_, S> { @@ -493,6 +561,13 @@ impl GraphSolver<'_, S> { return self.solve(state); } + // Workspace members outrank registrations: they are declared by the + // tree being installed, while a registration is machine-global state. + if let Some(member) = self.local_member(&key, &constraints)? { + state.workspace.insert(key, member); + return self.solve(state); + } + let (org, name) = split_key(&key)?; let package = self.source.package(&org, &name)?; let mut versions = package.versions.clone(); @@ -550,6 +625,13 @@ impl GraphSolver<'_, S> { { continue; } + // Prefetching a key the local registry will satisfy would contact + // the registry for a package this install never downloads — and in + // `only` mode reaching the registry at all is the error. + let constraints = constraints.clone(); + if self.local_member(key, &constraints)?.is_some() { + continue; + } let (org, name) = split_key(key)?; let package = self.source.package(&org, &name)?; @@ -693,6 +775,8 @@ pub(super) fn solve_install( manifest: &Manifest, registry: &dyn Registry, pool: &FetchPool, + local: Option<&LocalIndex>, + local_mode: LocalRegistryMode, ) -> Result { if manifest.dependencies.is_empty() { return Ok(PreparedInstall::default()); @@ -724,6 +808,9 @@ pub(super) fn solve_install( let outcome = GraphSolver { source: &mut source, workspace: &workspace, + local, + local_mode, + warned: BTreeSet::new(), } .solve(state)?; @@ -872,6 +959,9 @@ mod tests { let outcome = GraphSolver { source, workspace: &workspace, + local: None, + local_mode: LocalRegistryMode::Off, + warned: BTreeSet::new(), } .solve(state)?; match outcome { diff --git a/src/install_graph/tests.rs b/src/install_graph/tests.rs index 589f21169..b3aae4249 100644 --- a/src/install_graph/tests.rs +++ b/src/install_graph/tests.rs @@ -26,6 +26,7 @@ fn test_config(registry: &Path, home: &Path) -> Config { interactive: false, mirrors: Vec::new(), fallback: crate::mirrored_registry::FallbackPolicy::Disabled, + local: Default::default(), } } diff --git a/src/lib.rs b/src/lib.rs index 58d8a512f..5f063bc07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,7 +28,9 @@ pub mod inspect; pub mod install_graph; pub mod interactive; pub mod lifecycle; +pub mod local_cli; pub(crate) mod local_overrides; +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..082f66e0d --- /dev/null +++ b/src/local_cli.rs @@ -0,0 +1,359 @@ +//! 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, LinkPolicy, 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, + /// Which kind of volume the checkout lives on, as recorded at + /// registration: `fixed`, `removable`, `network`, `container-mount`. + volume: String, + /// The volume root, when one is known — the directory whose presence + /// answers "is this disk still attached?". + #[serde(skip_serializing_if = "Option::is_none")] + mount_point: Option, + /// This entry's own materialization preference. + link_policy: String, + /// The path as the index literally holds it, when a path map rewrote it + /// for this process. Absent when the two are the same. + #[serde(skip_serializing_if = "Option::is_none")] + stored_path: Option, +} + +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(), + volume: status.entry.volume.kind.as_str().to_string(), + mount_point: status.entry.volume.mount_point.clone(), + link_policy: status.entry.link_policy.as_str().to_string(), + stored_path: status.entry.stored_path.clone(), + } + } +} + +/// What `zed local doctor` reports: this machine's whole view, as data, so a +/// test can assert on it without parsing prose. +#[derive(Debug, Serialize)] +struct DoctorReport { + index: String, + in_container: bool, + link_policy: String, + ephemeral: bool, + path_map: Vec, + entries: Vec, +} + +#[derive(Debug, Serialize)] +struct PathMapRule { + from: String, + to: String, +} + +#[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, + link, + } => { + let target = resolve_path(cwd, path); + let (action, entry) = + local_registry::register(cfg, &target, priority, !disabled, link.map(Into::into))?; + let verb = match action { + RegisterAction::Added => "registered", + RegisterAction::Updated => "refreshed", + }; + println!("{verb} {}@{} -> {}", entry.key(), entry.version, entry.path); + println!("id: {}", entry.id()); + println!("volume: {}", entry.volume.kind.as_str()); + if !entry.enabled { + println!("note: entry is disabled and will not be selected"); + } + if entry.volume.kind.is_ephemeral() && entry.link_policy == LinkPolicy::Auto { + println!( + "note: {} media can go away, so installs copy this checkout instead of \ + symlinking it (override with --local-link-policy symlink)", + entry.volume.kind.as_str() + ); + } + 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(()) + } + LocalCmd::Doctor { json } => doctor(cfg, json), + } +} + +/// Everything that determines whether a registration is usable *here*: where +/// the index is, whether this process is inside a container, how paths are +/// translated across that boundary, and what each entry's volume looks like +/// right now. One command to answer "why did my install go to the network?". +fn doctor(cfg: &Config, json: bool) -> Result<()> { + let map = cfg.local.resolved_path_map()?; + let report = DoctorReport { + index: local_registry::index_path(cfg)?.display().to_string(), + in_container: local_registry::in_container(), + link_policy: cfg.local.resolved_link_policy()?.as_str().to_string(), + ephemeral: cfg.local.resolved_ephemeral(), + path_map: map + .rules() + .map(|(from, to)| PathMapRule { from, to }) + .collect(), + entries: local_registry::status(cfg)? + .iter() + .map(EntryReport::new) + .collect(), + }; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + println!("index {}", report.index); + println!( + "container {}", + if report.in_container { "yes" } else { "no" } + ); + println!("link policy {}", report.link_policy); + println!( + "ephemeral {}", + if report.ephemeral { + "yes (every checkout is copied)" + } else { + "no" + } + ); + if report.path_map.is_empty() { + println!("path map (none)"); + } else { + for rule in &report.path_map { + println!("path map {} -> {}", rule.from, rule.to); + } + } + println!("entries {}", report.entries.len()); + for entry in &report.entries { + println!( + " {:<9} {} {} [{}]", + if entry.selectable { "ok" } else { "unusable" }, + entry.package, + entry.path, + entry.volume + ); + if let Some(stored) = &entry.stored_path { + println!(" stored as {stored}"); + } + if !entry.selectable { + println!(" {}", entry.health); + } + } + 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..2edd80e3f --- /dev/null +++ b/src/local_registry.rs @@ -0,0 +1,2547 @@ +//! 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::{Component, 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, + /// Where this checkout lives, recorded at registration. Drives the + /// unavailable-versus-missing distinction and the link decision. + #[serde(default)] + pub volume: VolumeInfo, + /// This entry's preference for how it reaches `zed_modules/`. A process + /// wide `--local-link-policy` overrides it. + #[serde(default)] + pub link_policy: LinkPolicy, + /// Set when a path map rewrote this entry on load: the form the index + /// actually holds, so a save maps it back and a report can show both. + #[serde(skip)] + pub stored_path: 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() + } +} + +// --------------------------------------------------------------------------- +// portability: volumes, containers, and how a checkout reaches zed_modules/ +// +// A registered path routinely lives somewhere less permanent than `$HOME`: an +// external or virtual disk that is mounted today and gone tomorrow, a +// container bind mount where the same bytes have a different absolute path on +// each side, or a build mount that exists for one `RUN` step and is absent +// from the resulting image. Three mechanisms cover those, and all three are +// pure filesystem logic — nothing here opens a socket: +// +// 1. [`PathMap`] rewrites a registered path between host and container views, +// so one index file can be shared through the very bind mount it describes. +// 2. [`VolumeKind`] records where an entry lives, so a lookup can say +// "temporarily unavailable" instead of "deleted" when a disk is unplugged. +// 3. [`LinkPolicy`] refuses to leave a symlink pointing at media that will not +// be there later: removable disks and build mounts are copied, never +// linked, so the installed tree survives the mount going away. + +/// `host=container` path rewrites, separated by `,` (or `;`). +pub const PATH_MAP_ENV: &str = "ZED_PKG_LOCAL_REGISTRY_PATH_MAP"; +/// Force a link policy (`auto`, `symlink`, `copy`). +pub const LINK_POLICY_ENV: &str = "ZED_PKG_LOCAL_LINK_POLICY"; +/// Treat every registration as living on media that will not outlive this +/// process, so nothing is symlinked. Container image builds set this. +pub const EPHEMERAL_ENV: &str = "ZED_PKG_LOCAL_REGISTRY_EPHEMERAL"; + +/// Where a registered checkout physically lives. +/// +/// Advisory metadata: it steers link policy and produces a good diagnostic, and +/// a wrong guess degrades to the conservative choice (copy) rather than to a +/// wrong install. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum VolumeKind { + /// An ordinary internal disk: present whenever the machine is. + Fixed, + /// A removable or virtual disk (USB, external SSD, mounted disk image, + /// `/Volumes/*` on macOS, `/media|/mnt/*` on Linux). May vanish. + Removable, + /// A network filesystem (NFS, SMB, sshfs, 9p). May become unreachable. + Network, + /// A container bind mount or volume: the same bytes are reachable under a + /// different path on the host, and may not exist in a later image layer. + ContainerMount, + /// Could not be determined. + #[default] + Unknown, +} + +impl VolumeKind { + /// Media that may disappear between this install and the next process that + /// walks `zed_modules/`. A symlink into such media is a latent breakage. + pub fn is_ephemeral(self) -> bool { + matches!(self, Self::Removable | Self::Network | Self::ContainerMount) + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Fixed => "fixed", + Self::Removable => "removable", + Self::Network => "network", + Self::ContainerMount => "container-mount", + Self::Unknown => "unknown", + } + } +} + +/// Recorded provenance of the volume an entry lives on. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct VolumeInfo { + #[serde(default)] + pub kind: VolumeKind, + /// Longest mount point that is a prefix of the entry path, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mount_point: Option, + /// Filesystem type reported by the mount table, when there is one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fs_type: Option, +} + +/// Are we executing inside a container? Used only to pick better defaults and +/// to explain a lookup; never to change what a path means. +pub fn in_container() -> bool { + if std::env::var_os("ZED_PKG_IN_CONTAINER").is_some() { + return true; + } + if Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() { + return true; + } + fs::read_to_string("/proc/1/cgroup").is_ok_and(|cgroups| { + cgroups.lines().any(|line| { + line.contains("docker") || line.contains("containerd") || line.contains("kubepods") + }) + }) +} + +#[derive(Debug, Clone)] +struct MountEntry { + mount_point: PathBuf, + fs_type: String, + source: String, +} + +/// `/proc/self/mountinfo` is the only table parsed: it is present on Linux +/// (including every container runtime that matters here) and its shape is +/// stable. Elsewhere this returns nothing and the prefix heuristics below take +/// over. +fn read_mount_table() -> Vec { + let Ok(text) = fs::read_to_string("/proc/self/mountinfo") else { + return Vec::new(); + }; + text.lines() + .filter_map(|line| { + // id parent maj:min root mountpoint opts [tags] - fstype source sopts + let (before, after) = line.split_once(" - ")?; + let mount_point = before.split_whitespace().nth(4)?; + let mut tail = after.split_whitespace(); + let fs_type = tail.next()?.to_string(); + let source = tail.next().unwrap_or_default().to_string(); + Some(MountEntry { + mount_point: PathBuf::from(unescape_mount_field(mount_point)), + fs_type, + source: unescape_mount_field(&source), + }) + }) + .collect() +} + +/// mountinfo escapes space, tab, newline and backslash as three octal digits. +fn unescape_mount_field(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '\\' { + out.push(ch); + continue; + } + let digits: String = (0..3) + .filter_map(|_| chars.next_if(|c| ('0'..='7').contains(c))) + .collect(); + match u32::from_str_radix(&digits, 8) + .ok() + .filter(|_| digits.len() == 3) + { + Some(code) => out.push(char::from_u32(code).unwrap_or('\\')), + None => { + out.push('\\'); + out.push_str(&digits); + } + } + } + out +} + +fn longest_mount_prefix(mounts: &[MountEntry], path: &Path) -> Option { + mounts + .iter() + .filter(|mount| path.starts_with(&mount.mount_point)) + .max_by_key(|mount| mount.mount_point.as_os_str().len()) + .cloned() +} + +fn kind_for_mount(mount_point: &Path, fs_type: &str, source: &str) -> VolumeKind { + const NETWORK_FS: &[&str] = &[ + "nfs", + "nfs4", + "cifs", + "smbfs", + "smb3", + "afs", + "fuse.sshfs", + "fuse.rclone", + "afpfs", + "webdav", + "fuse.davfs", + ]; + // virtiofs/9p are how Docker Desktop, Lima, and Colima surface host + // directories into the VM; grpcfuse is Docker Desktop's osxfs successor. + // A path on one of those is host storage seen across a container boundary. + const CONTAINER_HOST_FS: &[&str] = &["virtiofs", "9p", "fuse.grpcfuse", "grpcfuse", "osxfs"]; + + let lowered = fs_type.to_ascii_lowercase(); + if CONTAINER_HOST_FS.contains(&lowered.as_str()) { + return VolumeKind::ContainerMount; + } + if NETWORK_FS.contains(&lowered.as_str()) { + return VolumeKind::Network; + } + if lowered == "overlay" || lowered == "tmpfs" || lowered == "ramfs" { + // An overlay at `/` is the container's own root filesystem, fixed for + // the life of the container. A tmpfs anywhere, and an overlay mounted + // below the root, are scratch space. + return if lowered == "overlay" && mount_point == Path::new("/") { + if in_container() { + VolumeKind::Fixed + } else { + VolumeKind::Unknown + } + } else { + VolumeKind::ContainerMount + }; + } + // A bind mount from the host has an ordinary filesystem type but a mount + // point the runtime created, so inside a container any non-root mount that + // is not system plumbing is treated as one. + if in_container() + && mount_point != Path::new("/") + && !is_system_mount_point(mount_point) + && !source.starts_with("udev") + { + return VolumeKind::ContainerMount; + } + match kind_for_prefix(mount_point) { + VolumeKind::Unknown => VolumeKind::Fixed, + other => other, + } +} + +fn is_system_mount_point(mount_point: &Path) -> bool { + const SYSTEM_PREFIXES: &[&str] = &[ + "/proc", + "/sys", + "/dev", + "/run", + "/etc/hosts", + "/etc/hostname", + "/etc/resolv.conf", + ]; + SYSTEM_PREFIXES + .iter() + .any(|prefix| mount_point.starts_with(prefix)) +} + +/// Classify from well-known layout alone. Deliberately biased toward +/// `Removable`: mistaking a fixed disk for a removable one costs one copy, +/// while the opposite mistake costs a broken project. +fn kind_for_prefix(path: &Path) -> VolumeKind { + const REMOVABLE_PREFIXES: &[&str] = &["/Volumes", "/media", "/mnt", "/run/media", "/vol"]; + for prefix in REMOVABLE_PREFIXES { + if path.starts_with(prefix) && path != Path::new(prefix) { + return VolumeKind::Removable; + } + } + if path.starts_with("/System/Volumes/Data") + || path.starts_with("/home") + || path.starts_with("/Users") + || path.starts_with("/root") + { + return VolumeKind::Fixed; + } + VolumeKind::Unknown +} + +/// The volume root for a path under a conventional mount directory: +/// `/Volumes/