use std::error::Error;
use std::fmt::{self, Display, Formatter};
use serde::{Deserialize, Serialize};
pub const BUILD_IDENTITY_SCHEMA_ID: &str = "lodestone-build-identity/v1";
pub const BUILD_IDENTITY_FORMAT_VERSION: u16 = 1;
pub const MAX_BUILD_IDENTITY_BYTES: usize = 16 * 1024;
const NATIVE_API_SHA256: &str = "sha256:3a14f2017b295839922ab36dd98b32e02dad1d314ecb9ccbc396a9c89cf84b5b";
const CONFIGURATION_SCHEMA_SHA256: &str = "sha256:5e80c4bddc11dc363a412143f8fb20fc0ace5a390fbc77ece36609099b23a39b";
const SUPPLY_CONTRACT_SHA256: &str = "sha256:2141026b1456de36051ebfb558eaea6e1c1c7f1566afae409deddc136c225d80";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum BuildSourceBindingV1 {
Unbound,
ExactDevelopmentSupply {
revision: String,
tree: String,
clean_tree: bool,
source_state_sha256: String,
},
}
impl BuildSourceBindingV1 {
#[must_use]
pub fn from_executable_environment(
revision: Option<&str>,
tree: Option<&str>,
clean_tree: Option<&str>,
source_state_sha256: Option<&str>,
) -> Self {
match (revision, tree, clean_tree, source_state_sha256) {
(None, None, None, None) => Self::Unbound,
(Some(revision), Some(tree), Some("true"), Some(source_state_sha256)) => Self::ExactDevelopmentSupply {
revision: revision.to_owned(),
tree: tree.to_owned(),
clean_tree: true,
source_state_sha256: source_state_sha256.to_owned(),
},
(revision, tree, clean_tree, source_state_sha256) => Self::ExactDevelopmentSupply {
revision: revision.unwrap_or_default().to_owned(),
tree: tree.unwrap_or_default().to_owned(),
clean_tree: clean_tree == Some("true"),
source_state_sha256: source_state_sha256.unwrap_or_default().to_owned(),
},
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BuildIdentityV1 {
pub schema_id: String,
pub format_version: u16,
pub artifact_version: String,
pub target: String,
pub native_api_sha256: String,
pub configuration_schema_sha256: String,
pub supply_contract_sha256: String,
pub source_binding: BuildSourceBindingV1,
}
impl BuildIdentityV1 {
#[must_use]
pub fn current() -> Self {
Self::current_with_source_binding(BuildSourceBindingV1::Unbound)
}
#[must_use]
pub fn current_with_source_binding(source_binding: BuildSourceBindingV1) -> Self {
Self {
schema_id: BUILD_IDENTITY_SCHEMA_ID.to_owned(),
format_version: BUILD_IDENTITY_FORMAT_VERSION,
artifact_version: env!("CARGO_PKG_VERSION").to_owned(),
target: env!("LODESTONE_COMPILE_TARGET").to_owned(),
native_api_sha256: NATIVE_API_SHA256.to_owned(),
configuration_schema_sha256: CONFIGURATION_SCHEMA_SHA256.to_owned(),
supply_contract_sha256: SUPPLY_CONTRACT_SHA256.to_owned(),
source_binding,
}
}
pub fn validate(&self) -> Result<(), BuildIdentityError> {
if self.schema_id != BUILD_IDENTITY_SCHEMA_ID || self.format_version != BUILD_IDENTITY_FORMAT_VERSION {
return Err(BuildIdentityError::UnsupportedFormat);
}
if !valid_artifact_version(&self.artifact_version) || !valid_target(&self.target) {
return Err(BuildIdentityError::InvalidIdentity);
}
if !valid_sha256(&self.native_api_sha256)
|| !valid_sha256(&self.configuration_schema_sha256)
|| !valid_sha256(&self.supply_contract_sha256)
{
return Err(BuildIdentityError::InvalidDigest);
}
if let BuildSourceBindingV1::ExactDevelopmentSupply {
revision,
tree,
clean_tree,
source_state_sha256,
} = &self.source_binding
&& (!valid_git_object(revision)
|| !valid_git_object(tree)
|| !clean_tree
|| !valid_sha256(source_state_sha256))
{
return Err(BuildIdentityError::InvalidSourceBinding);
}
Ok(())
}
pub fn encode_canonical(&self) -> Result<Vec<u8>, BuildIdentityError> {
self.validate()?;
let value = serde_json::to_value(self).map_err(|_| BuildIdentityError::Encoding)?;
serde_json::to_vec(&value).map_err(|_| BuildIdentityError::Encoding)
}
pub fn decode_canonical(bytes: &[u8]) -> Result<Self, BuildIdentityError> {
if bytes.is_empty() || bytes.len() > MAX_BUILD_IDENTITY_BYTES {
return Err(BuildIdentityError::InvalidSize);
}
let value: Self = serde_json::from_slice(bytes).map_err(|_| BuildIdentityError::Malformed)?;
value.validate()?;
if value.encode_canonical()?.as_slice() != bytes {
return Err(BuildIdentityError::NonCanonical);
}
Ok(value)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BuildIdentityError {
InvalidSize,
Malformed,
NonCanonical,
UnsupportedFormat,
InvalidIdentity,
InvalidDigest,
InvalidSourceBinding,
Encoding,
}
impl Display for BuildIdentityError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::InvalidSize => "build identity is empty or oversized",
Self::Malformed => "build identity is malformed",
Self::NonCanonical => "build identity is not canonical",
Self::UnsupportedFormat => "build identity format is unsupported",
Self::InvalidIdentity => "build identity contains an invalid artifact version or target",
Self::InvalidDigest => "build identity contains an invalid digest",
Self::InvalidSourceBinding => "build identity contains an invalid source binding",
Self::Encoding => "build identity could not be encoded",
})
}
}
impl Error for BuildIdentityError {}
fn valid_sha256(value: &str) -> bool {
value.len() == 71 && value.starts_with("sha256:") && lower_hex(value.trim_start_matches("sha256:"), 64, 64)
}
fn valid_git_object(value: &str) -> bool {
lower_hex(value, 40, 64)
}
fn lower_hex(value: &str, minimum: usize, maximum: usize) -> bool {
(minimum..=maximum).contains(&value.len())
&& value
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
}
fn valid_artifact_version(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'-'))
}
fn valid_target(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.as_bytes()
.first()
.is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')
&& value
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-'))
}
#[cfg(test)]
mod tests {
use super::{BuildIdentityError, BuildIdentityV1, BuildSourceBindingV1};
#[test]
fn current_identity_is_valid_and_canonical() -> Result<(), Box<dyn std::error::Error>> {
let identity = BuildIdentityV1::current();
identity.validate()?;
let encoded = identity.encode_canonical()?;
assert_eq!(BuildIdentityV1::decode_canonical(&encoded)?, identity);
assert!(matches!(identity.source_binding, BuildSourceBindingV1::Unbound));
Ok(())
}
#[test]
fn malformed_partial_and_noncanonical_documents_refuse() -> Result<(), Box<dyn std::error::Error>> {
let identity = BuildIdentityV1::current();
let mut value = serde_json::to_value(&identity)?;
value["source_binding"] = serde_json::json!({
"kind": "exact_development_supply",
"revision": "1".repeat(40)
});
let partial = serde_json::to_vec(&value)?;
assert_eq!(
BuildIdentityV1::decode_canonical(&partial),
Err(BuildIdentityError::Malformed)
);
let mut pretty = serde_json::to_vec_pretty(&identity)?;
pretty.push(b'\n');
assert_eq!(
BuildIdentityV1::decode_canonical(&pretty),
Err(BuildIdentityError::NonCanonical)
);
Ok(())
}
#[test]
fn executable_source_binding_is_all_or_none_and_fail_closed() {
assert_eq!(
BuildSourceBindingV1::from_executable_environment(None, None, None, None),
BuildSourceBindingV1::Unbound
);
let partial = BuildIdentityV1::current_with_source_binding(BuildSourceBindingV1::from_executable_environment(
Some(&"1".repeat(40)),
None,
Some("true"),
None,
));
assert_eq!(partial.validate(), Err(BuildIdentityError::InvalidSourceBinding));
}
#[test]
fn package_and_self_digests_are_absent() -> Result<(), Box<dyn std::error::Error>> {
let encoded = BuildIdentityV1::current().encode_canonical()?;
let text = std::str::from_utf8(&encoded)?;
assert!(!text.contains("package_digest"));
assert!(!text.contains("executable_sha256"));
assert!(!text.contains("oci_manifest"));
Ok(())
}
#[test]
fn canonical_encoding_orders_every_object_lexicographically() -> Result<(), Box<dyn std::error::Error>> {
let identity = BuildIdentityV1 {
schema_id: "lodestone-build-identity/v1".to_owned(),
format_version: 1,
artifact_version: env!("CARGO_PKG_VERSION").to_owned(),
target: "x86_64-unknown-linux-musl".to_owned(),
native_api_sha256: format!("sha256:{}", "1".repeat(64)),
configuration_schema_sha256: format!("sha256:{}", "2".repeat(64)),
supply_contract_sha256: format!("sha256:{}", "3".repeat(64)),
source_binding: BuildSourceBindingV1::ExactDevelopmentSupply {
revision: "4".repeat(40),
tree: "5".repeat(40),
clean_tree: true,
source_state_sha256: format!("sha256:{}", "6".repeat(64)),
},
};
let encoded = identity.encode_canonical()?;
let expected = format!(
concat!(
r#"{{"artifact_version":"{artifact_version}","configuration_schema_sha256":"sha256:{configuration}","format_version":1,"native_api_sha256":"sha256:{api}","schema_id":"lodestone-build-identity/v1","source_binding":{{"clean_tree":true,"kind":"exact_development_supply","revision":"{revision}","source_state_sha256":"sha256:{source_state}","tree":"{tree}"}},"supply_contract_sha256":"sha256:{contract}","target":"x86_64-unknown-linux-musl"}}"#
),
artifact_version = env!("CARGO_PKG_VERSION"),
configuration = "2".repeat(64),
api = "1".repeat(64),
revision = "4".repeat(40),
source_state = "6".repeat(64),
tree = "5".repeat(40),
contract = "3".repeat(64),
);
assert_eq!(encoded, expected.as_bytes());
assert_eq!(BuildIdentityV1::decode_canonical(&encoded)?, identity);
Ok(())
}
}