mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Align SHA-256 hash casing contracts
This commit is contained in:
parent
8154a0b5fd
commit
95b511128f
8 changed files with 80 additions and 31 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue