From 029c3bf275de38c00f6328c99bfc2bfbbe77ba92 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 12 Sep 2026 20:04:51 -0500 Subject: [PATCH 01/12] feat(fallback): normalize GitHub source archives into Zed artifacts --- src/github_source_archive.rs | 252 +++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 src/github_source_archive.rs diff --git a/src/github_source_archive.rs b/src/github_source_archive.rs new file mode 100644 index 000000000..93d195d9a --- /dev/null +++ b/src/github_source_archive.rs @@ -0,0 +1,252 @@ +//! Normalize GitHub-generated repository archives into canonical Zed artifacts. +//! +//! GitHub's automatic tag archives are source archives: they are rooted under +//! `-/` and are not directly installable by Zed, whose immutable +//! store accepts only archives rooted under `pkg/`. This module deliberately +//! reuses the hardened store extractor and the ordinary deterministic packer so +//! direct GitHub fallback has exactly the same package layout, ignore rules, +//! executable-bit normalization, and digest semantics as `zed pack`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, ensure}; +use zed_interfaces::manifest::Manifest; +use zed_interfaces::paths::MANIFEST_FILE; +use zed_interfaces::registry::VersionMetadata; +use zed_interfaces::source::{GithubIdentity, parse_github_identity}; + +/// Extract one GitHub-generated source archive, validate the tagged manifest, +/// and deterministically repack it into the artifact format expected by the +/// registry/store boundary. +/// +/// If `version.sha256` is already pinned, the normalized bytes MUST reproduce +/// that digest (and declared size, when present). This keeps source fallback +/// useful for frozen installs without weakening lockfile integrity. +pub(crate) fn repack_github_archive( + source_archive: &Path, + destination: &Path, + identity: &GithubIdentity, + version: &VersionMetadata, +) -> Result<()> { + let extracted = tempfile::tempdir().context("create GitHub source extraction directory")?; + crate::store::extract_archive_for_update(source_archive, extracted.path()) + .context("extract GitHub source archive")?; + + repack_extracted_root(extracted.path(), destination, identity, version) +} + +fn repack_extracted_root( + extracted: &Path, + destination: &Path, + identity: &GithubIdentity, + version: &VersionMetadata, +) -> Result<()> { + let source_root = github_source_root(extracted)?; + let manifest_path = source_root.join(MANIFEST_FILE); + let manifest_text = fs::read_to_string(&manifest_path).with_context(|| { + format!( + "GitHub source archive for {}/{}@{} is missing readable {MANIFEST_FILE}", + version.org, version.name, version.version + ) + })?; + let manifest = Manifest::parse(&manifest_text).map_err(|error| anyhow!(error))?; + validate_manifest(&manifest, identity, version)?; + + let packed_dir = tempfile::tempdir().context("create normalized GitHub package directory")?; + let packed = crate::pack::pack_format( + &source_root, + &manifest, + Some(packed_dir.path()), + version.format, + ) + .context("repack GitHub source archive as a canonical Zed artifact")?; + + let has_pinned_digest = zed_interfaces::manifest::is_sha256_hex(&version.sha256); + if has_pinned_digest { + ensure!( + packed.sha256 == version.sha256, + "GitHub source archive normalized to sha256 {}, but the package metadata pins {}; refusing non-identical fallback", + packed.sha256, + version.sha256 + ); + if version.size > 0 { + ensure!( + packed.size == version.size, + "GitHub source archive normalized to {} bytes, but the package metadata pins {} bytes; refusing non-identical fallback", + packed.size, + version.size + ); + } + } + + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&packed.path, destination).with_context(|| { + format!( + "copy normalized GitHub artifact to {}", + destination.display() + ) + })?; + Ok(()) +} + +/// GitHub/codeload archives contain exactly one synthetic top-level directory. +/// Requiring that shape prevents an archive from smuggling unrelated roots +/// into the package before the normal packer's include/exclude policy runs. +fn github_source_root(extracted: &Path) -> Result { + let mut entries = fs::read_dir(extracted) + .with_context(|| format!("read extracted GitHub archive at {}", extracted.display()))? + .collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + ensure!( + entries.len() == 1, + "GitHub source archive must contain exactly one top-level directory, found {} entries", + entries.len() + ); + let root = entries.remove(0).path(); + ensure!( + fs::symlink_metadata(&root)?.file_type().is_dir(), + "GitHub source archive top-level entry must be a directory" + ); + Ok(root) +} + +fn validate_manifest( + manifest: &Manifest, + identity: &GithubIdentity, + version: &VersionMetadata, +) -> Result<()> { + ensure!( + manifest.package.org == version.org + && manifest.package.name == version.name + && manifest.package.version == version.version, + "tagged {MANIFEST_FILE} claims {}/{}@{}, expected {}/{}@{}", + manifest.package.org, + manifest.package.name, + manifest.package.version, + version.org, + version.name, + version.version + ); + let declared = parse_github_identity(&manifest.package.repository.url).with_context(|| { + format!( + "tagged {MANIFEST_FILE} repository `{}` is not a GitHub repository", + manifest.package.repository.url + ) + })?; + ensure!( + declared.owner.eq_ignore_ascii_case(&identity.owner) + && declared.repo.eq_ignore_ascii_case(&identity.repo), + "tagged {MANIFEST_FILE} points at {}, expected {}", + declared.web_url(), + identity.web_url() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use zed_interfaces::artifact::ArtifactFormat; + + fn identity() -> GithubIdentity { + GithubIdentity { + owner: "acme".into(), + repo: "http-kit".into(), + } + } + + fn version(format: ArtifactFormat) -> VersionMetadata { + VersionMetadata { + org: "acme".into(), + name: "http-kit".into(), + version: "1.2.0".into(), + sha256: String::new(), + size: 0, + format, + vcs_tag: "v1.2.0".into(), + vcs_commit: None, + download_url: "https://github.com/acme/http-kit/archive/refs/tags/v1.2.0.tar.gz".into(), + published_at: "1970-01-01T00:00:00Z".into(), + yanked: false, + mirrors: Vec::new(), + signatures: Vec::new(), + } + } + + fn extracted_source() -> tempfile::TempDir { + let extracted = tempfile::tempdir().unwrap(); + let root = extracted.path().join("http-kit-deadbeef"); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join(MANIFEST_FILE), + r#"[package] +org = "acme" +name = "http-kit" +version = "1.2.0" +language = "rust" + +[package.repository] +vcs = "git" +url = "https://github.com/acme/http-kit" +"#, + ) + .unwrap(); + fs::write(root.join("src/lib.rs"), "pub fn answer() -> u8 { 42 }\n").unwrap(); + extracted + } + + #[test] + fn github_source_tree_becomes_store_compatible_tar_and_zip() { + for format in [ArtifactFormat::TarGz, ArtifactFormat::Zip] { + let extracted = extracted_source(); + let output = tempfile::NamedTempFile::new().unwrap(); + let metadata = version(format); + repack_extracted_root(extracted.path(), output.path(), &identity(), &metadata).unwrap(); + + let (sha256, _) = crate::pack::sha256_file(output.path()).unwrap(); + let store_home = tempfile::tempdir().unwrap(); + let store = crate::store::Store::new(store_home.path()); + let package = store.add_artifact(output.path(), &sha256).unwrap(); + assert!(package.join(MANIFEST_FILE).is_file()); + assert!(package.join("src/lib.rs").is_file()); + } + } + + #[test] + fn pinned_digest_must_match_normalized_bytes() { + let extracted = extracted_source(); + let output = tempfile::NamedTempFile::new().unwrap(); + let mut metadata = version(ArtifactFormat::TarGz); + metadata.sha256 = "ab".repeat(32); + metadata.size = 1; + let error = repack_extracted_root(extracted.path(), output.path(), &identity(), &metadata) + .unwrap_err() + .to_string(); + assert!(error.contains("refusing non-identical fallback")); + } + + #[test] + fn tagged_manifest_must_match_requested_package_identity() { + let extracted = extracted_source(); + let root = extracted.path().join("http-kit-deadbeef"); + let text = fs::read_to_string(root.join(MANIFEST_FILE)).unwrap(); + fs::write( + root.join(MANIFEST_FILE), + text.replace("version = \"1.2.0\"", "version = \"9.9.9\""), + ) + .unwrap(); + let output = tempfile::NamedTempFile::new().unwrap(); + let error = repack_extracted_root( + extracted.path(), + output.path(), + &identity(), + &version(ArtifactFormat::TarGz), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("expected acme/http-kit@1.2.0")); + } +} From 540dd80dfc17a8c8d2b9641f1ae009ec0cd4f7f8 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 12 Sep 2026 20:05:02 -0500 Subject: [PATCH 02/12] feat(fallback): expose GitHub archive normalizer --- src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index cdcf41b57..c4ebb1b35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,6 +116,7 @@ pub mod mise_export { } pub mod github_mirror; pub mod github_packages; +pub(crate) mod github_source_archive; pub mod mise_lock; pub mod native; pub mod native_host_client; @@ -152,4 +153,4 @@ pub mod update; pub mod validation; pub mod vcs; -pub mod tool_versions; +pub mod tool_versions; \ No newline at end of file From 6cbbbddb40868fb7592c0e45df9b3e78a11b20fe Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 12 Sep 2026 20:06:22 -0500 Subject: [PATCH 03/12] fix(fallback): repack GitHub source archives before install --- src/source_fallback.rs | 51 +++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/source_fallback.rs b/src/source_fallback.rs index b845f1f43..1b3eeaa6c 100644 --- a/src/source_fallback.rs +++ b/src/source_fallback.rs @@ -1,9 +1,10 @@ //! Retry GitHub and public R2 when the configured 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 a tagged source archive only when no packed digest is known. +//! and air-gapped mirrors never leak to github.com. Production registry edges +//! fall back to guessed public R2 keys and GitHub Release assets, then to a +//! tagged source archive. GitHub source archives are normalized through the +//! ordinary Zed packer before their digest is admitted to the store. use std::cell::RefCell; use std::collections::HashMap; @@ -604,9 +605,6 @@ impl FallbackRegistry { r2_public_key: self.config.r2_public_key.as_deref(), }); for locator in locators { - if locator.kind == ArtifactSourceKind::GithubArchive && packed_digest { - continue; - } if locator.kind == ArtifactSourceKind::Registry { continue; } @@ -619,21 +617,41 @@ impl FallbackRegistry { version.size, max_artifact_bytes(), ) - } else if locator.kind == ArtifactSourceKind::GithubArchive - && let Some(token) = self.config.github_token.as_deref() - { - // github.com///archive/... is anonymous-only, so a - // private repository 404s there even with a token. The REST - // tarball endpoint honours the token and redirects to a signed - // codeload URL for the same tag. - let api_url = github_api_tarball_url(&identity, &version.vcs_tag); - download_url(&self.client, &api_url, dest, version.size, Some(token)) + } else if locator.kind == ArtifactSourceKind::GithubArchive { + // GitHub's automatic source archives are not Zed artifacts: + // they carry a synthetic repo/ref root rather than `pkg/`. + // Download to an isolated file, then feed the tagged source + // through the hardened extractor + deterministic Zed packer. + let raw = tempfile::NamedTempFile::new().context("GitHub source archive tempfile"); + match raw { + Ok(raw) => { + let downloaded = if let Some(token) = self.config.github_token.as_deref() { + // github.com///archive/... is anonymous-only, so a + // private repository 404s there even with a token. The REST + // tarball endpoint honours the token and redirects to a signed + // codeload URL for the same tag. + let api_url = github_api_tarball_url(&identity, &version.vcs_tag); + download_url(&self.client, &api_url, raw.path(), 0, Some(token)) + } else { + download_url(&self.client, &locator.url, raw.path(), 0, None) + }; + downloaded.and_then(|()| { + crate::github_source_archive::repack_github_archive( + raw.path(), + dest, + &identity, + version, + ) + }) + } + Err(error) => Err(error.into()), + } } else { download_url(&self.client, &locator.url, dest, version.size, None) }; match result { Ok(()) => return Ok(()), - Err(error) => errors.push(format!("{}: {error}", locator.url)), + Err(error) => errors.push(format!("{}: {error:#}", locator.url)), } } bail!( @@ -1015,6 +1033,7 @@ mod tests { assert!(is_loopback_registry("http://127.0.0.1:18080")); assert!(is_loopback_registry("http://localhost:8080")); assert!(!is_loopback_registry("https://registry.zpkg.net")); + assert!(!is_loopback_registry("https://zpkg.net")); } #[test] From 29aa09714bccfbce83e7e041e485aa53c3b83665 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 12 Sep 2026 20:11:30 -0500 Subject: [PATCH 04/12] ci(fallback): exercise GitHub source repacking despite stale lock --- .github/workflows/github-source-fallback.yml | 48 ++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/github-source-fallback.yml diff --git a/.github/workflows/github-source-fallback.yml b/.github/workflows/github-source-fallback.yml new file mode 100644 index 000000000..41eced905 --- /dev/null +++ b/.github/workflows/github-source-fallback.yml @@ -0,0 +1,48 @@ +name: github-source-fallback + +on: + pull_request: + paths: + - "src/github_source_archive.rs" + - "src/source_fallback.rs" + - "src/lib.rs" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/github-source-fallback.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + candidate: + name: GitHub source archive fallback + runs-on: ubuntu-24.04 + steps: + - name: Checkout exact candidate + uses: actions/checkout@v5 + with: + persist-credentials: false + + # main currently carries an older zed-lock source in Cargo.lock than the + # immutable revision already required by Cargo.toml. Refresh that one + # package in-run so this focused job tests the fallback code instead of + # being masked by the unrelated graph-authority failure. + - name: Refresh declared zed-lock revision in runner + run: cargo update -p zed-lock + + - name: Verify lock refresh is scoped + shell: bash + run: | + set -Eeuo pipefail + git diff -- Cargo.lock + git diff --exit-code -- Cargo.toml + + - name: Check candidate + run: cargo check --all-targets + + - name: Test GitHub source normalizer + run: cargo test --lib github_source_archive -- --nocapture + + - name: Test source fallback + run: cargo test --lib source_fallback -- --nocapture From 1d9ca770655d3dca88388a4973408659656ea61f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 12 Sep 2026 20:16:01 -0500 Subject: [PATCH 05/12] ci: stage canonical zpkg edge transport fixup --- .../workflows/canonical-zpkg-edge-fixup.yml | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/canonical-zpkg-edge-fixup.yml diff --git a/.github/workflows/canonical-zpkg-edge-fixup.yml b/.github/workflows/canonical-zpkg-edge-fixup.yml new file mode 100644 index 000000000..c05a133b7 --- /dev/null +++ b/.github/workflows/canonical-zpkg-edge-fixup.yml @@ -0,0 +1,95 @@ +name: canonical-zpkg-edge-fixup + +on: + push: + branches: + - fix/github-source-archive-fallback + paths: + - .github/workflows/canonical-zpkg-edge-fixup.yml + +permissions: + contents: write + +jobs: + fixup: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + with: + ref: fix/github-source-archive-fallback + - name: Canonicalize legacy registry default at the transport boundary + shell: python + run: | + from pathlib import Path + path = Path("src/registry.rs") + text = path.read_text() + old = '''pub fn registry_for(url: &str) -> Result> { + if url.starts_with("file://") { + Ok(Box::new(FileRegistry::new(file_registry_path(url)?))) + } else if url.starts_with("http://") || url.starts_with("https://") { + let http = HttpRegistry::new(url.to_string())?; + Ok(crate::source_fallback::FallbackRegistry::wrap(http, url)) + } else { + bail!("unsupported registry url `{url}` (expected http(s):// or file://)"); + } + } + ''' + new = '''const LEGACY_PUBLIC_REGISTRY_URL: &str = "https://registry.zpkg.net"; + const CANONICAL_PUBLIC_REGISTRY_URL: &str = "https://zpkg.net"; + + fn canonical_registry_url(url: &str) -> &str { + if url.trim_end_matches('/') == LEGACY_PUBLIC_REGISTRY_URL { + CANONICAL_PUBLIC_REGISTRY_URL + } else { + url + } + } + + pub fn registry_for(url: &str) -> Result> { + let url = canonical_registry_url(url); + if url.starts_with("file://") { + Ok(Box::new(FileRegistry::new(file_registry_path(url)?))) + } else if url.starts_with("http://") || url.starts_with("https://") { + let http = HttpRegistry::new(url.to_string())?; + Ok(crate::source_fallback::FallbackRegistry::wrap(http, url)) + } else { + bail!("unsupported registry url `{url}` (expected http(s):// or file://)"); + } + } + + #[cfg(test)] + #[test] + fn legacy_public_registry_alias_uses_the_canonical_zpkg_edge() { + assert_eq!( + canonical_registry_url("https://registry.zpkg.net"), + "https://zpkg.net" + ); + assert_eq!( + canonical_registry_url("https://registry.zpkg.net/"), + "https://zpkg.net" + ); + assert_eq!( + canonical_registry_url("https://registry.example.test"), + "https://registry.example.test" + ); + } + ''' + if old not in text: + raise SystemExit("registry_for block did not match expected authority") + path.write_text(text.replace(old, new, 1)) + - name: Verify scoped change + shell: bash + run: | + set -Eeuo pipefail + git diff --check + test "$(git diff --name-only)" = "src/registry.rs" + git diff -- src/registry.rs + - name: Commit transport routing + shell: bash + run: | + set -Eeuo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/registry.rs + git commit -m "fix(registry): route legacy public default through zpkg.net" + git push origin HEAD:fix/github-source-archive-fallback From a997c32d23aadbdfc90a236af4671be94663c59e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:16:08 +0000 Subject: [PATCH 06/12] fix(registry): route legacy public default through zpkg.net --- src/registry.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/registry.rs b/src/registry.rs index b465cae45..3e929fdf5 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -158,7 +158,19 @@ pub trait Registry { ) -> Result; } +const LEGACY_PUBLIC_REGISTRY_URL: &str = "https://registry.zpkg.net"; +const CANONICAL_PUBLIC_REGISTRY_URL: &str = "https://zpkg.net"; + +fn canonical_registry_url(url: &str) -> &str { + if url.trim_end_matches('/') == LEGACY_PUBLIC_REGISTRY_URL { + CANONICAL_PUBLIC_REGISTRY_URL + } else { + url + } +} + pub fn registry_for(url: &str) -> Result> { + let url = canonical_registry_url(url); if url.starts_with("file://") { Ok(Box::new(FileRegistry::new(file_registry_path(url)?))) } else if url.starts_with("http://") || url.starts_with("https://") { @@ -169,6 +181,23 @@ pub fn registry_for(url: &str) -> Result> { } } +#[cfg(test)] +#[test] +fn legacy_public_registry_alias_uses_the_canonical_zpkg_edge() { + assert_eq!( + canonical_registry_url("https://registry.zpkg.net"), + "https://zpkg.net" + ); + assert_eq!( + canonical_registry_url("https://registry.zpkg.net/"), + "https://zpkg.net" + ); + assert_eq!( + canonical_registry_url("https://registry.example.test"), + "https://registry.example.test" + ); +} + /// Pick the highest stable version satisfying `req`. pub fn resolve_version(req: &VersionReq, versions: &[String]) -> Option { versions From b5e3d2d16989363c8e0b3157001051ee70945a98 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 12 Sep 2026 20:16:43 -0500 Subject: [PATCH 07/12] ci: remove one-shot canonical edge fixup --- .../workflows/canonical-zpkg-edge-fixup.yml | 95 ------------------- 1 file changed, 95 deletions(-) delete mode 100644 .github/workflows/canonical-zpkg-edge-fixup.yml diff --git a/.github/workflows/canonical-zpkg-edge-fixup.yml b/.github/workflows/canonical-zpkg-edge-fixup.yml deleted file mode 100644 index c05a133b7..000000000 --- a/.github/workflows/canonical-zpkg-edge-fixup.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: canonical-zpkg-edge-fixup - -on: - push: - branches: - - fix/github-source-archive-fallback - paths: - - .github/workflows/canonical-zpkg-edge-fixup.yml - -permissions: - contents: write - -jobs: - fixup: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v5 - with: - ref: fix/github-source-archive-fallback - - name: Canonicalize legacy registry default at the transport boundary - shell: python - run: | - from pathlib import Path - path = Path("src/registry.rs") - text = path.read_text() - old = '''pub fn registry_for(url: &str) -> Result> { - if url.starts_with("file://") { - Ok(Box::new(FileRegistry::new(file_registry_path(url)?))) - } else if url.starts_with("http://") || url.starts_with("https://") { - let http = HttpRegistry::new(url.to_string())?; - Ok(crate::source_fallback::FallbackRegistry::wrap(http, url)) - } else { - bail!("unsupported registry url `{url}` (expected http(s):// or file://)"); - } - } - ''' - new = '''const LEGACY_PUBLIC_REGISTRY_URL: &str = "https://registry.zpkg.net"; - const CANONICAL_PUBLIC_REGISTRY_URL: &str = "https://zpkg.net"; - - fn canonical_registry_url(url: &str) -> &str { - if url.trim_end_matches('/') == LEGACY_PUBLIC_REGISTRY_URL { - CANONICAL_PUBLIC_REGISTRY_URL - } else { - url - } - } - - pub fn registry_for(url: &str) -> Result> { - let url = canonical_registry_url(url); - if url.starts_with("file://") { - Ok(Box::new(FileRegistry::new(file_registry_path(url)?))) - } else if url.starts_with("http://") || url.starts_with("https://") { - let http = HttpRegistry::new(url.to_string())?; - Ok(crate::source_fallback::FallbackRegistry::wrap(http, url)) - } else { - bail!("unsupported registry url `{url}` (expected http(s):// or file://)"); - } - } - - #[cfg(test)] - #[test] - fn legacy_public_registry_alias_uses_the_canonical_zpkg_edge() { - assert_eq!( - canonical_registry_url("https://registry.zpkg.net"), - "https://zpkg.net" - ); - assert_eq!( - canonical_registry_url("https://registry.zpkg.net/"), - "https://zpkg.net" - ); - assert_eq!( - canonical_registry_url("https://registry.example.test"), - "https://registry.example.test" - ); - } - ''' - if old not in text: - raise SystemExit("registry_for block did not match expected authority") - path.write_text(text.replace(old, new, 1)) - - name: Verify scoped change - shell: bash - run: | - set -Eeuo pipefail - git diff --check - test "$(git diff --name-only)" = "src/registry.rs" - git diff -- src/registry.rs - - name: Commit transport routing - shell: bash - run: | - set -Eeuo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src/registry.rs - git commit -m "fix(registry): route legacy public default through zpkg.net" - git push origin HEAD:fix/github-source-archive-fallback From 9a2d2bbcc682801ef26a479db22f1bb9a0d1142d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 12 Sep 2026 20:22:13 -0500 Subject: [PATCH 08/12] ci(fallback): isolate candidate from known main-branch drift --- .github/workflows/github-source-fallback.yml | 36 +++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/github-source-fallback.yml b/.github/workflows/github-source-fallback.yml index 41eced905..03d7d66d5 100644 --- a/.github/workflows/github-source-fallback.yml +++ b/.github/workflows/github-source-fallback.yml @@ -5,6 +5,7 @@ on: paths: - "src/github_source_archive.rs" - "src/source_fallback.rs" + - "src/registry.rs" - "src/lib.rs" - "Cargo.toml" - "Cargo.lock" @@ -24,19 +25,35 @@ jobs: with: persist-credentials: false - # main currently carries an older zed-lock source in Cargo.lock than the - # immutable revision already required by Cargo.toml. Refresh that one - # package in-run so this focused job tests the fallback code instead of - # being masked by the unrelated graph-authority failure. - - name: Refresh declared zed-lock revision in runner - run: cargo update -p zed-lock + # main currently carries two unrelated baseline defects that stop an + # all-targets compile before this PR's code can be exercised: Cargo.lock + # resolves an older zed-lock revision than Cargo.toml requires, and one + # hardening test module imports registry_for twice. Repair those only in + # the ephemeral runner; the permanent cleanup is intentionally separate. + - name: Repair known main-branch graph/test drift in runner + shell: bash + run: | + set -Eeuo pipefail + cargo update -p zed-lock + python - <<'PY' + from pathlib import Path + path = Path("src/install_graph/hardening_tests.rs") + text = path.read_text() + needle = "use crate::registry::registry_for;" + if text.count(needle) != 2: + raise SystemExit(f"expected known baseline duplicate import twice, found {text.count(needle)}") + first = text.find(needle) + second = text.find(needle, first + len(needle)) + path.write_text((text[:second] + text[second + len(needle):]).rstrip() + "\n") + PY - - name: Verify lock refresh is scoped + - name: Verify baseline repair is scoped shell: bash run: | set -Eeuo pipefail - git diff -- Cargo.lock + git diff -- Cargo.lock src/install_graph/hardening_tests.rs git diff --exit-code -- Cargo.toml + test "$(git diff --name-only | sort | tr '\n' ' ')" = "Cargo.lock src/install_graph/hardening_tests.rs " - name: Check candidate run: cargo check --all-targets @@ -46,3 +63,6 @@ jobs: - name: Test source fallback run: cargo test --lib source_fallback -- --nocapture + + - name: Test canonical zpkg edge alias + run: cargo test --lib legacy_public_registry_alias_uses_the_canonical_zpkg_edge -- --nocapture From a6795384d13fa903dae9d902ba8e207888f437f3 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 12 Sep 2026 20:38:21 -0500 Subject: [PATCH 09/12] fix(fallback): tolerate GitHub PAX global tar metadata --- src/github_source_archive.rs | 167 ++++++++++++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 4 deletions(-) diff --git a/src/github_source_archive.rs b/src/github_source_archive.rs index 93d195d9a..e853209ff 100644 --- a/src/github_source_archive.rs +++ b/src/github_source_archive.rs @@ -3,19 +3,33 @@ //! GitHub's automatic tag archives are source archives: they are rooted under //! `-/` and are not directly installable by Zed, whose immutable //! store accepts only archives rooted under `pkg/`. This module deliberately -//! reuses the hardened store extractor and the ordinary deterministic packer so -//! direct GitHub fallback has exactly the same package layout, ignore rules, -//! executable-bit normalization, and digest semantics as `zed pack`. +//! keeps the hardened store extractor as the final extraction boundary and the +//! ordinary deterministic packer as the output path, so direct GitHub fallback +//! has the same package layout, ignore rules, executable-bit normalization, and +//! digest semantics as `zed pack`. use std::fs; +use std::io::{Read, sink}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, ensure}; +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; use zed_interfaces::manifest::Manifest; use zed_interfaces::paths::MANIFEST_FILE; use zed_interfaces::registry::VersionMetadata; use zed_interfaces::source::{GithubIdentity, parse_github_identity}; +// GitHub's generated tarballs currently include a POSIX PAX global metadata +// member before the repository tree. Canonical Zed artifacts intentionally +// reject every non-file/non-directory member, so source fallback strips only +// this metadata-only record into a temporary tarball and then still sends the +// result through the ordinary hardened extractor. Keep preprocessing bounded so +// it cannot become a decompression-bomb bypass around the store extractor. +const MAX_GITHUB_SOURCE_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const MAX_GITHUB_SOURCE_ENTRIES: usize = 200_000; + /// Extract one GitHub-generated source archive, validate the tagged manifest, /// and deterministically repack it into the artifact format expected by the /// registry/store boundary. @@ -30,12 +44,87 @@ pub(crate) fn repack_github_archive( version: &VersionMetadata, ) -> Result<()> { let extracted = tempfile::tempdir().context("create GitHub source extraction directory")?; - crate::store::extract_archive_for_update(source_archive, extracted.path()) + let sanitized = strip_github_pax_global_headers(source_archive)?; + let archive_for_extract = sanitized + .as_ref() + .map(tempfile::NamedTempFile::path) + .unwrap_or(source_archive); + crate::store::extract_archive_for_update(archive_for_extract, extracted.path()) .context("extract GitHub source archive")?; repack_extracted_root(extracted.path(), destination, identity, version) } +/// GitHub/codeload tarballs may carry a metadata-only POSIX PAX global header +/// (`typeflag = 'g'`). `tar` deliberately exposes global headers as entries, +/// while it consumes local PAX/GNU long-name records as metadata for the file +/// they describe. Iterate raw entries here so every other header is preserved +/// byte-for-byte semantically; remove only the global record, then let the +/// strict store extractor perform path/type/size validation on the result. +fn strip_github_pax_global_headers( + source_archive: &Path, +) -> Result> { + let mut magic = [0u8; 2]; + let read = { + let mut file = fs::File::open(source_archive)?; + file.read(&mut magic).unwrap_or(0) + }; + if read < 2 || magic != [0x1f, 0x8b] { + return Ok(None); + } + + let input = fs::File::open(source_archive)?; + let mut archive = tar::Archive::new(GzDecoder::new(input)); + let sanitized = tempfile::NamedTempFile::new() + .context("create sanitized GitHub source archive")?; + let output = sanitized + .reopen() + .context("reopen sanitized GitHub source archive")?; + let encoder = GzEncoder::new(output, Compression::default()); + let mut builder = tar::Builder::new(encoder); + + let mut bytes = 0u64; + let mut count = 0usize; + for raw_entry in archive.entries()?.raw(true) { + let mut entry = raw_entry.context("read GitHub source tar entry")?; + count += 1; + ensure!( + count <= MAX_GITHUB_SOURCE_ENTRIES, + "GitHub source archive has more than {MAX_GITHUB_SOURCE_ENTRIES} raw entries; refusing" + ); + let size = entry.header().size().context("read GitHub tar entry size")?; + bytes = bytes.saturating_add(size); + ensure!( + bytes <= MAX_GITHUB_SOURCE_BYTES, + "GitHub source archive expands past the {MAX_GITHUB_SOURCE_BYTES}-byte preprocessing cap; refusing" + ); + + if entry.header().entry_type() == tar::EntryType::XGlobalHeader { + // Drain the record so malformed/truncated metadata still fails the + // source read instead of being silently accepted. + let copied = std::io::copy(&mut entry, &mut sink())?; + ensure!( + copied == size, + "GitHub PAX global header is truncated: declared {size} bytes, read {copied}" + ); + continue; + } + + let header = entry.header().clone(); + builder + .append(&header, &mut entry) + .context("copy GitHub source tar entry into sanitized archive")?; + } + + let encoder = builder + .into_inner() + .context("finish sanitized GitHub source tar")?; + encoder + .finish() + .context("finish sanitized GitHub source gzip stream")?; + Ok(Some(sanitized)) +} + fn repack_extracted_root( extracted: &Path, destination: &Path, @@ -198,6 +287,47 @@ url = "https://github.com/acme/http-kit" extracted } + fn pax_record(key: &str, value: &str) -> Vec { + let payload = format!("{key}={value}\n"); + let mut digits = 1usize; + loop { + let len = digits + 1 + payload.len(); + let next_digits = len.to_string().len(); + if next_digits == digits { + return format!("{len} {payload}").into_bytes(); + } + digits = next_digits; + } + } + + fn github_style_tar_with_global_pax() -> tempfile::NamedTempFile { + let source = extracted_source(); + let root = source.path().join("http-kit-deadbeef"); + let archive = tempfile::NamedTempFile::new().unwrap(); + let file = archive.reopen().unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = tar::Builder::new(encoder); + + let pax = pax_record("comment", "deadbeef"); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::XGlobalHeader); + header.set_mode(0o644); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_size(pax.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "pax_global_header", pax.as_slice()) + .unwrap(); + builder + .append_dir_all("http-kit-deadbeef", &root) + .unwrap(); + let encoder = builder.into_inner().unwrap(); + encoder.finish().unwrap(); + archive + } + #[test] fn github_source_tree_becomes_store_compatible_tar_and_zip() { for format in [ArtifactFormat::TarGz, ArtifactFormat::Zip] { @@ -215,6 +345,35 @@ url = "https://github.com/acme/http-kit" } } + #[test] + fn github_pax_global_header_is_stripped_before_strict_extraction() { + let source = github_style_tar_with_global_pax(); + + // The canonical artifact/update extractor remains strict: raw GitHub + // metadata is not silently admitted into the immutable package format. + let strict_dest = tempfile::tempdir().unwrap(); + let strict_error = crate::store::extract_archive_for_update(source.path(), strict_dest.path()) + .unwrap_err() + .to_string(); + assert!(strict_error.contains("XGlobalHeader")); + + let output = tempfile::NamedTempFile::new().unwrap(); + repack_github_archive( + source.path(), + output.path(), + &identity(), + &version(ArtifactFormat::TarGz), + ) + .unwrap(); + + let (sha256, _) = crate::pack::sha256_file(output.path()).unwrap(); + let store_home = tempfile::tempdir().unwrap(); + let store = crate::store::Store::new(store_home.path()); + let package = store.add_artifact(output.path(), &sha256).unwrap(); + assert!(package.join(MANIFEST_FILE).is_file()); + assert!(package.join("src/lib.rs").is_file()); + } + #[test] fn pinned_digest_must_match_normalized_bytes() { let extracted = extracted_source(); From edea0c9f8628c620306e3504ebc42f0c6fa34d9f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 15 Sep 2026 10:42:13 -0500 Subject: [PATCH 10/12] noop --- tmp-do-not-create-2 | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tmp-do-not-create-2 diff --git a/tmp-do-not-create-2 b/tmp-do-not-create-2 new file mode 100644 index 000000000..e69de29bb From 233797b71c0372e2facaebdae39dd42308f3ca84 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 15 Sep 2026 10:42:28 -0500 Subject: [PATCH 11/12] chore: remove accidental temporary file --- tmp-do-not-create-2 | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tmp-do-not-create-2 diff --git a/tmp-do-not-create-2 b/tmp-do-not-create-2 deleted file mode 100644 index e69de29bb..000000000 From b6b8e2d17c0d6146754800cde4ef0ec77cedaeee Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 15 Sep 2026 19:05:40 -0500 Subject: [PATCH 12/12] fix: drop stale manifest and CLI-contract drift from registry alias PR Restore .cli-flags.toml and .zpkg.toml byte-for-byte from current main so this branch contains only the intended registry.zpkg.net compatibility alias change in src/registry.rs. --- .cli-flags.toml | 2 +- .zpkg.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) 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/.zpkg.toml b/.zpkg.toml index bcf0a0aa5..72a45115a 100644 --- a/.zpkg.toml +++ b/.zpkg.toml @@ -56,5 +56,4 @@ registry = "crates-io" package = "zed-cli" [scripts] -toml-contract = "cargo test --test toml_contract --locked" test = "cargo test --test toml_contract --locked && bash scripts/validate-zed-package-graph.sh && cargo test --all-targets --locked"