use std::env;
use std::process::Command;
pub const KEY_PREFIX: &str = "io.hyperi.contract";
pub const VERSION: &str = "v1";
#[derive(Debug, thiserror::Error)]
pub enum IdentityError {
#[error("source_commit must be 40-char lowercase hex (got {got:?})")]
InvalidSourceCommit {
got: String,
},
#[error(
"image_ref must include an explicit registry host (got {got:?}); \
no implicit docker.io/library/ prefix allowed"
)]
InvalidImageRef {
got: String,
},
#[error(
"could not auto-detect source_commit: GITHUB_SHA env var is unset \
or invalid, and `git rev-parse HEAD` failed: {reason}"
)]
DetectFailed {
reason: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ContractIdentity {
source_commit: String,
image_ref: String,
}
impl ContractIdentity {
pub fn new(
source_commit: impl Into<String>,
image_ref: impl Into<String>,
) -> Result<Self, IdentityError> {
let source_commit = source_commit.into();
let image_ref = image_ref.into();
validate_source_commit(&source_commit)?;
validate_image_ref(&image_ref)?;
Ok(Self {
source_commit,
image_ref,
})
}
pub fn detect(image_ref: impl Into<String>) -> Result<Self, IdentityError> {
let source_commit = detect_source_commit()?;
Self::new(source_commit, image_ref)
}
#[must_use]
pub fn version(&self) -> &'static str {
VERSION
}
#[must_use]
pub fn source_commit(&self) -> &str {
&self.source_commit
}
#[must_use]
pub fn image_ref(&self) -> &str {
&self.image_ref
}
#[must_use]
pub fn as_dockerfile_labels(&self) -> String {
format!(
"LABEL {KEY_PREFIX}.version=\"{VERSION}\"\n\
LABEL {KEY_PREFIX}.source-commit=\"{c}\"\n\
LABEL {KEY_PREFIX}.image-ref=\"{r}\"",
c = self.source_commit,
r = self.image_ref,
)
}
#[must_use]
pub fn as_yaml_annotations(&self, indent: usize) -> String {
let pad = " ".repeat(indent);
format!(
"{pad}{KEY_PREFIX}.version: \"{VERSION}\"\n\
{pad}{KEY_PREFIX}.source-commit: \"{c}\"\n\
{pad}{KEY_PREFIX}.image-ref: \"{r}\"",
c = self.source_commit,
r = self.image_ref,
)
}
}
fn validate_source_commit(s: &str) -> Result<(), IdentityError> {
if s.len() != 40 || !s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
return Err(IdentityError::InvalidSourceCommit { got: s.to_string() });
}
Ok(())
}
fn validate_image_ref(s: &str) -> Result<(), IdentityError> {
if s.is_empty() {
return Err(IdentityError::InvalidImageRef { got: s.to_string() });
}
let Some((host, _rest)) = s.split_once('/') else {
return Err(IdentityError::InvalidImageRef { got: s.to_string() });
};
if host == "localhost" || host.contains('.') || host.contains(':') {
Ok(())
} else {
Err(IdentityError::InvalidImageRef { got: s.to_string() })
}
}
fn detect_source_commit() -> Result<String, IdentityError> {
if let Ok(sha) = env::var("GITHUB_SHA") {
let sha = sha.trim().to_lowercase();
if validate_source_commit(&sha).is_ok() {
return Ok(sha);
}
}
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.map_err(|e| IdentityError::DetectFailed {
reason: e.to_string(),
})?;
if !output.status.success() {
return Err(IdentityError::DetectFailed {
reason: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
let sha = String::from_utf8_lossy(&output.stdout)
.trim()
.to_lowercase();
validate_source_commit(&sha)?;
Ok(sha)
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
#[test]
fn new_accepts_valid_inputs() {
let id = ContractIdentity::new(VALID_SHA, "ghcr.io/hyperi-io/dfe-loader:v2.7.2").unwrap();
assert_eq!(id.version(), "v1");
assert_eq!(id.source_commit(), VALID_SHA);
assert_eq!(id.image_ref(), "ghcr.io/hyperi-io/dfe-loader:v2.7.2");
}
#[test]
fn new_rejects_short_source_commit() {
let err = ContractIdentity::new("abc123", "ghcr.io/x/y:v1").unwrap_err();
assert!(matches!(err, IdentityError::InvalidSourceCommit { .. }));
}
#[test]
fn new_rejects_uppercase_source_commit() {
let upper = "0123456789ABCDEF0123456789ABCDEF01234567";
let err = ContractIdentity::new(upper, "ghcr.io/x/y:v1").unwrap_err();
assert!(matches!(err, IdentityError::InvalidSourceCommit { .. }));
}
#[test]
fn new_rejects_source_commit_with_sha256_prefix() {
let prefixed = format!("sha256:{VALID_SHA}");
let err = ContractIdentity::new(prefixed, "ghcr.io/x/y:v1").unwrap_err();
assert!(matches!(err, IdentityError::InvalidSourceCommit { .. }));
}
#[test]
fn new_rejects_empty_image_ref() {
let err = ContractIdentity::new(VALID_SHA, "").unwrap_err();
assert!(matches!(err, IdentityError::InvalidImageRef { .. }));
}
#[test]
fn new_rejects_bare_docker_hub_shortcut() {
let err = ContractIdentity::new(VALID_SHA, "nginx:1.25").unwrap_err();
assert!(matches!(err, IdentityError::InvalidImageRef { .. }));
}
#[test]
fn new_rejects_bare_repo_path_without_host() {
let err = ContractIdentity::new(VALID_SHA, "library/nginx:1.25").unwrap_err();
assert!(matches!(err, IdentityError::InvalidImageRef { .. }));
}
#[test]
fn new_accepts_localhost_registry() {
let id = ContractIdentity::new(VALID_SHA, "localhost:5000/dfe-loader:test").unwrap();
assert_eq!(id.image_ref(), "localhost:5000/dfe-loader:test");
}
#[test]
fn new_accepts_digest_form() {
let digest_ref = format!("ghcr.io/hyperi-io/dfe-loader@sha256:{VALID_SHA}");
let id = ContractIdentity::new(VALID_SHA, digest_ref.clone()).unwrap();
assert_eq!(id.image_ref(), digest_ref);
}
#[test]
fn dockerfile_labels_canonical_order_and_quoting() {
let id = ContractIdentity::new(VALID_SHA, "ghcr.io/hyperi-io/dfe-loader:v2.7.2").unwrap();
let out = id.as_dockerfile_labels();
assert_eq!(
out,
"LABEL io.hyperi.contract.version=\"v1\"\n\
LABEL io.hyperi.contract.source-commit=\"0123456789abcdef0123456789abcdef01234567\"\n\
LABEL io.hyperi.contract.image-ref=\"ghcr.io/hyperi-io/dfe-loader:v2.7.2\""
);
}
#[test]
fn yaml_annotations_canonical_order_and_quoting() {
let id = ContractIdentity::new(VALID_SHA, "ghcr.io/hyperi-io/dfe-loader:v2.7.2").unwrap();
let out = id.as_yaml_annotations(4);
assert_eq!(
out,
" io.hyperi.contract.version: \"v1\"\n \
io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\"\n \
io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\""
);
}
#[test]
fn yaml_annotations_zero_indent() {
let id = ContractIdentity::new(VALID_SHA, "ghcr.io/x/y:v1").unwrap();
let out = id.as_yaml_annotations(0);
assert!(out.starts_with("io.hyperi.contract.version: \"v1\""));
}
#[test]
fn key_prefix_is_grep_target() {
let id = ContractIdentity::new(VALID_SHA, "ghcr.io/x/y:v1").unwrap();
let dockerfile = id.as_dockerfile_labels();
let yaml = id.as_yaml_annotations(2);
assert_eq!(dockerfile.matches(KEY_PREFIX).count(), 3);
assert_eq!(yaml.matches(KEY_PREFIX).count(), 3);
}
}