From 62ebe029209b2077ff1dded554f0023ae8c85a45 Mon Sep 17 00:00:00 2001 From: rzonyx <77541804+rzonyx@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:17:23 +0700 Subject: [PATCH] Pull OCI datasets with the docker config's credentials `run`, `datasets download` and `datasets list` opened every registry anonymously, so a dataset in a private package failed with "not found, or not public" even after `docker login`, while `push` and `export` already read the same login. Store::open now takes the registry's credentials from the docker config as they do ($DOCKER_CONFIG/config.json, else ~/.docker/config.json: its credential helper, else its auths entry), and stays anonymous without any. An OCI layout reads no credentials. A registry with stale credentials in the config now refuses pulls of its public datasets instead of falling back to anonymous, as `docker pull` and `export` do. The anonymous error says to log in. Co-Authored-By: Claude Opus 5.5 --- docs/REFERENCE.md | 8 +++++--- src/oci/store.rs | 21 +++++++++++++++++---- src/oci/tests.rs | 20 ++++++++++++++++++++ src/oci/tests/publish.rs | 9 ++++++++- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index a0d85ba..6aa848c 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -178,8 +178,10 @@ hbench datasets list --registry oci://ghcr.io/ # what it ``` - `oci:///`: `-d @` is the manifest tagged `` in - `//`. Pulls are anonymous (with a registry token where the registry asks for one, as - GHCR does) and over HTTPS, except to `localhost`, `127.0.0.1` and `::1`, which use plain HTTP. + `//`. Pulls use the docker config's credentials for the registry, as `push` does (see + [Publishing datasets](#publishing-datasets)), so a private dataset runs after `docker login`; without any they are + anonymous (with a registry token where the registry asks for one, as GHCR does). They are over HTTPS, except to + `localhost`, `127.0.0.1` and `::1`, which use plain HTTP. - `oci-layout:`: `/` is an OCI image layout, and the dataset is the manifest in its `index.json` whose `org.opencontainers.image.ref.name` is ``. `oras cp //: --to-oci-layout /:` makes one, for offline use. @@ -245,7 +247,7 @@ hbench datasets export -d hello@1.0 --registry oci://ghcr.io/ --oci-l targets, `task_checksum` and package digest. Any difference fails the command. - `export` copies `name@version` from `--registry` (as `run` takes it) into `/`, tagged with the version and checked blob by blob, so that `--registry oci-layout:` runs it offline. It reads with the docker config's - credentials; `run` and `datasets download` pull anonymously. + credentials, as `run` and `datasets download` do. ### Dataset format diff --git a/src/oci/store.rs b/src/oci/store.rs index 7be23a0..68e848b 100644 --- a/src/oci/store.rs +++ b/src/oci/store.rs @@ -1,5 +1,5 @@ //! Where a dataset's manifests and blobs come from and go to: an OCI registry, pulled into the content-addressed -//! blob cache (anonymously, unless the store was opened with credentials), or OCI image layouts on disk, +//! blob cache with the docker config's credentials for it (anonymously without any), or OCI image layouts on disk, //! `/`. Every manifest and blob is checked against its digest before it is used. use super::format::{ARTIFACT_TYPE, INDEX_MEDIA_TYPE, MANIFEST_MEDIA_TYPE, MAX_MANIFEST_BYTES, REF_NAME}; @@ -35,9 +35,20 @@ fn is_loopback(registry: &str) -> bool { impl Store { /// The store for a task's or dataset's `registry` (a registry host, or `oci-layout:`), caching - /// registry blobs under `cache_root/blobs/sha256`. + /// registry blobs under `cache_root/blobs/sha256` and reading with the credentials the docker config has for it. pub fn open(registry: &str, cache_root: &Path) -> Result { - Store::open_with_auth(registry, cache_root, RegistryAuth::Anonymous) + Store::open_with_config(registry, cache_root, super::credentials::docker_config_path().as_deref()) + } + + /// [`Store::open`], with the docker config at `docker_config` (anonymous when there is none). An OCI layout + /// reads no credentials. + pub fn open_with_config(registry: &str, cache_root: &Path, docker_config: Option<&Path>) -> Result { + let auth = if registry.starts_with(LAYOUT_SCHEME) { + RegistryAuth::Anonymous + } else { + super::credentials::registry_auth(docker_config, registry)? + }; + Store::open_with_auth(registry, cache_root, auth) } /// [`Store::open`], authenticating to a registry with `auth`. @@ -314,7 +325,9 @@ impl Store { fn fetch_error(reference: &str, auth: &RegistryAuth, e: OciDistributionError) -> anyhow::Error { match (e, auth) { (OciDistributionError::UnauthorizedError { .. }, RegistryAuth::Anonymous) => { - anyhow::anyhow!("{reference}: not found, or not public (hbench pulls anonymously)") + anyhow::anyhow!( + "{reference}: not found, or not public (the docker config has no credentials for this registry, so hbench pulled anonymously; log in with `docker login` or `oras login`)" + ) } (OciDistributionError::UnauthorizedError { .. }, _) => anyhow::anyhow!("{reference}: not found, or the credentials can't read it"), (e, _) => anyhow::Error::new(e).context(format!("fetching {reference}")), diff --git a/src/oci/tests.rs b/src/oci/tests.rs index b72f84a..6c5a1ef 100644 --- a/src/oci/tests.rs +++ b/src/oci/tests.rs @@ -363,6 +363,26 @@ fn registry_specs() { assert!(Location::parse("oci://ghcr.io/ns").unwrap().repository("Terminal-Bench").is_err()); } +#[test] +fn a_registry_store_reads_the_docker_config_and_a_layout_store_does_not() { + use oci_client::secrets::RegistryAuth; + let root = scratch("pull-auth"); + let config = root.join("config.json"); + std::fs::write(&config, r#"{"auths": {"registry.example": {"username": "reader", "password": "pw"}}}"#).unwrap(); + let auth = |registry: &str| match super::store::Store::open_with_config(registry, &root, Some(&config)).unwrap() { + super::store::Store::Registry { auth, .. } => auth, + super::store::Store::Layout { .. } => panic!("{registry} is a registry"), + }; + assert_eq!(auth("registry.example"), RegistryAuth::Basic("reader".into(), "pw".into())); + assert_eq!(auth("other.example"), RegistryAuth::Anonymous); + + std::fs::write(&config, "not json").unwrap(); + let err = super::store::Store::open_with_config("registry.example", &root, Some(&config)).err().unwrap(); + assert!(format!("{err:#}").contains("parsing"), "{err:#}"); + let layout = format!("oci-layout:{}", root.display()); + assert!(matches!(super::store::Store::open_with_config(&layout, &root, Some(&config)), Ok(super::store::Store::Layout { .. }))); +} + #[test] fn lock_json_records_oci_tasks() { let oci = OciTask { diff --git a/src/oci/tests/publish.rs b/src/oci/tests/publish.rs index 444dab1..3d517db 100644 --- a/src/oci/tests/publish.rs +++ b/src/oci/tests/publish.rs @@ -411,8 +411,15 @@ async fn push_and_export_use_the_docker_config_credentials() { let registry_spec = format!("oci://{registry}/{namespace}"); let offline = root.join("offline"); let err = format!("{:#}", publish::export("examples@1.0", ®istry_spec, &offline, RegistryAuth::Anonymous).await.unwrap_err()); - assert!(err.contains("not public (hbench pulls anonymously)"), "{err}"); + assert!(err.contains("not public (the docker config has no credentials for this registry"), "{err}"); let exported = publish::export("examples@1.0", ®istry_spec, &offline, auth).await.unwrap(); assert_eq!(exported.digest, packed.digest); assert_reads_back_identical(format!("oci-layout:{}", offline.display()), &root.join("cache")).await; + + let repository = format!("{namespace}/examples"); + let pulled = Store::open_with_config(®istry, &root.join("pull"), Some(&docker_config)).unwrap(); + assert_eq!(pulled.resolve_tag(&repository, "1.0").await.unwrap(), packed.digest, "a pull reads with the credentials"); + let anonymous = Store::open_with_config(®istry, &root.join("pull"), None).unwrap(); + let err = format!("{:#}", anonymous.resolve_tag(&repository, "1.0").await.unwrap_err()); + assert!(err.contains("log in with `docker login`"), "{err}"); }