Skip to content

Commit

Permalink
Migrate inventory code (#861)
Browse files Browse the repository at this point in the history
* Add src files from inventory repo

Copy all files currently living in https://github.com/Malax/inventory/tree/main/inventory/src without any changes.

Co-Authored-By: Manuel Fuchs <[email protected]>
Co-Authored-By: Josh W Lewis <[email protected]>

* Merge lib.rs and inventory.rs

* Declare inventory module and feature

* Prefer PhantomData

* Add must_use attribute

* Remove unused import

* Allow unwrap for now

* Add sha2 feature

We may want to consider another approach here (e.g. naming the feature `inventory-sha2` and/or pulling in the inventory dependency ["inventory", "dep:sha2"]).

While the crate will be pulled in if a user enables the `sha2`, the inventory-specific sha2 code won't be compiled unless the `inventory` feature is also enabled.

* Add semver feature

* Include patch version in dependency requirements

* Add inventory toml dependency

* Add inventory thiserror dependency

* Add changelog entry

* Allow unreachable pub for re-exports

Also see Malax/inventory#2 (comment)

* Remove semver and sha2 re-exports

These re-exports appear to be unnecessary, and doesn't have any impact on code that rely on the `inventory` module (and have the `semver` and/or `sha2` features enabled). Also see related prior discussion here Malax/inventory#2 (comment).

The `semver` and `sha2` modules only contain implementations of public traits, so I don't think we need to re-export those (unlike bringing for instance a function or struct in to scope).

If I understand how trait implementations work correctly, it doesn't matter where an implementation lives (only the visibility of the trait and the type it's implemented for is relevant) - in other words, the implementation will be available to any code that can access both the trait and the type, even across crate binaries.

* Rename `semver` feature to `inventory-semver`

* Rename `sha2` feature to `inventory-sha2`

* [stacked] Add docs to inventory code (#864)

* Add module docs and example

* Document resolve and partial_resolve

* Document more inventory methods

* Document Artifact

* Document ArtifactRequirement

I also suggest we change the name of `inventory/version` to `inventory/artifact_requirement.rs` or `requirement.rs`.

* Update example to compare Checksum instead of string

* Apply suggestions from code review

Co-authored-by: Rune Soerensen <[email protected]>

* Update feature names

* Rewrite example usage

* Show how to display checksum in example

---------

Co-authored-by: Rune Soerensen <[email protected]>

* Group inventory features alphabetically

---------

Co-authored-by: Manuel Fuchs <[email protected]>
Co-authored-by: Josh W Lewis <[email protected]>
Co-authored-by: Richard Schneeman <[email protected]>
  • Loading branch information
4 people authored Oct 16, 2024
1 parent ba5e823 commit f8dc601
Show file tree
Hide file tree
Showing 10 changed files with 695 additions and 1 deletion.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `libherokubuildpack`:
- Added `inventory` module. ([#861](https://github.com/heroku/libcnb.rs/pull/861))

## [0.23.0] - 2024-08-28

Expand Down
9 changes: 8 additions & 1 deletion libherokubuildpack/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ all-features = true
workspace = true

[features]
default = ["command", "download", "digest", "error", "log", "tar", "toml", "fs", "write", "buildpack_output"]
default = ["command", "download", "digest", "error", "inventory", "log", "inventory-semver", "inventory-sha2", "tar", "toml", "fs", "write", "buildpack_output"]
download = ["dep:ureq", "dep:thiserror"]
digest = ["dep:sha2"]
error = ["log", "dep:libcnb"]
inventory = ["dep:hex", "dep:serde", "dep:thiserror", "dep:toml"]
inventory-semver = ["dep:semver"]
inventory-sha2 = ["dep:sha2"]
log = ["dep:termcolor"]
tar = ["dep:tar", "dep:flate2"]
toml = ["dep:toml"]
Expand All @@ -38,8 +41,11 @@ crossbeam-utils = { version = "0.8.20", optional = true }
# https://github.com/rust-lang/libz-sys/issues/93
# As such we have to use the next best alternate backend, which is `zlib`.
flate2 = { version = "1.0.33", default-features = false, features = ["zlib"], optional = true }
hex = { version = "0.4.3", optional = true }
libcnb = { workspace = true, optional = true }
pathdiff = { version = "0.2.1", optional = true }
semver = { version = "1.0.21", features = ["serde"], optional = true }
serde = { version = "1.0.209", features = ["derive"], optional = true }
sha2 = { version = "0.10.8", optional = true }
tar = { version = "0.4.41", default-features = false, optional = true }
termcolor = { version = "1.4.1", optional = true }
Expand All @@ -50,4 +56,5 @@ ureq = { version = "2.10.1", default-features = false, features = ["tls"], optio
[dev-dependencies]
indoc = "2.0.5"
libcnb-test = { workspace = true }
serde_test = "1.0.177"
tempfile = "3.12.0"
284 changes: 284 additions & 0 deletions libherokubuildpack/src/inventory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
//! # Inventory
//!
//! Many buildpacks need to provide artifacts from different URLs. A helpful pattern
//! is to provide a list of artifacts in a TOML file, which can be parsed and used by
//! the buildpack to download the correct artifact. For example, a Ruby buildpack
//! might need to download pre-compiled Ruby binaries hosted on S3.
//!
//! This module can be used to produce and consume such an inventory file.
//!
//! ## Features
//!
//! - Version lookup and comparison: To implement the inventory, you'll need to define how
//! versions are compared. This allows the inventory code to find an appropriate artifact
//! based on whatever custom version logic you need. If you don't need custom logic, you can
//! use the included `inventory-semver` feature.
//! - Architecture aware: Beyond version specifiers, buildpack authors may need to provide different
//! artifacts for different computer architectures such as ARM64 or AMD64. The inventory encodes
//! this information which is used to select the correct artifact.
//! - Checksum validation: In addition to knowing the URL of an artifact, buildp authors
//! want to be confident that the artifact they download is the correct one. To accomplish this
//! the inventory contains a checksum of the download and can be used to validate the download
//! has not been modified or tampered with. To use sha256 or sha512 checksums out of the box,
//! enable the `inventory-sha2` feature
//! - Extensible with metadata: The default inventory format covers a lot of common use cases,
//! but if you need more, you can extend it by adding custom metadata to each artifact.
//!
//! ## Example usage
//!
//! This example demonstrates:
//! * Creating an artifact using the `inventory-sha2` and `inventory-semver` features.
//! * Adding the artifact to an inventory.
//! * Serializing and deserializing the inventory [to](Inventory#method.fmt) and [from](Inventory::from_str) TOML.
//! * [Resolving an inventory artifact](Inventory::resolve) specifying relevant OS, architecture, and version requirements.
//! * Using the resolved artifact's checksum value to verify "downloaded" data.
//!
//! ```rust
//! use libherokubuildpack::inventory::{artifact::{Arch, Artifact, Os}, Inventory, checksum::Checksum};
//! use semver::{Version, VersionReq};
//! use sha2::{Sha256, Digest};
//!
//! // Create an artifact with a SHA256 checksum and `semver::Version`
//! let new_artifact = Artifact {
//! version: Version::new(1, 0, 0),
//! os: Os::Linux,
//! arch: Arch::Arm64,
//! url: "https://example.com/foo.txt".to_string(),
//! checksum: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
//! .parse::<Checksum<Sha256>>()
//! .unwrap(),
//! metadata: None,
//! };
//!
//! // Create an inventory and add the artifact
//! let mut inventory = Inventory::<Version, Sha256, Option<()>>::new();
//! inventory.push(new_artifact.clone());
//!
//! // Serialize the inventory to TOML
//! let inventory_toml = inventory.to_string();
//! assert_eq!(
//! r#"[[artifacts]]
//! version = "1.0.0"
//! os = "linux"
//! arch = "arm64"
//! url = "https://example.com/foo.txt"
//! checksum = "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
//! "#,
//! inventory_toml
//! );
//!
//! // Deserialize the inventory from TOML
//! let parsed_inventory = inventory_toml
//! .parse::<Inventory<Version, Sha256, Option<()>>>()
//! .unwrap();
//!
//! // Resolve the artifact by OS, architecture, and version requirement
//! let version_req = VersionReq::parse("=1.0.0").unwrap();
//! let resolved_artifact = parsed_inventory.resolve(Os::Linux, Arch::Arm64, &version_req).unwrap();
//!
//! assert_eq!(&new_artifact, resolved_artifact);
//!
//! // Verify checksum of the resolved artifact
//! let downloaded_data = "foo"; // Example downloaded file content
//! let downloaded_checksum = Sha256::digest(downloaded_data).to_vec();
//!
//! assert_eq!(downloaded_checksum, resolved_artifact.checksum.value);
//! println!(
//! "Successfully downloaded {} with checksum {}",
//! resolved_artifact.url,
//! hex::encode(&resolved_artifact.checksum.value)
//! );
//! ```
pub mod artifact;
pub mod checksum;
pub mod version;

#[cfg(feature = "inventory-semver")]
mod semver;
#[cfg(feature = "inventory-sha2")]
mod sha2;
mod unit;

use crate::inventory::artifact::{Arch, Artifact, Os};
use crate::inventory::checksum::Digest;
use crate::inventory::version::ArtifactRequirement;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::fmt::Formatter;
use std::str::FromStr;

/// Represents an inventory of artifacts.
///
/// An inventory can be read directly from a TOML file on disk and used by a buildpack to resolve
/// requirements for a specific artifact to download.
///
/// The inventory can be manipulated in-memory and then re-serialized to disk to facilitate both
/// reading and writing inventory files.
#[derive(Debug, Serialize, Deserialize)]
pub struct Inventory<V, D, M> {
#[serde(bound = "V: Serialize + DeserializeOwned, D: Digest, M: Serialize + DeserializeOwned")]
pub artifacts: Vec<Artifact<V, D, M>>,
}

impl<V, D, M> Default for Inventory<V, D, M> {
fn default() -> Self {
Self { artifacts: vec![] }
}
}

impl<V, D, M> Inventory<V, D, M> {
/// Creates a new empty inventory
#[must_use]
pub fn new() -> Self {
Self::default()
}

/// Add a new artifact to the in-memory inventory
pub fn push(&mut self, artifact: Artifact<V, D, M>) {
self.artifacts.push(artifact);
}

/// Return a single artifact as the best match given the input constraints
///
/// If multiple artifacts match the constraints, the one with the highest version is returned.
pub fn resolve<R>(&self, os: Os, arch: Arch, requirement: &R) -> Option<&Artifact<V, D, M>>
where
V: Ord,
R: ArtifactRequirement<V, M>,
{
self.artifacts
.iter()
.filter(|artifact| {
artifact.os == os
&& artifact.arch == arch
&& requirement.satisfies_version(&artifact.version)
&& requirement.satisfies_metadata(&artifact.metadata)
})
.max_by_key(|artifact| &artifact.version)
}

/// Resolve logic for Artifacts that implement `PartialOrd` rather than `Ord`
///
/// Some version implementations are only partially ordered. One example could be f32 which is not totally ordered
/// because NaN is not comparable to any other number.
pub fn partial_resolve<R>(
&self,
os: Os,
arch: Arch,
requirement: &R,
) -> Option<&Artifact<V, D, M>>
where
V: PartialOrd,
R: ArtifactRequirement<V, M>,
{
#[inline]
fn partial_max_by_key<I, F, A>(iterator: I, f: F) -> Option<I::Item>
where
I: Iterator,
F: Fn(&I::Item) -> A,
A: PartialOrd,
{
iterator.fold(None, |acc, item| match acc {
None => Some(item),
Some(acc) => match f(&item).partial_cmp(&f(&acc)) {
Some(Ordering::Greater | Ordering::Equal) => Some(item),
None | Some(Ordering::Less) => Some(acc),
},
})
}

partial_max_by_key(
self.artifacts.iter().filter(|artifact| {
artifact.os == os
&& artifact.arch == arch
&& requirement.satisfies_version(&artifact.version)
&& requirement.satisfies_metadata(&artifact.metadata)
}),
|artifact| &artifact.version,
)
}
}

#[derive(thiserror::Error, Debug)]
pub enum ParseInventoryError {
#[error("TOML parsing error: {0}")]
TomlError(toml::de::Error),
}

impl<V, D, M> FromStr for Inventory<V, D, M>
where
V: Serialize + DeserializeOwned,
D: Digest,
M: Serialize + DeserializeOwned,
{
type Err = ParseInventoryError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
toml::from_str(s).map_err(ParseInventoryError::TomlError)
}
}

impl<V, D, M> std::fmt::Display for Inventory<V, D, M>
where
V: Serialize + DeserializeOwned,
D: Digest,
M: Serialize + DeserializeOwned,
{
#![allow(clippy::unwrap_used)]
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(&toml::to_string(self).unwrap())
}
}

#[cfg(test)]
mod test {
use crate::inventory::artifact::{Arch, Artifact, Os};
use crate::inventory::checksum::tests::BogusDigest;
use crate::inventory::Inventory;

#[test]
fn test_matching_artifact_resolution() {
let mut inventory = Inventory::new();
inventory.push(create_artifact("foo", Os::Linux, Arch::Arm64));

assert_eq!(
"foo",
&inventory
.resolve(Os::Linux, Arch::Arm64, &String::from("foo"))
.expect("should resolve matching artifact")
.version,
);
}

#[test]
fn test_dont_resolve_artifact_with_wrong_arch() {
let mut inventory = Inventory::new();
inventory.push(create_artifact("foo", Os::Linux, Arch::Arm64));

assert!(inventory
.resolve(Os::Linux, Arch::Amd64, &String::from("foo"))
.is_none());
}

#[test]
fn test_dont_resolve_artifact_with_wrong_version() {
let mut inventory = Inventory::new();
inventory.push(create_artifact("foo", Os::Linux, Arch::Arm64));

assert!(inventory
.resolve(Os::Linux, Arch::Arm64, &String::from("bar"))
.is_none());
}

fn create_artifact(version: &str, os: Os, arch: Arch) -> Artifact<String, BogusDigest, ()> {
Artifact {
version: String::from(version),
os,
arch,
url: "https://example.com".to_string(),
checksum: BogusDigest::checksum("cafebabe"),
metadata: (),
}
}
}
Loading

0 comments on commit f8dc601

Please sign in to comment.