Align SHA-256 hash casing contracts

This commit is contained in:
Scott Werner 2026-08-17 17:31:15 -04:00
parent 8154a0b5fd
commit 95b511128f
8 changed files with 80 additions and 31 deletions

View file

@ -9151,9 +9151,11 @@ components:
example: graphs/main.fabro
WorkflowVersionId:
description: SHA-256 identity of validated canonical workflow-version bytes.
description: >-
SHA-256 identity of validated canonical workflow-version bytes. Hex input is
case-insensitive; Fabro emits the canonical lowercase form.
type: string
pattern: "^[0-9a-f]{64}$"
pattern: "^[0-9A-Fa-f]{64}$"
example: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
WorkflowVersion:
@ -10283,9 +10285,11 @@ components:
example: 42
BlobHash:
description: Content-addressed SHA-256 hash of a stored blob.
description: >-
Content-addressed SHA-256 hash of a stored blob. Hex input is case-insensitive;
Fabro emits the canonical lowercase form.
type: string
pattern: "^[0-9a-f]{64}$"
pattern: "^[0-9A-Fa-f]{64}$"
example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
WriteBlobResponse:
@ -10404,7 +10408,7 @@ components:
example: src/lib.rs
sha256:
type: ["string", "null"]
description: Optional lowercase hex SHA-256 checksum for the file contents.
description: Optional SHA-256 checksum for the file contents; hex input is case-insensitive.
example: 3f785df4c5b7d3f1f4c1f0ecb0f55f1d9f6f6a3d9f0a8a98f7a74f29d1f81a2c
expected_bytes:
type: ["integer", "null"]

View file

@ -11034,7 +11034,7 @@ async fn get_checkpoint_returns_null_initially() {
}
#[tokio::test]
async fn write_and_read_run_blob_round_trip() {
async fn write_and_read_run_blob_accepts_uppercase_hash() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
@ -11061,7 +11061,10 @@ async fn write_and_read_run_blob_round_trip() {
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/blobs/{blob_hash}")))
.uri(api(&format!(
"/runs/{run_id}/blobs/{}",
blob_hash.to_uppercase()
)))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();

View file

@ -20,18 +20,15 @@ fn write_blob_response_round_trips_exact_wire_shape() {
}
#[test]
fn blob_hash_emits_the_documented_lowercase_pattern() {
// Serialization must match the OpenAPI schema pattern `^[0-9a-f]{64}$`.
let hash: ApiBlobHash = serde_json::from_value(json!(BLOB_HASH)).unwrap();
let emitted = serde_json::to_value(hash).unwrap();
assert_eq!(emitted, json!(BLOB_HASH));
let text = emitted.as_str().unwrap();
assert_eq!(text.len(), 64);
assert!(
text.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
);
fn blob_hash_accepts_any_case_and_emits_lowercase() {
for input in [
BLOB_HASH.to_string(),
BLOB_HASH.to_uppercase(),
alternating_hex_case(BLOB_HASH),
] {
let hash: ApiBlobHash = serde_json::from_value(json!(input)).unwrap();
assert_eq!(serde_json::to_value(hash).unwrap(), json!(BLOB_HASH));
}
}
#[test]
@ -48,3 +45,17 @@ fn assert_same_type<Api: 'static, Domain: 'static>() {
type_name::<Domain>()
);
}
fn alternating_hex_case(value: &str) -> String {
value
.chars()
.enumerate()
.map(|(index, character)| {
if index % 2 == 0 {
character.to_ascii_uppercase()
} else {
character
}
})
.collect()
}

View file

@ -40,9 +40,7 @@ fn create_workflow_version_response_round_trips_exact_wire_shape() {
}
#[test]
fn workflow_version_id_emits_the_documented_lowercase_pattern() {
// Input is accepted case-insensitively, but serialization must match the
// OpenAPI schema pattern `^[0-9a-f]{64}$`.
fn workflow_version_id_accepts_any_case_and_emits_lowercase() {
let id = serde_json::from_value::<ApiWorkflowVersionId>(json!(DEPENDENCY_ID.to_uppercase()))
.unwrap();
let emitted = serde_json::to_value(id).unwrap();

View file

@ -6,6 +6,10 @@ use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use sha2::{Digest, Sha256};
/// SHA-256 content identity.
///
/// Parsing accepts exactly 64 hexadecimal digits case-insensitively. Display
/// and serialization emit the canonical lowercase form.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct BlobHash([u8; 32]);
@ -76,10 +80,17 @@ mod tests {
}
#[test]
fn display_and_parse_round_trip() {
fn parse_accepts_any_case_and_display_normalizes_to_lowercase() {
let blob_hash = BlobHash::new(b"hello");
let parsed: BlobHash = blob_hash.to_string().parse().unwrap();
assert_eq!(parsed, blob_hash);
let lowercase = blob_hash.to_string();
let uppercase = lowercase.to_uppercase();
let mixed_case = alternating_hex_case(&lowercase);
for value in [&lowercase, &uppercase, &mixed_case] {
let parsed: BlobHash = value.parse().unwrap();
assert_eq!(parsed, blob_hash);
assert_eq!(parsed.to_string(), lowercase);
}
}
#[test]
@ -91,8 +102,30 @@ mod tests {
}
#[test]
fn parse_rejects_non_hex_blob_hashes() {
let parsed = "not-a-blob-hash".parse::<BlobHash>();
assert!(parsed.is_err());
fn parse_rejects_invalid_shapes() {
for value in [
String::new(),
"0".repeat(63),
"0".repeat(65),
"g".repeat(64),
format!("0x{}", "0".repeat(64)),
format!(" {}", "0".repeat(64)),
] {
assert!(value.parse::<BlobHash>().is_err(), "accepted {value:?}");
}
}
fn alternating_hex_case(value: &str) -> String {
value
.chars()
.enumerate()
.map(|(index, character)| {
if index % 2 == 0 {
character.to_ascii_uppercase()
} else {
character
}
})
.collect()
}
}

View file

@ -27,7 +27,7 @@ export interface ArtifactBatchUploadEntry {
*/
'path': string;
/**
* Optional lowercase hex SHA-256 checksum for the file contents.
* Optional SHA-256 checksum for the file contents; hex input is case-insensitive.
*/
'sha256'?: string | null;
/**

View file

@ -19,7 +19,7 @@
*/
export interface CreateWorkflowVersionResponse {
/**
* SHA-256 identity of validated canonical workflow-version bytes.
* SHA-256 identity of validated canonical workflow-version bytes. Hex input is case-insensitive; Fabro emits the canonical lowercase form.
*/
'workflow_version_id': string;
}

View file

@ -19,7 +19,7 @@
*/
export interface WriteBlobResponse {
/**
* Content-addressed SHA-256 hash of a stored blob.
* Content-addressed SHA-256 hash of a stored blob. Hex input is case-insensitive; Fabro emits the canonical lowercase form.
*/
'hash': string;
}