diff --git a/.cli-flags.toml b/.cli-flags.toml index ca4db4e34..740dabc1e 100644 --- a/.cli-flags.toml +++ b/.cli-flags.toml @@ -524,7 +524,7 @@ help = "Print the installed dependency graph as a tree." [commands.tree.flags.tree_depth] env = "ZED_PKG_TREE_DEPTH" aliases = ["depth"] -type = "int" +type = "integer" help = "Levels below the root to print; omit for the whole graph." [commands.tree.flags.tree_json] diff --git a/.github/workflows/github-control-plane-outage.yml b/.github/workflows/github-control-plane-outage.yml new file mode 100644 index 000000000..bb73a94d4 --- /dev/null +++ b/.github/workflows/github-control-plane-outage.yml @@ -0,0 +1,234 @@ +name: GitHub control-plane outage fallback + +on: + pull_request: + paths: + - 'src/source_fallback.rs' + - 'src/registry.rs' + - 'src/fetch.rs' + - 'src/store.rs' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/github-control-plane-outage.yml' + - 'docs/github-control-plane-outage.md' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: github-control-plane-outage-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +env: + # The registry cannot resolve; the R2 endpoint is a local sentinel that logs + # any request. A successful GitHub-first restore must leave that log with no + # GETs, proving Zed/Cloudflare infrastructure is not consulted first. + DEAD_REGISTRY: https://zed-control-plane-intentionally-unavailable.invalid + DEAD_R2: http://127.0.0.1:18081 + +jobs: + github-direct: + name: GitHub is sufficient when Zed infrastructure is gone + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Check out exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install pinned Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: '1.98.1' + + - name: Build exact candidate with locked dependencies + run: | + set -Eeuo pipefail + cargo build --locked --release --bin zed + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + + - name: Run focused fallback security and archive tests + run: | + set -Eeuo pipefail + cargo test --locked source_fallback::tests:: -- --nocapture + + - name: Start fail-if-used R2 sentinel + run: | + set -Eeuo pipefail + mkdir -p "$RUNNER_TEMP/dead-r2-root" + : > "$RUNNER_TEMP/r2-sentinel.log" + python3 -m http.server 18081 \ + --bind 127.0.0.1 \ + --directory "$RUNNER_TEMP/dead-r2-root" \ + >"$RUNNER_TEMP/r2-sentinel.log" 2>&1 & + echo "$!" > "$RUNNER_TEMP/r2-sentinel.pid" + sleep 1 + kill -0 "$(cat "$RUNNER_TEMP/r2-sentinel.pid")" + + - name: Create five-language consumer + run: | + set -Eeuo pipefail + mkdir -p "$RUNNER_TEMP/github-only-consumer" + cat > "$RUNNER_TEMP/github-only-consumer/.zpkg.toml" <<'EOF' + [package] + org = "zed-pkg-test" + name = "github-only-outage-consumer" + version = "0.0.0" + description = "Proves zed-cli can install with every Zed-owned network service unavailable" + + [package.repository] + vcs = "git" + url = "https://github.com/zed-pkg-test/github-api-fallback-canary" + + [install] + dir = "zed_modules" + adapter = "none" + + [dependencies] + "zed-pkg-test/go-lib" = "=1.0.0" + "zed-pkg-test/python-lib" = "=1.0.0" + "zed-pkg-test/dart-lib" = "=1.0.0" + "zed-pkg-test/node-lib" = "=1.0.0" + "zed-pkg-test/gleam-lib" = "=1.0.0" + EOF + + - name: Install with registry down, R2 forbidden, and no GitHub token + working-directory: ${{ runner.temp }}/github-only-consumer + env: + ZED_PKG_SOURCE_FALLBACK: 'true' + ZED_PKG_R2_PUBLIC_BASE: ${{ env.DEAD_R2 }} + run: | + set -Eeuo pipefail + ZED="$GITHUB_WORKSPACE/target/release/zed" + unset GITHUB_TOKEN GH_TOKEN ZED_PKG_GITHUB_TOKEN || true + "$ZED" --registry "$DEAD_REGISTRY" --home "$RUNNER_TEMP/zed-home-a" install \ + --install-mode copy --adapter none --allow-ecosystem-mismatch \ + 2>&1 | tee "$RUNNER_TEMP/github-only-a.log" + + test "$(grep -E -c 'using GitHub(-first)? fallback' "$RUNNER_TEMP/github-only-a.log")" -ge 5 + test -s .zpkg.lock + for name in go-lib python-lib dart-lib node-lib gleam-lib; do + grep -R -q "name = \"$name\"" zed_modules + done + cp .zpkg.lock "$RUNNER_TEMP/github-only.lock" + + - name: Prove clean-store resolution is deterministic + working-directory: ${{ runner.temp }}/github-only-consumer + env: + ZED_PKG_SOURCE_FALLBACK: 'true' + ZED_PKG_R2_PUBLIC_BASE: ${{ env.DEAD_R2 }} + run: | + set -Eeuo pipefail + ZED="$GITHUB_WORKSPACE/target/release/zed" + unset GITHUB_TOKEN GH_TOKEN ZED_PKG_GITHUB_TOKEN || true + rm -rf zed_modules .zpkg.lock "$RUNNER_TEMP/zed-home-b" + "$ZED" --registry "$DEAD_REGISTRY" --home "$RUNNER_TEMP/zed-home-b" install \ + --install-mode copy --adapter none --allow-ecosystem-mismatch \ + 2>&1 | tee "$RUNNER_TEMP/github-only-b.log" + cmp "$RUNNER_TEMP/github-only.lock" .zpkg.lock + + - name: Prove frozen replay works from another empty store + working-directory: ${{ runner.temp }}/github-only-consumer + env: + ZED_PKG_SOURCE_FALLBACK: 'true' + ZED_PKG_R2_PUBLIC_BASE: ${{ env.DEAD_R2 }} + run: | + set -Eeuo pipefail + ZED="$GITHUB_WORKSPACE/target/release/zed" + unset GITHUB_TOKEN GH_TOKEN ZED_PKG_GITHUB_TOKEN || true + rm -rf zed_modules "$RUNNER_TEMP/zed-home-frozen" + "$ZED" --registry "$DEAD_REGISTRY" --home "$RUNNER_TEMP/zed-home-frozen" install --frozen \ + --install-mode copy --adapter none --allow-ecosystem-mismatch \ + 2>&1 | tee "$RUNNER_TEMP/github-only-frozen.log" + cmp "$RUNNER_TEMP/github-only.lock" .zpkg.lock + test "$(find zed_modules -name .zpkg.toml -type f | wc -l)" -ge 5 + + - name: Prove successful GitHub recovery did not touch R2 or Cloudflare + run: | + set -Eeuo pipefail + log="$RUNNER_TEMP/github-only-a.log" + test -s "$log" + grep -E -q 'using GitHub(-first)? fallback' "$log" + if grep -q 'GET /' "$RUNNER_TEMP/r2-sentinel.log"; then + echo 'Successful GitHub recovery unexpectedly contacted the configured R2/CDN endpoint:' >&2 + cat "$RUNNER_TEMP/r2-sentinel.log" >&2 + exit 1 + fi + ! grep -E 'success.*(zpkg\.net|cdn\.zpkg\.net|cloudflare)' "$log" + # Negative tests below intentionally exercise failure paths that may + # probe configured mirrors. Reset the sentinel so they cannot taint + # the positive recovery assertion above. + : > "$RUNNER_TEMP/r2-sentinel.log" + + - name: Prove fallback is required rather than accidental cache reuse + working-directory: ${{ runner.temp }}/github-only-consumer + env: + ZED_PKG_SOURCE_FALLBACK: 'false' + ZED_PKG_R2_PUBLIC_BASE: ${{ env.DEAD_R2 }} + run: | + set -Eeuo pipefail + ZED="$GITHUB_WORKSPACE/target/release/zed" + unset GITHUB_TOKEN GH_TOKEN ZED_PKG_GITHUB_TOKEN || true + rm -rf zed_modules "$RUNNER_TEMP/zed-home-no-fallback" + set +e + "$ZED" --registry "$DEAD_REGISTRY" --home "$RUNNER_TEMP/zed-home-no-fallback" install --frozen \ + --install-mode copy --adapter none --allow-ecosystem-mismatch \ + >"$RUNNER_TEMP/no-fallback.log" 2>&1 + status=$? + set -e + cat "$RUNNER_TEMP/no-fallback.log" + test "$status" -ne 0 + + - name: Reject a GitHub tag whose manifest claims another package identity + run: | + set -Eeuo pipefail + mkdir -p "$RUNNER_TEMP/github-negative" + cat > "$RUNNER_TEMP/github-negative/.zpkg.toml" <<'EOF' + [package] + org = "zed-pkg-test" + name = "github-negative-consumer" + version = "0.0.0" + description = "Fail-closed GitHub identity canary" + + [package.repository] + vcs = "git" + url = "https://github.com/zed-pkg-test/github-api-fallback-canary" + + [install] + dir = "zed_modules" + adapter = "none" + + [dependencies] + "zed-pkg-test/awkward-lib" = "=0.1.0" + EOF + + ZED="$GITHUB_WORKSPACE/target/release/zed" + unset GITHUB_TOKEN GH_TOKEN ZED_PKG_GITHUB_TOKEN || true + set +e + ( + cd "$RUNNER_TEMP/github-negative" + ZED_PKG_SOURCE_FALLBACK=true ZED_PKG_R2_PUBLIC_BASE="$DEAD_R2" \ + "$ZED" --registry "$DEAD_REGISTRY" --home "$RUNNER_TEMP/zed-home-negative" install \ + --install-mode copy --adapter none --allow-ecosystem-mismatch + ) >"$RUNNER_TEMP/github-negative.log" 2>&1 + status=$? + set -e + cat "$RUNNER_TEMP/github-negative.log" + test "$status" -ne 0 + grep -F 'does not self-claim package zed-pkg-test/awkward-lib' "$RUNNER_TEMP/github-negative.log" + + - name: Stop R2 sentinel + if: always() + run: | + set -Eeuo pipefail + if test -s "$RUNNER_TEMP/r2-sentinel.pid"; then + kill "$(cat "$RUNNER_TEMP/r2-sentinel.pid")" || true + fi diff --git a/docs/github-control-plane-outage.md b/docs/github-control-plane-outage.md new file mode 100644 index 000000000..30d6dfca3 --- /dev/null +++ b/docs/github-control-plane-outage.md @@ -0,0 +1,59 @@ +# GitHub-only recovery when the Zed control plane is unavailable + +`zed` must continue resolving and installing already-published public packages when every Zed-operated network service is unavailable. `registry.zpkg.net`, `cdn.zpkg.net`, Cloudflare Workers, R2, and application servers are acceleration and control-plane services; they are not required availability dependencies for public package restore when GitHub contains enough provenance to reconstruct the package. + +## Recovery contract + +The normal path remains the configured registry. When an HTTP registry request fails and source fallback is enabled, `zed` may reconstruct package metadata from GitHub and fetch package bytes from GitHub-owned surfaces. GitHub is treated as an independent content/repository recovery plane, not as a new package-authority model. + +A public package is admissible through GitHub fallback only when the repository can be mapped unambiguously to the requested `org/name`, the committed `.zpkg.toml` self-claims that package identity, and the requested version is represented by an admissible version tag. Release assets, GHCR artifacts, and the GitHub tag archive are acceptable GitHub-hosted content sources. A tag archive is repacked using the same deterministic Zed package rules before its digest is recorded or compared. + +The fallback remains fail-closed. Repository-search ambiguity, a mismatched package identity, malformed or unsafe archives, missing required provenance, and digest/size mismatches are errors. Fallback never grants authority to an arbitrary repository merely because its name resembles the package name. + +## What must not be required + +The recovery path must succeed with all of the following unavailable at the same time: + +- the configured Zed HTTP registry; +- every `*.zpkg.net` endpoint; +- Cloudflare Workers, Pages, Tunnels, and other Zed-owned edge services; +- the Zed R2/CDN origin; and +- any Zed application or API server behind Cloudflare. + +The exact-head outage workflow configures a guaranteed-unresolvable registry plus a dead loopback R2 endpoint. It then removes `GITHUB_TOKEN`, `GH_TOKEN`, and `ZED_PKG_GITHUB_TOKEN` and restores public packages from GitHub. This proves the public recovery path does not rely on credentials or on a Zed-operated server. + +## GitHub as a recovery CDN + +For public packages, the GitHub recovery plane can use: + +1. `api.github.com` for repository identity, tags, releases, and tag-to-commit provenance; +2. `raw.githubusercontent.com` for the committed `.zpkg.toml` used for identity admission; +3. GitHub Releases and GitHub Packages/GHCR when a package publishes compatible artifacts there; and +4. GitHub/codeload tag archives as the final reconstructable source artifact. + +When a GitHub source succeeds, Zed-owned mirrors are not required for correctness. R2 remains an optional mirror for cases where GitHub is unavailable or incomplete; it must not be the sole recovery source for a package that can be reconstructed from GitHub. + +For private repositories, an explicit GitHub token may be used. Credentials are attached only to GitHub API requests that require them; redirects to codeload or unrelated mirror hosts must not receive the token. + +## Hermetic exceptions + +`file://` registries and loopback registries remain hermetic by default. Local tests, air-gapped mirrors, and deliberately isolated development environments must not leak package identities to GitHub. The explicit loopback fallback opt-in remains available for controlled canaries. + +Source fallback is a read/recovery capability. Operations that require Zed control-plane state or mutation—publishing, namespace claims, yanking, server-side search, and audit-log access—may still fail while the control plane is down. The outage guarantee is specifically resolution, download, deterministic reconstruction, install, and frozen restore of packages whose GitHub provenance is sufficient. + +## Acceptance gates + +The required outage canary must prove all of these conditions on the exact pull-request head: + +1. the configured registry is unreachable; +2. the configured R2/CDN endpoint is unreachable; +3. no GitHub token is available for the public-package test; +4. five package ecosystems resolve from GitHub source fallback; +5. the packages are actually materialized, not merely resolved; +6. a second empty store produces the identical lockfile; +7. a frozen restore succeeds from another empty store; +8. disabling source fallback makes the same outage fail instead of reusing an accidental cache; +9. a GitHub repository/tag with the wrong package identity is rejected; and +10. successful recovery has no dependency on a `zpkg.net` or Cloudflare response. + +These gates intentionally model a total Zed control-plane outage rather than a partial CDN degradation. \ No newline at end of file diff --git a/src/github_packages.rs b/src/github_packages.rs index b37399492..a7d422280 100644 --- a/src/github_packages.rs +++ b/src/github_packages.rs @@ -88,6 +88,7 @@ pub enum GhcrOutcome { #[derive(Debug)] struct Blob { digest: String, + #[allow(dead_code)] media_type: &'static str, bytes: Vec, } @@ -248,26 +249,56 @@ fn sha256_hex(bytes: &[u8]) -> String { hex::encode(Sha256::digest(bytes)) } -fn ghcr_bearer( +/// Exchange a GitHub PAT for a GHCR registry token. +/// +/// A Bearer PAT against `https://ghcr.io/token` is pull-only (or rejected on +/// `/v2`). Docker-style Basic `x-access-token:` is what GHCR accepts for +/// push and for authenticated pull. Do not fall back to the raw PAT as a +/// registry Bearer — GHCR returns `DENIED: invalid token`. +pub(crate) fn ghcr_registry_token( client: &reqwest::blocking::Client, github_token: &str, identity: &GithubIdentity, + access: &str, ) -> Result { - let scope = format!("repository:{}:push,pull", ghcr_repository(identity)); + let scope = format!("repository:{}:{access}", ghcr_repository(identity)); let response = client .get("https://ghcr.io/token") .query(&[("service", "ghcr.io"), ("scope", scope.as_str())]) - .bearer_auth(github_token) + .basic_auth("x-access-token", Some(github_token)) .send() .context("request GHCR registry token")?; - if response.status().is_success() + let status = response.status(); + if status.is_success() && let Ok(body) = response.json::() && let Some(token) = body.token.or(body.access_token) && !token.is_empty() { return Ok(token); } - Ok(github_token.to_string()) + bail!( + "GHCR token exchange for {} returned {status}", + ghcr_repository(identity) + ) +} + +fn ghcr_bearer( + client: &reqwest::blocking::Client, + github_token: &str, + identity: &GithubIdentity, +) -> Result { + ghcr_registry_token(client, github_token, identity, "push,pull") +} + +pub(crate) fn identity_from_ghcr_url(url: &str) -> Option { + let path = url.strip_prefix("https://ghcr.io/v2/")?; + let mut parts = path.split('/'); + let owner = parts.next()?.to_string(); + let repo = parts.next()?.to_string(); + if owner.is_empty() || repo.is_empty() { + return None; + } + Some(GithubIdentity { owner, repo }) } #[derive(Debug, serde::Deserialize)] @@ -291,8 +322,9 @@ fn upload_blob( if head.status().is_success() { return Ok(()); } + let upload_url = ghcr_uploads_url(identity); let start = client - .post(ghcr_uploads_url(identity)) + .post(&upload_url) .bearer_auth(token) .send() .with_context(|| format!("start GHCR blob upload {}", blob.digest))?; @@ -309,11 +341,12 @@ fn upload_blob( .and_then(|value| value.to_str().ok()) .map(str::to_string) .context("GHCR upload session missing Location")?; + let location = resolve_upload_location(&upload_url, &location)?; let put_url = with_digest_query(&location, &blob.digest); let put = client .put(&put_url) .bearer_auth(token) - .header("Content-Type", blob.media_type) + .header("Content-Type", "application/octet-stream") .body(blob.bytes.clone()) .send() .with_context(|| format!("upload GHCR blob {}", blob.digest))?; @@ -347,6 +380,18 @@ fn put_manifest( ) } +fn resolve_upload_location(base_url: &str, location: &str) -> Result { + if let Ok(url) = reqwest::Url::parse(location) { + return Ok(url.to_string()); + } + let base = reqwest::Url::parse(base_url) + .with_context(|| format!("parse GHCR upload base URL {base_url}"))?; + Ok(base + .join(location) + .with_context(|| format!("resolve GHCR upload Location {location:?} against {base_url}"))? + .to_string()) +} + fn with_digest_query(location: &str, digest: &str) -> String { let separator = if location.contains('?') { '&' } else { '?' }; format!("{location}{separator}digest={digest}") @@ -362,13 +407,30 @@ pub fn download_ghcr_layer( declared_size: u64, max_bytes: u64, ) -> Result<()> { + let registry_token = match (token, identity_from_ghcr_url(url)) { + (Some(github_token), Some(identity)) => Some(ghcr_registry_token( + client, + github_token, + &identity, + "pull", + )?), + (Some(github_token), None) => Some(github_token.to_string()), + (None, _) => None, + }; if url.contains("/blobs/") { - return download_blob(client, token, url, dest, declared_size, max_bytes); + return download_blob( + client, + registry_token.as_deref(), + url, + dest, + declared_size, + max_bytes, + ); } let mut request = client .get(url) .header("Accept", OCI_IMAGE_MANIFEST_MEDIA_TYPE); - if let Some(token) = token { + if let Some(token) = registry_token.as_deref() { request = request.bearer_auth(token); } let response = request.send().with_context(|| format!("GET {url}"))?; @@ -390,7 +452,14 @@ pub fn download_ghcr_layer( .next() .map(|prefix| format!("{prefix}/blobs/{digest}")) .context("GHCR manifest URL is not a registry manifest")?; - download_blob(client, token, &blob_url, dest, declared_size, max_bytes) + download_blob( + client, + registry_token.as_deref(), + &blob_url, + dest, + declared_size, + max_bytes, + ) } fn download_blob( @@ -448,6 +517,37 @@ mod tests { } } + #[test] + fn identity_from_ghcr_blob_url() { + let identity = identity_from_ghcr_url( + "https://ghcr.io/v2/zed-pkg-test/ghcr-fallback-canary/blobs/sha256:abc", + ) + .expect("parse"); + assert_eq!(identity.owner, "zed-pkg-test"); + assert_eq!(identity.repo, "ghcr-fallback-canary"); + assert!( + identity_from_ghcr_url("https://example.com/v2/acme/pkg/blobs/sha256:abc").is_none() + ); + } + + #[test] + fn upload_session_resolves_relative_location_against_registry_origin() { + let base = "https://ghcr.io/v2/acme/http-kit/blobs/uploads/"; + assert_eq!( + resolve_upload_location(base, "/v2/acme/http-kit/blobs/uploads/abc?_state=1").unwrap(), + "https://ghcr.io/v2/acme/http-kit/blobs/uploads/abc?_state=1" + ); + assert_eq!( + resolve_upload_location(base, "abc?_state=1").unwrap(), + "https://ghcr.io/v2/acme/http-kit/blobs/uploads/abc?_state=1" + ); + assert_eq!( + resolve_upload_location(base, "https://ghcr.io/v2/acme/http-kit/blobs/uploads/xyz") + .unwrap(), + "https://ghcr.io/v2/acme/http-kit/blobs/uploads/xyz" + ); + } + #[test] fn upload_session_appends_digest_query() { assert_eq!( diff --git a/src/source_fallback.rs b/src/source_fallback.rs index e2a5b2059..d6f42b8f0 100644 --- a/src/source_fallback.rs +++ b/src/source_fallback.rs @@ -1,10 +1,10 @@ -//! Retry GitHub and public R2 when the configured HTTP registry is unreachable. +//! Retry GitHub first, then optional public R2, when an HTTP registry is unreachable. //! //! Loopback `file://` and `http://127.0.0.1` registries stay hermetic: tests //! and air-gapped mirrors never leak to github.com. Production hosts such as -//! `registry.zpkg.net` fall back to guessed public R2 keys and GitHub Release -//! assets, then to the GitHub tag archive, repacked exactly as `zed publish` -//! would pack that tag. +//! `registry.zpkg.net` recover directly from GitHub Releases, GHCR, or a GitHub +//! tag archive repacked exactly as `zed publish` would pack that tag. Public R2 +//! remains a later optional mirror, never a required recovery dependency. use std::cell::RefCell; use std::collections::HashMap; @@ -12,6 +12,7 @@ use std::fs; use std::io::Read as _; use std::path::{Component, Path}; use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; @@ -73,6 +74,24 @@ fn max_artifact_bytes() -> u64 { .unwrap_or(DEFAULT_MAX_ARTIFACT_BYTES) } +/// During source fallback, prefer infrastructure owned by GitHub before +/// any Zed-operated R2/Cloudflare mirror. The normal registry path is +/// still attempted before this function is involved. +fn source_fallback_priority(kind: ArtifactSourceKind) -> u8 { + match kind { + ArtifactSourceKind::GithubRelease => 0, + ArtifactSourceKind::GithubPackages => 1, + ArtifactSourceKind::GithubArchive => 2, + ArtifactSourceKind::R2 => 3, + ArtifactSourceKind::Registry => 4, + } +} + +fn github_first_locators(mut locators: Vec) -> Vec { + locators.sort_by_key(|locator| source_fallback_priority(locator.kind)); + locators +} + #[derive(Debug, Clone)] pub struct SourceFallbackConfig { pub enabled: bool, @@ -116,6 +135,10 @@ pub struct FallbackRegistry { client: reqwest::blocking::Client, cache: Mutex>, github_identities: Mutex>, + github_repositories: Mutex>, + github_tag_cache: Mutex>>, + github_smart_ref_cache: Mutex>, + github_api_degraded: AtomicBool, } impl FallbackRegistry { @@ -138,6 +161,10 @@ impl FallbackRegistry { client, cache: Mutex::new(HashMap::new()), github_identities: Mutex::new(HashMap::new()), + github_repositories: Mutex::new(HashMap::new()), + github_tag_cache: Mutex::new(HashMap::new()), + github_smart_ref_cache: Mutex::new(HashMap::new()), + github_api_degraded: AtomicBool::new(false), }), Err(_) => Box::new(inner), } @@ -196,13 +223,31 @@ impl FallbackRegistry { github_api_repo_url(identity), metadata.vcs_tag ); + if !self.github_api_degraded.load(Ordering::Relaxed) { + match self.github_headers(self.client.get(url)).send() { + Ok(response) if response.status().is_success() => { + metadata.vcs_commit = response + .json::() + .ok() + .and_then(|commit| commit_sha(&commit.sha)); + if metadata.vcs_commit.is_some() { + return; + } + } + Ok(response) if github_api_status_is_degradable(response.status()) => { + self.github_api_degraded.store(true, Ordering::Relaxed); + } + Err(_) => { + self.github_api_degraded.store(true, Ordering::Relaxed); + } + _ => {} + } + } metadata.vcs_commit = self - .github_headers(self.client.get(url)) - .send() + .github_smart_refs(identity) .ok() - .filter(|response| response.status().is_success()) - .and_then(|response| response.json::().ok()) - .and_then(|commit| commit_sha(&commit.sha)); + .and_then(|refs| refs.tag_commits.get(&metadata.vcs_tag).cloned()) + .and_then(|sha| commit_sha(&sha)); } fn github_headers( @@ -246,20 +291,74 @@ impl FallbackRegistry { } fn github_repo(&self, identity: &GithubIdentity) -> Result { - let response = self - .github_headers(self.client.get(github_api_repo_url(identity))) - .send() - .with_context(|| format!("fetch GitHub repository {}", identity.web_url()))?; - if !response.status().is_success() { - bail!( + let key = github_identity_key(identity); + if let Some(repo) = self + .github_repositories + .lock() + .ok() + .and_then(|guard| guard.get(&key).cloned()) + { + return Ok(repo); + } + + let api_result = if self.github_api_degraded.load(Ordering::Relaxed) { + None + } else { + Some( + self.github_headers(self.client.get(github_api_repo_url(identity))) + .send(), + ) + }; + + let repo = match api_result { + Some(Ok(response)) if response.status().is_success() => response + .json() + .with_context(|| format!("decode GitHub repository {}", identity.web_url()))?, + Some(Ok(response)) if github_api_status_is_degradable(response.status()) => { + self.github_api_degraded.store(true, Ordering::Relaxed); + self.github_repo_via_smart_http(identity).with_context(|| { + format!( + "GitHub REST repository lookup for {} returned {}; smart-HTTP fallback also failed", + identity.web_url(), + response.status() + ) + })? + } + Some(Ok(response)) => bail!( "GitHub repository {} returned {}", identity.web_url(), response.status() - ); + ), + Some(Err(error)) => { + self.github_api_degraded.store(true, Ordering::Relaxed); + self.github_repo_via_smart_http(identity).with_context(|| { + format!( + "GitHub REST repository lookup for {} failed ({error}); smart-HTTP fallback also failed", + identity.web_url() + ) + })? + } + None => self.github_repo_via_smart_http(identity)?, + }; + + if let Ok(mut guard) = self.github_repositories.lock() { + guard.insert(key, repo.clone()); } - response - .json() - .with_context(|| format!("decode GitHub repository {}", identity.web_url())) + Ok(repo) + } + + fn github_repo_via_smart_http(&self, identity: &GithubIdentity) -> Result { + let refs = self.github_smart_refs(identity)?; + let default_branch = refs.default_branch.clone().with_context(|| { + format!( + "GitHub smart-HTTP advertisement for {} did not report HEAD's branch", + identity.web_url() + ) + })?; + Ok(GithubRepo { + default_branch, + html_url: identity.web_url(), + }) } fn github_manifest(&self, identity: &GithubIdentity, default_branch: &str) -> Result { @@ -461,11 +560,9 @@ impl FallbackRegistry { r2_public_base: self.config.r2_public_base.as_deref(), r2_public_key: self.config.r2_public_key.as_deref(), }; - let locators = artifact_locators(&query); + let locators = github_first_locators(artifact_locators(&query)); let download_url = locators - .iter() - .find(|locator| locator.kind != ArtifactSourceKind::GithubArchive) - .or_else(|| locators.last()) + .first() .map(|locator| locator.url.clone()) .context("no fallback locator")?; let mut metadata = VersionMetadata { @@ -519,25 +616,116 @@ impl FallbackRegistry { } fn github_tags(&self, identity: &GithubIdentity) -> Result> { - let response = self - .github_headers(self.client.get(github_api_tags_url(identity))) + let key = github_identity_key(identity); + if let Some(tags) = self + .github_tag_cache + .lock() + .ok() + .and_then(|guard| guard.get(&key).cloned()) + { + return Ok(tags); + } + + let api_result = if self.github_api_degraded.load(Ordering::Relaxed) { + None + } else { + Some( + self.github_headers(self.client.get(github_api_tags_url(identity))) + .send(), + ) + }; + + let tags = match api_result { + Some(Ok(response)) if response.status().is_success() => { + response.json().unwrap_or_default() + } + Some(Ok(response)) if github_api_status_is_degradable(response.status()) => { + self.github_api_degraded.store(true, Ordering::Relaxed); + self.github_tags_via_smart_http(identity).with_context(|| { + format!( + "GitHub REST tags for {} returned {}; smart-HTTP fallback also failed", + identity.web_url(), + response.status() + ) + })? + } + Some(Ok(response)) => { + let hint = if self.config.github_token.is_none() + && response.status() == reqwest::StatusCode::NOT_FOUND + { + " (repository may be private; private repositories need ZED_PKG_GITHUB_TOKEN, GITHUB_TOKEN, or GH_TOKEN)" + } else { + "" + }; + bail!( + "GitHub tags for {} returned {}{hint}", + identity.web_url(), + response.status() + ); + } + Some(Err(error)) => { + self.github_api_degraded.store(true, Ordering::Relaxed); + self.github_tags_via_smart_http(identity).with_context(|| { + format!( + "GitHub REST tags for {} failed ({error}); smart-HTTP fallback also failed", + identity.web_url() + ) + })? + } + None => self.github_tags_via_smart_http(identity)?, + }; + + if let Ok(mut guard) = self.github_tag_cache.lock() { + guard.insert(key, tags.clone()); + } + Ok(tags) + } + + fn github_tags_via_smart_http(&self, identity: &GithubIdentity) -> Result> { + let refs = self.github_smart_refs(identity)?; + let mut names: Vec = refs.tag_commits.keys().cloned().collect(); + names.sort(); + Ok(names.into_iter().map(|name| GithubTag { name }).collect()) + } + + fn github_smart_refs(&self, identity: &GithubIdentity) -> Result { + let key = github_identity_key(identity); + if let Some(refs) = self + .github_smart_ref_cache + .lock() + .ok() + .and_then(|guard| guard.get(&key).cloned()) + { + return Ok(refs); + } + + let url = format!( + "https://github.com/{}/{}.git/info/refs?service=git-upload-pack", + identity.owner, identity.repo + ); + let mut request = self + .client + .get(&url) + .header("Accept", "application/x-git-upload-pack-advertisement"); + if let Some(token) = self.config.github_token.as_deref() { + request = request.basic_auth("x-access-token", Some(token)); + } + let response = request .send() - .with_context(|| format!("list tags for {}", identity.web_url()))?; + .with_context(|| format!("fetch GitHub smart refs from {url}"))?; if !response.status().is_success() { - let hint = if self.config.github_token.is_none() - && response.status() == reqwest::StatusCode::NOT_FOUND - { - " (repository may be private; private repositories need ZED_PKG_GITHUB_TOKEN, GITHUB_TOKEN, or GH_TOKEN)" - } else { - "" - }; bail!( - "GitHub tags for {} returned {}{hint}", + "GitHub smart refs for {} returned {}", identity.web_url(), response.status() ); } - Ok(response.json().unwrap_or_default()) + let bytes = response.bytes().context("read GitHub smart refs")?; + let refs = parse_git_smart_refs(bytes.as_ref())?; + if let Ok(mut guard) = self.github_smart_ref_cache.lock() { + guard.insert(key, refs.clone()); + } + Ok(refs) } fn release_sidecar( @@ -548,12 +736,27 @@ impl FallbackRegistry { version: &str, tag: &str, ) -> Option { - let release = self - .github_headers(self.client.get(github_api_release_url(identity, tag))) - .send() - .ok() - .and_then(|response| response.error_for_status().ok()) - .and_then(|response| response.json::().ok()); + let release = if self.github_api_degraded.load(Ordering::Relaxed) { + None + } else { + match self + .github_headers(self.client.get(github_api_release_url(identity, tag))) + .send() + { + Ok(response) if response.status().is_success() => { + response.json::().ok() + } + Ok(response) if github_api_status_is_degradable(response.status()) => { + self.github_api_degraded.store(true, Ordering::Relaxed); + None + } + Err(_) => { + self.github_api_degraded.store(true, Ordering::Relaxed); + None + } + _ => None, + } + }; for sidecar in github_release_sidecar_names(org, name, version) { let url = zed_interfaces::source::github_release_download_url(identity, tag, &sidecar); let Ok(response) = self.client.get(&url).send() else { @@ -594,7 +797,11 @@ impl FallbackRegistry { .get(&url) .header("Accept", "application/vnd.oci.image.manifest.v1+json"); if let Some(token) = &self.config.github_token { - request = request.bearer_auth(token); + match crate::github_packages::ghcr_registry_token(&self.client, token, identity, "pull") + { + Ok(bearer) => request = request.bearer_auth(bearer), + Err(_) => return None, + } } let Ok(response) = request.send() else { return None; @@ -613,7 +820,7 @@ impl FallbackRegistry { .unwrap_or(digest.as_str()) .to_string(); let repo_url = identity.web_url(); - let locators = artifact_locators(&ArtifactQuery { + let locators = github_first_locators(artifact_locators(&ArtifactQuery { org, name, version, @@ -625,7 +832,7 @@ impl FallbackRegistry { registry_base: None, r2_public_base: self.config.r2_public_base.as_deref(), r2_public_key: self.config.r2_public_key.as_deref(), - }); + })); Some(VersionMetadata { org: org.to_string(), name: name.to_string(), @@ -655,7 +862,7 @@ impl FallbackRegistry { // carry suffixes such as `.rs`. let identity = self.resolve_github_identity(&version.org, &version.name)?; let repo_url = identity.web_url(); - let locators = artifact_locators(&ArtifactQuery { + let locators = github_first_locators(artifact_locators(&ArtifactQuery { org: &version.org, name: &version.name, version: &version.version, @@ -667,12 +874,11 @@ impl FallbackRegistry { registry_base: None, r2_public_base: self.config.r2_public_base.as_deref(), r2_public_key: self.config.r2_public_key.as_deref(), - }); + })); // The GitHub tag archive stays in the chain even when a digest is - // pinned. It is the last locator and is repacked deterministically, so - // a digest derived from it is reproducible, and every caller verifies - // the bytes against the pin. Skipping it broke installs whose digest - // had been derived from that same archive in another registry instance. + // pinned. GitHub sources are tried before Zed-owned R2 so a total Zed + // control-plane outage goes directly to GitHub. The archive is repacked + // deterministically and every caller verifies the bytes against the pin. for locator in locators { if locator.kind == ArtifactSourceKind::Registry { continue; @@ -730,12 +936,12 @@ impl Registry for FallbackRegistry { Err(error) => match self.github_get_version(org, name, version) { Ok(metadata) => { eprintln!( - "warning: registry unavailable for {org}/{name}@{version}; using GitHub/R2 fallback ({error})" + "warning: registry unavailable for {org}/{name}@{version}; using GitHub-first fallback ({error})" ); Ok(metadata) } Err(fallback) => Err(error.context(format!( - "registry unavailable and the GitHub/R2 fallback also failed: {fallback:#}" + "registry unavailable and the GitHub-first/R2 fallback also failed: {fallback:#}" ))), }, } @@ -747,7 +953,7 @@ impl Registry for FallbackRegistry { Err(error) => match self.download_locators(version, dest) { Ok(()) => Ok(()), Err(fallback) => Err(error.context(format!( - "registry download failed and the GitHub/R2 fallback also failed: {fallback:#}" + "registry download failed and the GitHub-first/R2 fallback also failed: {fallback:#}" ))), }, } @@ -797,7 +1003,7 @@ struct GithubRepositorySearch { items: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct GithubRepo { default_branch: String, #[serde(default)] @@ -892,7 +1098,7 @@ fn release_asset_metadata( None } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct GithubTag { name: String, } @@ -902,6 +1108,102 @@ struct GithubCommit { sha: String, } +#[derive(Debug, Clone, Default)] +struct GitSmartRefs { + default_branch: Option, + tag_commits: HashMap, +} + +fn github_api_status_is_degradable(status: reqwest::StatusCode) -> bool { + status == reqwest::StatusCode::FORBIDDEN + || status == reqwest::StatusCode::TOO_MANY_REQUESTS + || status.is_server_error() +} + +fn github_identity_key(identity: &GithubIdentity) -> String { + format!( + "{}/{}", + identity.owner.to_ascii_lowercase(), + identity.repo.to_ascii_lowercase() + ) +} + +fn parse_git_smart_refs(bytes: &[u8]) -> Result { + let mut refs = GitSmartRefs::default(); + let mut offset = 0usize; + let mut tag_refs: HashMap = HashMap::new(); + + while offset < bytes.len() { + if bytes.len().saturating_sub(offset) < 4 { + bail!("truncated Git smart-HTTP pkt-line header"); + } + let header = std::str::from_utf8(&bytes[offset..offset + 4]) + .context("non-UTF8 Git smart-HTTP pkt-line length")?; + let length = usize::from_str_radix(header, 16) + .with_context(|| format!("invalid Git smart-HTTP pkt-line length `{header}`"))?; + offset += 4; + if length == 0 || length == 1 || length == 2 { + continue; + } + if length < 4 { + bail!("invalid Git smart-HTTP pkt-line length {length}"); + } + let payload_len = length - 4; + if bytes.len().saturating_sub(offset) < payload_len { + bail!("truncated Git smart-HTTP pkt-line payload"); + } + let payload = &bytes[offset..offset + payload_len]; + offset += payload_len; + let line = std::str::from_utf8(payload) + .context("non-UTF8 Git smart-HTTP advertisement")? + .trim_end_matches(['\r', '\n']); + if line.is_empty() || line.starts_with("# service=") { + continue; + } + + let (ref_line, capabilities) = line.split_once('\0').unwrap_or((line, "")); + for capability in capabilities.split_whitespace() { + if let Some(branch) = capability.strip_prefix("symref=HEAD:refs/heads/") + && !branch.is_empty() + { + refs.default_branch = Some(branch.to_string()); + } + } + + let Some((sha, reference)) = ref_line.split_once(' ') else { + continue; + }; + let Some(sha) = commit_sha(sha) else { + continue; + }; + let Some(raw_tag) = reference.strip_prefix("refs/tags/") else { + continue; + }; + let (tag, peeled) = match raw_tag.strip_suffix("^{}") { + Some(tag) => (tag, true), + None => (raw_tag, false), + }; + if tag.is_empty() { + continue; + } + match tag_refs.get(tag) { + Some((_, true)) if !peeled => {} + _ => { + tag_refs.insert(tag.to_string(), (sha, peeled)); + } + } + } + + refs.tag_commits = tag_refs + .into_iter() + .map(|(tag, (sha, _))| (tag, sha)) + .collect(); + if refs.default_branch.is_none() && refs.tag_commits.is_empty() { + bail!("Git smart-HTTP advertisement contained no usable HEAD or tags"); + } + Ok(refs) +} + /// A full lowercase 40-hex Git commit id, or nothing. Abbreviated or oddly /// cased ids would never compare equal to a lock's recorded commit anyway. fn commit_sha(value: &str) -> Option { @@ -1248,6 +1550,77 @@ mod tests { use flate2::write::GzEncoder; use zed_interfaces::source::{r2_object_keys, resolve_r2_public_base}; + fn pkt(payload: &str) -> String { + format!("{:04x}{payload}", payload.len() + 4) + } + + #[test] + fn smart_http_refs_recover_default_branch_and_peeled_tags() { + let commit = "a".repeat(40); + let tag_object = "b".repeat(40); + let peeled = "c".repeat(40); + let first = + format!("{commit} HEAD\0multi_ack symref=HEAD:refs/heads/trunk agent=git/github\n"); + let lightweight = format!("{commit} refs/tags/v1.0.0\n"); + let annotated = format!("{tag_object} refs/tags/v2.0.0\n"); + let annotated_peeled = format!("{peeled} refs/tags/v2.0.0^{{}}\n"); + let advertisement = format!( + "{}0000{}{}{}{}0000", + pkt("# service=git-upload-pack\n"), + pkt(&first), + pkt(&lightweight), + pkt(&annotated), + pkt(&annotated_peeled) + ); + let refs = parse_git_smart_refs(advertisement.as_bytes()).unwrap(); + assert_eq!(refs.default_branch.as_deref(), Some("trunk")); + assert_eq!(refs.tag_commits.get("v1.0.0"), Some(&commit)); + assert_eq!(refs.tag_commits.get("v2.0.0"), Some(&peeled)); + } + + #[test] + fn github_rest_quota_and_server_failures_are_degradable() { + assert!(github_api_status_is_degradable( + reqwest::StatusCode::FORBIDDEN + )); + assert!(github_api_status_is_degradable( + reqwest::StatusCode::TOO_MANY_REQUESTS + )); + assert!(github_api_status_is_degradable( + reqwest::StatusCode::BAD_GATEWAY + )); + assert!(!github_api_status_is_degradable( + reqwest::StatusCode::NOT_FOUND + )); + assert!(!github_api_status_is_degradable( + reqwest::StatusCode::UNAUTHORIZED + )); + } + + #[test] + fn github_sources_precede_zed_owned_r2_during_fallback() { + assert!( + source_fallback_priority(ArtifactSourceKind::GithubRelease) + < source_fallback_priority(ArtifactSourceKind::R2) + ); + assert!( + source_fallback_priority(ArtifactSourceKind::GithubPackages) + < source_fallback_priority(ArtifactSourceKind::R2) + ); + assert!( + source_fallback_priority(ArtifactSourceKind::GithubArchive) + < source_fallback_priority(ArtifactSourceKind::R2) + ); + assert!( + source_fallback_priority(ArtifactSourceKind::GithubRelease) + < source_fallback_priority(ArtifactSourceKind::GithubPackages) + ); + assert!( + source_fallback_priority(ArtifactSourceKind::GithubPackages) + < source_fallback_priority(ArtifactSourceKind::GithubArchive) + ); + } + #[test] fn loopback_registries_are_hermetic() { assert!(is_loopback_registry("http://127.0.0.1:18080")); diff --git a/tests/toml_contract.rs b/tests/toml_contract.rs index 04b406f49..1a4ec8bbd 100644 --- a/tests/toml_contract.rs +++ b/tests/toml_contract.rs @@ -9,8 +9,7 @@ fn root() -> PathBuf { fn parse_toml(path: &Path) -> toml::Value { let text = fs::read_to_string(path) .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); - toml::from_str(&text) - .unwrap_or_else(|error| panic!("invalid TOML {}: {error}", path.display())) + toml::from_str(&text).unwrap_or_else(|error| panic!("invalid TOML {}: {error}", path.display())) } fn string_at<'a>(value: &'a toml::Value, path: &[&str]) -> &'a str { @@ -133,7 +132,10 @@ fn global_flags_do_not_reuse_environment_keys_or_public_aliases() { if alias == canonical_alias { continue; } - assert!(aliases.insert(alias.to_owned()), "duplicate global flag alias: {alias}"); + assert!( + aliases.insert(alias.to_owned()), + "duplicate global flag alias: {alias}" + ); } } }