Skip to content

Commit

Permalink
feat: Add the package digest to list_package (#163)
Browse files Browse the repository at this point in the history
The (html) index of package files now contains the SHA256 digest of the
package.

closes: #160
  • Loading branch information
AllexVeldman authored Jan 31, 2025
1 parent 968afe8 commit 7fd3ccd
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 8 deletions.
6 changes: 3 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ the key/value pair will be added to the ImageManifest annotations.
# References

### PyPi
- Simple API: https://peps.python.org/pep-0503/
- Simple JSON extention: https://peps.python.org/pep-0691/#content-types
- JSON API: https://warehouse.pypa.io/api-reference/json.html#
- Simple API PEP: https://peps.python.org/pep-0503/
- Simple JSON extention PEP: https://peps.python.org/pep-0691/#content-types
- API definitions: https://docs.pypi.org/api/

### Python packaging
- Name normalization: https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization
Expand Down
9 changes: 7 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,13 @@ impl UploadForm {
if let Some(label) = classifier.strip_prefix("PyOCI :: Label :: ") {
if let [key, value] = label.splitn(2, " :: ").collect::<Vec<_>>()[..] {
labels.insert(key.to_string(), value.to_string());
};
}
debug!("Found label '{key}={value}'");
} else {
debug!("Invalid PyOci label '{label}'");
}
} else {
debug!("Discarding field 'classifiers': {classifier}");
};
}
"sha256_digest" => sha256 = Some(field.text().await?),
name => debug!("Discarding field '{name}': {}", field.text().await?),
Expand Down
28 changes: 26 additions & 2 deletions src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub struct Package<'a, T: FileState> {
name: String,
version: Option<String>,
arch: Option<String>,
sha256: Option<String>,
_phantom: PhantomData<T>,
}

Expand All @@ -39,6 +40,7 @@ impl<'a, T: FileState> Package<'a, T> {
name: self.name.to_owned(),
version: Some(tag.replace('-', "+")),
arch: Some(arch.to_string()),
sha256: None,
_phantom: PhantomData,
}
}
Expand Down Expand Up @@ -92,6 +94,7 @@ impl Package<'_, WithoutFileName> {
name,
version: None,
arch: None,
sha256: None,
_phantom: PhantomData,
}
}
Expand Down Expand Up @@ -139,10 +142,15 @@ impl Package<'_, WithFileName> {
name: name.to_string(),
version: Some(version.to_string()),
arch: Some(arch.to_string()),
sha256: None,
_phantom: PhantomData,
})
}

pub fn with_sha256(self, sha256: Option<String>) -> Self {
Self { sha256, ..self }
}

/// Tag of the package as used for the OCI registry
pub fn oci_tag(&self) -> String {
// OCI tags are not allowed to contain a "+" character
Expand All @@ -168,13 +176,18 @@ impl Package<'_, WithFileName> {
.strip_prefix("https://")
.unwrap_or(self.registry);
let registry = urlencoding::encode(registry);
format!(
let uri = format!(
"/{}/{}/{}/{}",
registry,
self.namespace,
self.name,
self.filename()
)
);
if let Some(sha256) = &self.sha256 {
format!("{}#sha256={}", uri, sha256)
} else {
uri
}
}

/// Return the filename of this package
Expand Down Expand Up @@ -248,6 +261,17 @@ mod tests {
);
}

#[test]
fn test_info_py_uri_with_sha() {
let info = Package::from_filename("https://foo.example:4000", "bar", "baz-1.tar.gz")
.unwrap()
.with_sha256(Some("12345".into()));
assert_eq!(
info.py_uri(),
"/foo.example%3A4000/bar/baz/baz-1.tar.gz#sha256=12345".to_string()
);
}

#[test]
/// Test Info.with_oci_file() return an Info object with the new version
fn test_info_with_oci_file() {
Expand Down
116 changes: 115 additions & 1 deletion src/pyoci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ where
tracing::debug!("{:?}", result);
Ok(result.tags().to_owned())
}

/// List all files for the given package
///
/// Limits the number of files to `n`
Expand Down Expand Up @@ -287,7 +288,16 @@ where
for manifest in index.manifests() {
match manifest.platform().as_ref().unwrap().architecture() {
oci_spec::image::Arch::Other(arch) => {
let file = package.with_oci_file(reference, arch);
let sha256_digest = if let Some(annotations) = manifest.annotations() {
annotations
.get("com.pyoci.sha256_digest")
.map(|v| v.to_string())
} else {
None
};
let file = package
.with_oci_file(reference, arch)
.with_sha256(sha256_digest);
files.push(file);
}
arch => bail!("Unsupported architecture '{}'", arch),
Expand Down Expand Up @@ -967,6 +977,110 @@ mod tests {
assert_eq!(err.status, StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn package_info_for_ref() {
let mut server = mockito::Server::new_async().await;
let url = server.url();

// Existing ImageIndex
let index = r#"{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"artifactType": "application/pyoci.package.v1",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:0d749abe1377573493e0df74df8d1282e46967754a1ebc7cc6323923a788ad5c",
"size": 6,
"platform": {
"architecture": ".tar.gz",
"os": "any"
}
}
],
"annotations": {
"created": "yesterday"
}
}"#;
server
.mock("GET", "/v2/mockserver/bar/manifests/1")
.with_status(200)
.with_header("content-type", "application/vnd.oci.image.index.v1+json")
.with_body(index)
.create_async()
.await;

let pyoci = PyOci {
registry: Url::parse(&url).expect("valid url"),
transport: HttpTransport::new(None).unwrap(),
};

let package = Package::new("ghcr.io", "mockserver", "bar");

let result = pyoci
.package_info_for_ref(&package, "mockserver/bar", "1")
.await
.expect("Valid response");

assert_eq!(result.len(), 1);
assert_eq!(result[0].py_uri(), "/ghcr.io/mockserver/bar/bar-1.tar.gz");
}

#[tokio::test]
async fn package_info_for_ref_sha256_digest() {
let mut server = mockito::Server::new_async().await;
let url = server.url();

// Existing ImageIndex
let index = r#"{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"artifactType": "application/pyoci.package.v1",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:0d749abe1377573493e0df74df8d1282e46967754a1ebc7cc6323923a788ad5c",
"size": 6,
"platform": {
"architecture": ".tar.gz",
"os": "any"
},
"annotations":{
"com.pyoci.sha256_digest": "12345"
}
}
],
"annotations": {
"created": "yesterday"
}
}"#;
server
.mock("GET", "/v2/mockserver/bar/manifests/1")
.with_status(200)
.with_header("content-type", "application/vnd.oci.image.index.v1+json")
.with_body(index)
.create_async()
.await;

let pyoci = PyOci {
registry: Url::parse(&url).expect("valid url"),
transport: HttpTransport::new(None).unwrap(),
};

let package = Package::new("ghcr.io", "mockserver", "bar");

let result = pyoci
.package_info_for_ref(&package, "mockserver/bar", "1")
.await
.expect("Valid response");

assert_eq!(result.len(), 1);
assert_eq!(
result[0].py_uri(),
"/ghcr.io/mockserver/bar/bar-1.tar.gz#sha256=12345"
);
}

#[test]
fn image_manifest() {
let pyoci = PyOci {
Expand Down

0 comments on commit 7fd3ccd

Please sign in to comment.