use std::num::NonZeroU64;
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tracing::warn;
use crate::keys;
use crate::object_store::{ObjectStore, ObjectStoreError};
use crate::packchain::PackchainError;
use crate::packchain::keys::is_chain_json_key;
use crate::packchain::schema::ChainManifest;
use crate::url::{AzureAddressing, RemoteUrl, S3Addressing};
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct BundleUriOpts {
pub(crate) presign_ttl_seconds: Option<NonZeroU64>,
}
#[derive(Debug, thiserror::Error)]
pub enum BundleUriError {
#[error(transparent)]
Packchain(#[from] PackchainError),
#[error(transparent)]
Store(#[from] ObjectStoreError),
#[error(transparent)]
Io(#[from] std::io::Error),
}
pub(crate) async fn handle_bundle_uri<W>(
store: &dyn ObjectStore,
remote: &RemoteUrl,
opts: BundleUriOpts,
advertised: bool,
writer: &mut W,
) -> Result<(), BundleUriError>
where
W: AsyncWrite + Unpin,
{
if !advertised {
writer.write_all(b"\n").await?;
writer.flush().await?;
return Ok(());
}
let prefix = remote.prefix().unwrap_or_default();
let entries = match collect_entries(store, prefix).await {
Ok(entries) => entries,
Err(e) => {
warn!(
error = %e,
"bundle-uri: refs listing failed; emitting empty response",
);
Vec::new()
}
};
for entry in &entries {
let url =
match bundle_url_for_emission(store, remote, &entry.ref_path, &entry.full_at, &opts)
.await
{
Ok(u) => u,
Err(e) => {
warn!(
ref_path = %entry.ref_path,
error = %e,
"bundle-uri: URL build failed; skipping entry",
);
continue;
}
};
let ref_path = &entry.ref_path;
let token = &entry.full_at;
let line =
format!("bundle.{ref_path}.uri={url}\nbundle.{ref_path}.creationToken={token}\n");
writer.write_all(line.as_bytes()).await?;
}
writer.write_all(b"\n").await?;
writer.flush().await?;
Ok(())
}
#[derive(Debug, Clone)]
struct BundleEntry {
ref_path: String,
full_at: String,
}
async fn collect_entries(
store: &dyn ObjectStore,
prefix: &str,
) -> Result<Vec<BundleEntry>, PackchainError> {
let refs_prefix = keys::join(Some(prefix), "refs/heads/");
let metas = store.list(&refs_prefix).await?;
let prefix_opt = Some(prefix);
let mut out: Vec<BundleEntry> = Vec::with_capacity(metas.len());
for meta in metas {
if !is_chain_json_key(&meta.key) {
continue;
}
let Some(ref_path) = crate::packchain::keys::ref_path_from_chain_key(prefix_opt, &meta.key)
else {
warn!(key = %meta.key, "bundle-uri: chain.json key has unexpected shape; skipping");
continue;
};
if !crate::git::RefName::is_valid(&ref_path) {
warn!(
key = %meta.key,
ref_path = %ref_path,
"bundle-uri: derived ref path is not a valid ref name; skipping",
);
continue;
}
if !is_safe_for_bundle_uri_emission(&ref_path) {
warn!(
key = %meta.key,
ref_path = %ref_path,
"bundle-uri: derived ref path contains framing-unsafe or URL-unsafe bytes; skipping",
);
continue;
}
let body = match store.get_bytes(&meta.key).await {
Ok(b) => b,
Err(e) => {
warn!(
key = %meta.key,
error = %e,
"bundle-uri: chain.json fetch failed; skipping",
);
continue;
}
};
match ChainManifest::from_json_bytes(&body) {
Ok(chain) => out.push(BundleEntry {
ref_path,
full_at: chain.full_at.as_str().to_owned(),
}),
Err(e) => warn!(
key = %meta.key,
error = %e,
"bundle-uri: chain.json failed to parse; skipping",
),
}
}
out.sort_by(|a, b| a.ref_path.cmp(&b.ref_path));
Ok(out)
}
async fn bundle_url_for_emission(
store: &dyn ObjectStore,
remote: &RemoteUrl,
ref_path: &str,
full_at: &str,
opts: &BundleUriOpts,
) -> Result<String, BundleUriError> {
let Some(ttl_seconds) = opts.presign_ttl_seconds else {
return Ok(canonical_bundle_url(remote, ref_path, full_at));
};
let bundle_key = keys::bundle_key(remote.prefix(), ref_path, full_at);
let ttl = std::time::Duration::from_secs(ttl_seconds.get());
Ok(store.presigned_get_url(&bundle_key, ttl).await?)
}
fn canonical_bundle_url(remote: &RemoteUrl, ref_path: &str, full_at: &str) -> String {
let bundle_key = keys::bundle_key(remote.prefix(), ref_path, full_at);
let endpoint = match remote {
RemoteUrl::S3 { endpoint, .. } | RemoteUrl::Azure { endpoint, .. } => endpoint,
};
let authority = host_authority(endpoint);
match remote {
RemoteUrl::S3 {
addressing: S3Addressing::VirtualHosted,
..
} => format!("{authority}/{bundle_key}"),
RemoteUrl::S3 {
bucket,
addressing: S3Addressing::PathStyle,
..
} => format!("{authority}/{bucket}/{bundle_key}"),
RemoteUrl::Azure {
container,
addressing: AzureAddressing::VirtualHosted,
..
} => format!("{authority}/{container}/{bundle_key}"),
RemoteUrl::Azure {
account,
container,
addressing: AzureAddressing::PathStyle,
..
} => format!("{authority}/{account}/{container}/{bundle_key}"),
}
}
fn is_safe_for_bundle_uri_emission(ref_path: &str) -> bool {
const FRAMING_AND_URL_UNSAFE: &[u8] = b"=#%&;,?";
!ref_path
.as_bytes()
.iter()
.any(|b| FRAMING_AND_URL_UNSAFE.contains(b))
}
fn host_authority(endpoint: &url::Url) -> String {
let scheme = endpoint.scheme();
let host = endpoint
.host_str()
.expect("RemoteUrl invariant: parse() rejects URLs without a host");
match endpoint.port() {
Some(port) => format!("{scheme}://{host}:{port}"),
None => format!("{scheme}://{host}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::object_store::mock::MockStore;
use crate::packchain::manifest::write_chain;
use crate::packchain::schema::{ChainManifest, ChainSegment, Sha40};
use crate::url::parse;
use bytes::Bytes;
const SHA_TIP: &str = "0000000000000000000000000000000000000001";
const SHA_FULL: &str = "0000000000000000000000000000000000000002";
const SHA_PACK: &str = "1111111111111111111111111111111111111111";
fn sha40(s: &str) -> Sha40 {
Sha40::try_new(s).unwrap()
}
fn ref_main() -> crate::git::RefName {
crate::git::RefName::new("refs/heads/main").unwrap()
}
async fn write_test_chain(
store: &MockStore,
prefix: Option<&str>,
ref_name: &crate::git::RefName,
tip: &str,
full_at: &str,
) {
let chain = ChainManifest {
v: 1,
tip: sha40(tip),
full_at: sha40(full_at),
segments: vec![ChainSegment {
sha: sha40(tip),
parent_sha: None,
pack: format!("packs/{SHA_PACK}.pack"),
bytes: 1_024,
}],
};
write_chain(store, prefix, ref_name, &chain).await.unwrap();
}
#[tokio::test]
async fn empty_bucket_emits_just_terminator() {
let store = MockStore::new();
let remote =
parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?bundle_uri=1").unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.unwrap();
assert_eq!(&buf, b"\n", "empty bucket must emit only the terminator");
}
#[tokio::test]
async fn emits_one_entry_per_ref_with_canonical_s3_url() {
let store = MockStore::new();
write_test_chain(&store, Some("repo"), &ref_main(), SHA_TIP, SHA_FULL).await;
let remote = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.unwrap();
let text = std::str::from_utf8(&buf).unwrap();
assert_eq!(
text,
format!(
"bundle.refs/heads/main.uri=https://my-bucket.s3.us-west-2.amazonaws.com/repo/refs/heads/main/{SHA_FULL}.bundle\n\
bundle.refs/heads/main.creationToken={SHA_FULL}\n\
\n"
),
);
}
#[tokio::test]
async fn s3_path_style_url_uses_bucket_in_path() {
let store = MockStore::new();
write_test_chain(&store, Some("repo"), &ref_main(), SHA_TIP, SHA_FULL).await;
let remote = parse(
"s3+https://s3.us-west-2.amazonaws.com/my-bucket/repo?addressing=path&engine=packchain&bundle_uri=1",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.unwrap();
let text = std::str::from_utf8(&buf).unwrap();
assert!(
text.contains(&format!(
"uri=https://s3.us-west-2.amazonaws.com/my-bucket/repo/refs/heads/main/{SHA_FULL}.bundle\n",
)),
"{text}",
);
}
#[tokio::test]
async fn azure_virtual_hosted_url_uses_account_subdomain() {
let store = MockStore::new();
write_test_chain(&store, Some("repo"), &ref_main(), SHA_TIP, SHA_FULL).await;
let remote = parse(
"az+https://myaccount.blob.core.windows.net/my-container/repo?engine=packchain&bundle_uri=1",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.unwrap();
let text = std::str::from_utf8(&buf).unwrap();
assert!(
text.contains(&format!(
"uri=https://myaccount.blob.core.windows.net/my-container/repo/refs/heads/main/{SHA_FULL}.bundle\n",
)),
"{text}",
);
}
#[tokio::test]
async fn presign_ttl_emits_presigned_url_via_dispatch() {
let store = MockStore::new();
store.set_presign_stub(Some(|key: &str, ttl: std::time::Duration| {
Ok(format!(
"https://stub.example/{key}?X-Amz-Expires={}&X-Amz-Signature=DEADBEEF",
ttl.as_secs(),
))
}));
write_test_chain(&store, Some("repo"), &ref_main(), SHA_TIP, SHA_FULL).await;
let remote = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=3600",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(
&store,
&remote,
BundleUriOpts {
presign_ttl_seconds: Some(NonZeroU64::new(3_600).unwrap()),
},
true,
&mut buf,
)
.await
.expect("handler succeeds against a presign-capable backend");
let text = std::str::from_utf8(&buf).unwrap();
assert!(
text.contains("uri=https://stub.example/"),
"presigned URL must reach the wire output: {text}",
);
assert!(
text.contains("X-Amz-Expires=3600"),
"TTL must be honoured (3600s): {text}",
);
assert!(
text.contains("X-Amz-Signature=DEADBEEF"),
"signature query param must be present: {text}",
);
}
#[tokio::test]
async fn presign_ttl_against_unsupporting_backend_warn_skips_entries() {
let store = MockStore::new();
write_test_chain(&store, Some("repo"), &ref_main(), SHA_TIP, SHA_FULL).await;
let remote = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=3600",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(
&store,
&remote,
BundleUriOpts {
presign_ttl_seconds: Some(NonZeroU64::new(3_600).unwrap()),
},
true,
&mut buf,
)
.await
.expect("warn-and-skip never aborts the helper");
assert_eq!(
std::str::from_utf8(&buf).unwrap(),
"\n",
"presigning unsupported by backend → only the terminator reaches the wire",
);
}
#[tokio::test]
async fn skips_chain_json_with_equals_in_ref_name() {
let store = MockStore::new();
write_test_chain(&store, Some("repo"), &ref_main(), SHA_TIP, SHA_FULL).await;
store.insert(
"repo/refs/heads/x=evil/chain.json",
Bytes::from(
format!(r#"{{"v":1,"tip":"{SHA_TIP}","full_at":"{SHA_TIP}","segments":[]}}"#)
.into_bytes(),
),
);
let remote = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.unwrap();
let text = std::str::from_utf8(&buf).unwrap();
assert!(
!text.contains("x=evil"),
"no entry containing `=` in the ref-name segment may reach the wire output: {text}",
);
assert!(text.contains("bundle.refs/heads/main.uri="), "{text}");
}
#[test]
fn is_safe_for_bundle_uri_emission_accepts_typical_ref_paths() {
for path in &[
"refs/heads/main",
"refs/heads/feature/foo-bar.baz",
"refs/heads/release-1.0.0",
"refs/tags/v1",
] {
assert!(
is_safe_for_bundle_uri_emission(path),
"expected `{path}` to be accepted",
);
}
}
#[test]
fn is_safe_for_bundle_uri_emission_rejects_equals() {
for path in &[
"refs/heads/x=y",
"refs/heads/=",
"=refs/heads/main",
"refs/heads/main=",
"refs/heads/main=evil.attacker",
] {
assert!(
!is_safe_for_bundle_uri_emission(path),
"expected `{path}` to be rejected",
);
}
}
#[test]
fn is_safe_for_bundle_uri_emission_rejects_url_reserved_bytes() {
for unsafe_byte in ['#', '%', '&', ';', ',', '?'] {
for path in &[
format!("refs/heads/x{unsafe_byte}y"),
format!("{unsafe_byte}refs/heads/main"),
format!("refs/heads/main{unsafe_byte}"),
] {
assert!(
!is_safe_for_bundle_uri_emission(path),
"expected `{path}` to be rejected (byte = {unsafe_byte:?})",
);
}
}
}
#[tokio::test]
async fn skips_chain_json_with_url_reserved_bytes_in_ref_name() {
for unsafe_byte in ['#', '%', '&', ';', ','] {
let store = MockStore::new();
write_test_chain(&store, Some("repo"), &ref_main(), SHA_TIP, SHA_FULL).await;
let bad_key = format!("repo/refs/heads/x{unsafe_byte}evil/chain.json");
store.insert(
&bad_key,
Bytes::from(
format!(r#"{{"v":1,"tip":"{SHA_TIP}","full_at":"{SHA_TIP}","segments":[]}}"#)
.into_bytes(),
),
);
let remote = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.unwrap();
let text = std::str::from_utf8(&buf).unwrap();
let needle = format!("x{unsafe_byte}evil");
assert!(
!text.contains(&needle),
"no entry containing `{needle}` may reach the wire output: {text}",
);
assert!(
text.contains("bundle.refs/heads/main.uri="),
"good ref dropped for byte {unsafe_byte:?}: {text}",
);
}
}
#[tokio::test]
async fn skips_chain_json_with_path_traversal_in_ref_name() {
let store = MockStore::new();
write_test_chain(&store, Some("repo"), &ref_main(), SHA_TIP, SHA_FULL).await;
store.insert(
"repo/refs/heads/../etc/passwd/chain.json",
Bytes::from(
format!(r#"{{"v":1,"tip":"{SHA_TIP}","full_at":"{SHA_TIP}","segments":[]}}"#)
.into_bytes(),
),
);
let remote = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.unwrap();
let text = std::str::from_utf8(&buf).unwrap();
assert!(
!text.contains(".."),
"no entry containing `..` may reach the wire output: {text}",
);
assert!(text.contains("bundle.refs/heads/main.uri="), "{text}");
}
#[tokio::test]
async fn corrupt_chain_json_is_skipped() {
let store = MockStore::new();
write_test_chain(&store, Some("repo"), &ref_main(), SHA_TIP, SHA_FULL).await;
store.insert(
"repo/refs/heads/broken/chain.json",
Bytes::from_static(b"{not valid json"),
);
let remote = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.unwrap();
let text = std::str::from_utf8(&buf).unwrap();
assert!(text.contains("bundle.refs/heads/main.uri="), "{text}");
assert!(!text.contains("bundle.refs/heads/broken"), "{text}");
}
#[tokio::test]
async fn entries_are_sorted_alphabetically_by_ref_path() {
let store = MockStore::new();
let zulu = crate::git::RefName::new("refs/heads/zulu").unwrap();
let main = crate::git::RefName::new("refs/heads/main").unwrap();
let alpha = crate::git::RefName::new("refs/heads/alpha").unwrap();
write_test_chain(&store, Some("repo"), &zulu, SHA_TIP, SHA_FULL).await;
write_test_chain(&store, Some("repo"), &main, SHA_TIP, SHA_FULL).await;
write_test_chain(&store, Some("repo"), &alpha, SHA_TIP, SHA_FULL).await;
let remote = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.unwrap();
let text = std::str::from_utf8(&buf).unwrap();
let alpha_pos = text
.find("bundle.refs/heads/alpha.uri=")
.expect("alpha entry present");
let main_pos = text
.find("bundle.refs/heads/main.uri=")
.expect("main entry present");
let zulu_pos = text
.find("bundle.refs/heads/zulu.uri=")
.expect("zulu entry present");
assert!(
alpha_pos < main_pos && main_pos < zulu_pos,
"entries must appear in lexical ref-path order; got\n{text}",
);
}
#[tokio::test]
async fn root_prefix_emits_bare_bundle_keys() {
let store = MockStore::new();
write_test_chain(&store, None, &ref_main(), SHA_TIP, SHA_FULL).await;
let remote =
parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/?engine=packchain&bundle_uri=1")
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.unwrap();
let text = std::str::from_utf8(&buf).unwrap();
assert!(
text.contains(&format!(
"uri=https://my-bucket.s3.us-west-2.amazonaws.com/refs/heads/main/{SHA_FULL}.bundle\n",
)),
"{text}",
);
}
struct FailListStore;
#[allow(clippy::unreachable)]
#[async_trait::async_trait]
impl crate::object_store::ObjectStore for FailListStore {
async fn list(
&self,
_prefix: &str,
) -> Result<Vec<crate::object_store::ObjectMeta>, crate::object_store::ObjectStoreError>
{
Err(crate::object_store::ObjectStoreError::Network(Box::new(
std::io::Error::other("simulated transport failure"),
)))
}
async fn get_to_file(
&self,
_key: &str,
_dest: &std::path::Path,
_opts: crate::object_store::GetOpts,
) -> Result<(), crate::object_store::ObjectStoreError> {
unreachable!("bundle-uri does not call get_to_file")
}
async fn get_bytes(
&self,
_key: &str,
) -> Result<bytes::Bytes, crate::object_store::ObjectStoreError> {
unreachable!("bundle-uri does not reach get_bytes when list fails")
}
async fn get_bytes_range(
&self,
_key: &str,
_range: std::ops::Range<u64>,
) -> Result<bytes::Bytes, crate::object_store::ObjectStoreError> {
unreachable!("bundle-uri does not call get_bytes_range")
}
async fn put_bytes(
&self,
_key: &str,
_body: bytes::Bytes,
_opts: crate::object_store::PutOpts,
) -> Result<(), crate::object_store::ObjectStoreError> {
unreachable!("bundle-uri does not call put_bytes")
}
async fn put_if_absent(
&self,
_key: &str,
_body: bytes::Bytes,
) -> Result<bool, crate::object_store::ObjectStoreError> {
unreachable!("bundle-uri does not call put_if_absent")
}
async fn head(
&self,
_key: &str,
) -> Result<crate::object_store::ObjectMeta, crate::object_store::ObjectStoreError>
{
unreachable!("bundle-uri does not call head")
}
async fn copy(
&self,
_src: &str,
_dst: &str,
) -> Result<(), crate::object_store::ObjectStoreError> {
unreachable!("bundle-uri does not call copy")
}
async fn delete(&self, _key: &str) -> Result<(), crate::object_store::ObjectStoreError> {
unreachable!("bundle-uri does not call delete")
}
}
#[tokio::test]
async fn list_failure_emits_empty_response_rather_than_aborting() {
let store = FailListStore;
let remote = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
)
.unwrap();
let mut buf: Vec<u8> = Vec::new();
handle_bundle_uri(&store, &remote, BundleUriOpts::default(), true, &mut buf)
.await
.expect("list failure must not surface as a hard error");
assert_eq!(
&buf, b"\n",
"list failure must yield only the trailing terminator",
);
}
}