diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 112230d9..9be9e8a3 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -264,6 +264,13 @@ jobs: - name: Setup and build wasm uses: ./.github/actions/setup_build_wasm + # pyright type-checks context-py against context_py.pyi, which is generated rather than + # committed. Without this, every symbol imported from context_py is `Unknown` and each new + # import is a fresh pyright finding. The wasm step above exists for the same reason on the + # JS side -- eslint needs the generated pkg/ to resolve. + - name: Generate Python stubs + run: cargo run --bin stub_gen --manifest-path context-py/Cargo.toml --no-default-features + - name: Trunk Check uses: trunk-io/trunk-action@v1 with: diff --git a/Cargo.lock b/Cargo.lock index a9154b86..3398e904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -967,6 +967,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2 0.10.9", "speedate 0.14.4", "tempfile", "test_utils", @@ -988,6 +989,7 @@ dependencies = [ "js-sys", "prost", "speedate 0.17.0", + "uuid", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", diff --git a/context-js/Cargo.toml b/context-js/Cargo.toml index 0caf737b..1bfad027 100644 --- a/context-js/Cargo.toml +++ b/context-js/Cargo.toml @@ -21,3 +21,4 @@ wasm-streams = "0.4.2" prost = "0.12.6" chrono = "0.4.42" speedate = "0.17.0" +uuid = { version = "1.10.0", features = ["v5", "v8"] } diff --git a/context-js/src/lib.rs b/context-js/src/lib.rs index c9e84368..88b11a96 100644 --- a/context-js/src/lib.rs +++ b/context-js/src/lib.rs @@ -10,11 +10,13 @@ use bundle::{ use chrono::{DateTime, FixedOffset}; use context::{ env, junit, meta::id::gen_info_id as gen_info_id_impl, - meta::id::gen_info_id_base as gen_info_id_base_impl, repo, + meta::id::gen_info_id_base as gen_info_id_base_impl, + meta::id::gen_test_case_guid as gen_test_case_guid_impl, repo, }; use futures::{future::Either, io::BufReader as BufReaderAsync, stream::TryStreamExt}; use js_sys::Uint8Array; use prost::Message; +use uuid::Uuid; use wasm_bindgen::prelude::*; use wasm_streams::{readable::ReadableStream, readable::sys}; @@ -264,3 +266,25 @@ pub fn gen_info_id_base( &variant, ) } + +/// Deterministic public id for a test case in a test collection: the frozen hash of the +/// `(test_collection_id, repo_id, test_case_id)` tuple. +/// +/// Takes and returns canonical UUID text. A malformed input errors rather than being hashed, which +/// would mint a valid-looking id that resolves to nothing. Nil `repo_id` matches `--no-repo`. +#[wasm_bindgen] +pub fn gen_test_case_guid( + test_collection_id: String, + repo_id: String, + test_case_id: String, +) -> Result { + let parse = |label: &str, raw: &str| { + Uuid::parse_str(raw).map_err(|e| JsError::new(&format!("invalid {label}: {e}"))) + }; + Ok(gen_test_case_guid_impl( + parse("test_collection_id", &test_collection_id)?, + parse("repo_id", &repo_id)?, + parse("test_case_id", &test_case_id)?, + ) + .to_string()) +} diff --git a/context-js/tests/test_case_guid.test.ts b/context-js/tests/test_case_guid.test.ts new file mode 100644 index 00000000..0a90dc07 --- /dev/null +++ b/context-js/tests/test_case_guid.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { gen_test_case_guid } from "../pkg/context_js"; + +// The frozen gen_test_case_guid contract, pinned identically in context/src/meta/id.rs. +describe("gen_test_case_guid contract", () => { + const COLLECTION_ID = "018f6d3a-6f2e-4c4a-9b1e-2f3a4b5c6d7e"; + const REPO_ID = "7a1f0e3d-2b4c-4d5e-8f90-123456789abc"; + const TEST_CASE_ID = "88e5353c-190c-5dce-9d06-0e66c3e062b1"; + const NIL_UUID = "00000000-0000-0000-0000-000000000000"; + + it("matches the golden vector with a repo", () => { + expect.hasAssertions(); + + const result = gen_test_case_guid(COLLECTION_ID, REPO_ID, TEST_CASE_ID); + + expect(result).toBe("bfeebcf4-72d1-887d-8bcd-788d0dec7f97"); + + // Generate again to ensure it is consistent + expect(gen_test_case_guid(COLLECTION_ID, REPO_ID, TEST_CASE_ID)).toBe( + result, + ); + }); + + // --no-repo stores the nil repo UUID, collapsing to one guid per collection. + it("matches the golden vector with the nil repo id", () => { + expect.hasAssertions(); + + expect(gen_test_case_guid(COLLECTION_ID, NIL_UUID, TEST_CASE_ID)).toBe( + "943a80af-66b0-84bb-ad01-56b3b72fe363", + ); + }); + + it("normalizes uppercase input to the same guid", () => { + expect.hasAssertions(); + + expect( + gen_test_case_guid( + COLLECTION_ID.toUpperCase(), + REPO_ID.toUpperCase(), + TEST_CASE_ID.toUpperCase(), + ), + ).toBe("bfeebcf4-72d1-887d-8bcd-788d0dec7f97"); + }); + + it("is stamped as a v8 UUID", () => { + expect.hasAssertions(); + + const result = gen_test_case_guid(COLLECTION_ID, REPO_ID, TEST_CASE_ID); + + // Version nibble, then the variant nibble consumers validate on. + expect(result[14]).toBe("8"); + expect(["8", "9", "a", "b"]).toContain(result[19]); + }); + + // Hashing a malformed id would mint an id that resolves to nothing. + it("throws on a malformed uuid instead of hashing it", () => { + expect.hasAssertions(); + + expect(() => + gen_test_case_guid(COLLECTION_ID, "not-a-uuid", TEST_CASE_ID), + ).toThrowError(/invalid repo_id/); + }); +}); diff --git a/context-py/src/lib.rs b/context-py/src/lib.rs index 46b56e68..ce2cd2dd 100644 --- a/context-py/src/lib.rs +++ b/context-py/src/lib.rs @@ -552,6 +552,29 @@ pub fn gen_info_id_base( ) } +/// Deterministic public id for a test case in a test collection: the frozen hash of the +/// `(test_collection_id, repo_id, test_case_id)` tuple. +/// +/// Takes and returns canonical UUID text. A malformed input raises rather than being hashed, which +/// would mint a valid-looking id that resolves to nothing. Nil `repo_id` matches `--no-repo`. +#[gen_stub_pyfunction] +#[pyfunction] +pub fn gen_test_case_guid( + test_collection_id: String, + repo_id: String, + test_case_id: String, +) -> PyResult { + let parse = |label: &str, raw: &str| { + Uuid::parse_str(raw).map_err(|e| PyTypeError::new_err(format!("invalid {label}: {e}"))) + }; + Ok(id::gen_test_case_guid( + parse("test_collection_id", &test_collection_id)?, + parse("repo_id", &repo_id)?, + parse("test_case_id", &test_case_id)?, + ) + .to_string()) +} + #[pymodule] fn context_py(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; @@ -609,6 +632,7 @@ fn context_py(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(parse_internal_bin_from_tarball, m)?)?; m.add_function(wrap_pyfunction!(gen_info_id, m)?)?; m.add_function(wrap_pyfunction!(gen_info_id_base, m)?)?; + m.add_function(wrap_pyfunction!(gen_test_case_guid, m)?)?; m.add_class::()?; m.add_function(wrap_pyfunction!(codeowners_parse, m)?)?; diff --git a/context-py/tests/test_meta.py b/context-py/tests/test_meta.py index 2bdab318..402863dd 100644 --- a/context-py/tests/test_meta.py +++ b/context-py/tests/test_meta.py @@ -1,4 +1,4 @@ -from context_py import gen_info_id, gen_info_id_base +from context_py import gen_info_id, gen_info_id_base, gen_test_case_guid def test_generates_id_properly_for_trunk(): @@ -191,3 +191,51 @@ def test_variant_wrapper_does_change_variant_case(): expected = "1bf61475-b542-5faf-aa85-e66a691257a3" assert result == expected assert base_result != expected + + +# The frozen gen_test_case_guid contract, pinned identically in context/src/meta/id.rs. +COLLECTION_ID = "018f6d3a-6f2e-4c4a-9b1e-2f3a4b5c6d7e" +REPO_ID = "7a1f0e3d-2b4c-4d5e-8f90-123456789abc" +TEST_CASE_ID = "88e5353c-190c-5dce-9d06-0e66c3e062b1" +NIL_UUID = "00000000-0000-0000-0000-000000000000" + + +def test_gen_test_case_guid_golden_with_repo(): + result = gen_test_case_guid(COLLECTION_ID, REPO_ID, TEST_CASE_ID) + assert result == "bfeebcf4-72d1-887d-8bcd-788d0dec7f97" + + # Generate again to ensure it is consistent + assert gen_test_case_guid(COLLECTION_ID, REPO_ID, TEST_CASE_ID) == result + + +def test_gen_test_case_guid_golden_no_repo(): + # --no-repo stores the nil repo UUID, collapsing to one guid per collection. + assert ( + gen_test_case_guid(COLLECTION_ID, NIL_UUID, TEST_CASE_ID) + == "943a80af-66b0-84bb-ad01-56b3b72fe363" + ) + + +def test_gen_test_case_guid_normalizes_uppercase_input(): + assert ( + gen_test_case_guid(COLLECTION_ID.upper(), REPO_ID.upper(), TEST_CASE_ID.upper()) + == "bfeebcf4-72d1-887d-8bcd-788d0dec7f97" + ) + + +def test_gen_test_case_guid_is_stamped_v8(): + result = gen_test_case_guid(COLLECTION_ID, REPO_ID, TEST_CASE_ID) + + # Version nibble, then the variant nibble consumers validate on. + assert result[14] == "8" + assert result[19] in ("8", "9", "a", "b") + + +def test_gen_test_case_guid_rejects_malformed_uuid(): + # Hashing a malformed id would mint an id that resolves to nothing. + try: + gen_test_case_guid(COLLECTION_ID, "not-a-uuid", TEST_CASE_ID) + except TypeError as error: + assert "invalid repo_id" in str(error) + else: + raise AssertionError("expected a malformed uuid to raise") diff --git a/context/Cargo.toml b/context/Cargo.toml index d362412a..e3fd26ce 100644 --- a/context/Cargo.toml +++ b/context/Cargo.toml @@ -35,10 +35,11 @@ quick-xml = "0.36.2" regex = { version = "1.10.3", default-features = false, features = ["std"] } serde = { version = "1.0.215", default-features = false, features = ["derive"] } serde_json = "1.0.133" +sha2 = "0.10.9" speedate = "0.14.4" thiserror = "1.0.63" tsify-next = { version = "0.5.4", optional = true } -uuid = { version = "1.10.0", features = ["v5"] } +uuid = { version = "1.10.0", features = ["v5", "v8"] } wasm-bindgen = { version = "0.2.95", optional = true } magnus = { version = "0.8.2", optional = true, default-features = false } proto = { path = "../proto" } diff --git a/context/src/meta/id.rs b/context/src/meta/id.rs index 300f0922..7c63eed7 100644 --- a/context/src/meta/id.rs +++ b/context/src/meta/id.rs @@ -1,3 +1,4 @@ +use sha2::{Digest, Sha256}; use uuid::Uuid; fn generate_checksum_uuid(values: Vec<&str>) -> String { @@ -5,6 +6,25 @@ fn generate_checksum_uuid(values: Vec<&str>) -> String { Uuid::new_v5(&Uuid::NAMESPACE_URL, info_id_input.as_bytes()).to_string() } +/// Deterministic, globally-unique public id for a test case in a test collection. +/// +/// A test case is unique only by the `(test_collection_id, repo_id, test_case_id)` tuple: +/// `test_case_id` is built from framework-internal values, and `--no-repo` deliberately shares it +/// across a collection's repos. Hashing the whole tuple inherits those semantics rather than +/// re-deciding them. +/// +/// The contract is FROZEN -- lowercase canonical text, `#`-joined, SHA-256, first 16 bytes, stamped +/// UUIDv8 -- and the pinned golden values in this file's tests are what hold it. The v8 stamp is +/// required, not cosmetic: consumers validate this with a UUID matcher that enforces the version +/// and variant nibbles. A change here changes which ids exist for every test case already reported. +pub fn gen_test_case_guid(test_collection_id: Uuid, repo_id: Uuid, test_case_id: Uuid) -> Uuid { + let msg = format!("{test_collection_id}#{repo_id}#{test_case_id}"); + let digest = Sha256::digest(msg.as_bytes()); + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&digest[..16]); + Uuid::new_v8(bytes) +} + // trunk-ignore(clippy/too_many_arguments) pub fn gen_info_id_base( org_url_slug: &str, @@ -93,7 +113,9 @@ pub fn gen_info_id( #[cfg(test)] #[cfg(feature = "bindings")] mod tests { - use crate::meta::id::{gen_info_id, gen_info_id_base}; + use uuid::Uuid; + + use crate::meta::id::{gen_info_id, gen_info_id_base, gen_test_case_guid}; #[cfg(feature = "bindings")] #[test] @@ -345,4 +367,79 @@ mod tests { ); assert_eq!(result_v4, result_again); } + + // These two vectors ARE the frozen gen_test_case_guid contract, and are pinned in the binding + // suites and by server-side consumers too. + + const GOLDEN_COLLECTION_ID: &str = "018f6d3a-6f2e-4c4a-9b1e-2f3a4b5c6d7e"; + const GOLDEN_REPO_ID: &str = "7a1f0e3d-2b4c-4d5e-8f90-123456789abc"; + const GOLDEN_TEST_CASE_ID: &str = "88e5353c-190c-5dce-9d06-0e66c3e062b1"; + + const GOLDEN_GUID_WITH_REPO: &str = "bfeebcf4-72d1-887d-8bcd-788d0dec7f97"; + /// `--no-repo`: nil repo UUID, so one guid per collection across repos. + const GOLDEN_GUID_NO_REPO: &str = "943a80af-66b0-84bb-ad01-56b3b72fe363"; + + fn golden_guid(repo_id: Uuid) -> Uuid { + gen_test_case_guid( + Uuid::parse_str(GOLDEN_COLLECTION_ID).unwrap(), + repo_id, + Uuid::parse_str(GOLDEN_TEST_CASE_ID).unwrap(), + ) + } + + #[cfg(feature = "bindings")] + #[test] + fn test_gen_test_case_guid_golden_with_repo() { + let result = golden_guid(Uuid::parse_str(GOLDEN_REPO_ID).unwrap()); + assert_eq!(result.to_string(), GOLDEN_GUID_WITH_REPO); + + // Run again to ensure deterministic output + let result_again = golden_guid(Uuid::parse_str(GOLDEN_REPO_ID).unwrap()); + assert_eq!(result_again, result); + } + + #[cfg(feature = "bindings")] + #[test] + fn test_gen_test_case_guid_golden_no_repo() { + let result = golden_guid(Uuid::nil()); + assert_eq!(result.to_string(), GOLDEN_GUID_NO_REPO); + + // Run again to ensure deterministic output + let result_again = golden_guid(Uuid::nil()); + assert_eq!(result_again, result); + } + + /// The contract hashes the canonical lowercase rendering, so case must not matter. + #[cfg(feature = "bindings")] + #[test] + fn test_gen_test_case_guid_normalizes_uppercase_inputs() { + let result = gen_test_case_guid( + Uuid::parse_str(&GOLDEN_COLLECTION_ID.to_uppercase()).unwrap(), + Uuid::parse_str(&GOLDEN_REPO_ID.to_uppercase()).unwrap(), + Uuid::parse_str(&GOLDEN_TEST_CASE_ID.to_uppercase()).unwrap(), + ); + assert_eq!(result.to_string(), GOLDEN_GUID_WITH_REPO); + } + + /// Consumers validate this with a UUID matcher enforcing version and variant. + #[cfg(feature = "bindings")] + #[test] + fn test_gen_test_case_guid_is_stamped_v8() { + for repo_id in [Uuid::parse_str(GOLDEN_REPO_ID).unwrap(), Uuid::nil()] { + let guid = golden_guid(repo_id); + assert_eq!(guid.get_version_num(), 8); + // RFC 9562 variant: the high two bits of byte 8 are 0b10. + assert_eq!(guid.as_bytes()[8] & 0xC0, 0x80); + } + } + + /// A real repo and the nil repo are different tuples, so different guids. + #[cfg(feature = "bindings")] + #[test] + fn test_gen_test_case_guid_repo_id_changes_the_guid() { + assert_ne!( + golden_guid(Uuid::parse_str(GOLDEN_REPO_ID).unwrap()), + golden_guid(Uuid::nil()) + ); + } }