From 95b511128f37b69153977f7357263f5b77ab94ea Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Mon, 17 Aug 2026 17:31:15 -0400 Subject: [PATCH] Align SHA-256 hash casing contracts --- docs/public/api-reference/fabro-api.yaml | 14 +++--- lib/apps/fabro-server/src/server/tests.rs | 7 ++- .../fabro-api/tests/blob_hash_round_trip.rs | 35 ++++++++++----- .../tests/workflow_version_round_trip.rs | 4 +- lib/foundation/fabro-types/src/blob_hash.rs | 45 ++++++++++++++++--- .../src/models/artifact-batch-upload-entry.ts | 2 +- .../create-workflow-version-response.ts | 2 +- .../src/models/write-blob-response.ts | 2 +- 8 files changed, 80 insertions(+), 31 deletions(-) diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 0352d7f62..8d1c8fa14 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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"] diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index f97892272..c6562fc3f 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -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(); diff --git a/lib/foundation/fabro-api/tests/blob_hash_round_trip.rs b/lib/foundation/fabro-api/tests/blob_hash_round_trip.rs index 397d4d00e..e67818068 100644 --- a/lib/foundation/fabro-api/tests/blob_hash_round_trip.rs +++ b/lib/foundation/fabro-api/tests/blob_hash_round_trip.rs @@ -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() { type_name::() ); } + +fn alternating_hex_case(value: &str) -> String { + value + .chars() + .enumerate() + .map(|(index, character)| { + if index % 2 == 0 { + character.to_ascii_uppercase() + } else { + character + } + }) + .collect() +} diff --git a/lib/foundation/fabro-api/tests/workflow_version_round_trip.rs b/lib/foundation/fabro-api/tests/workflow_version_round_trip.rs index 3bf0c8835..b478e393e 100644 --- a/lib/foundation/fabro-api/tests/workflow_version_round_trip.rs +++ b/lib/foundation/fabro-api/tests/workflow_version_round_trip.rs @@ -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::(json!(DEPENDENCY_ID.to_uppercase())) .unwrap(); let emitted = serde_json::to_value(id).unwrap(); diff --git a/lib/foundation/fabro-types/src/blob_hash.rs b/lib/foundation/fabro-types/src/blob_hash.rs index a99dd007a..45f9de0ad 100644 --- a/lib/foundation/fabro-types/src/blob_hash.rs +++ b/lib/foundation/fabro-types/src/blob_hash.rs @@ -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::(); - 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::().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() } } diff --git a/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts b/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts index 29d80b063..160e12f90 100644 --- a/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts +++ b/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts @@ -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; /** diff --git a/lib/packages/fabro-api-client/src/models/create-workflow-version-response.ts b/lib/packages/fabro-api-client/src/models/create-workflow-version-response.ts index 284fda5a5..de58626b1 100644 --- a/lib/packages/fabro-api-client/src/models/create-workflow-version-response.ts +++ b/lib/packages/fabro-api-client/src/models/create-workflow-version-response.ts @@ -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; } diff --git a/lib/packages/fabro-api-client/src/models/write-blob-response.ts b/lib/packages/fabro-api-client/src/models/write-blob-response.ts index 7058f86ab..d926f8118 100644 --- a/lib/packages/fabro-api-client/src/models/write-blob-response.ts +++ b/lib/packages/fabro-api-client/src/models/write-blob-response.ts @@ -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; }