Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions context-js/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
26 changes: 25 additions & 1 deletion context-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<String, JsError> {
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())
}
69 changes: 67 additions & 2 deletions context-js/tests/meta.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import { gen_info_id, gen_info_id_base } from "../pkg/context_js";
import {
gen_info_id,
gen_info_id_base,
gen_test_case_guid,
} from "../pkg/context_js";

describe("context-js", () => {
// These tests match the tests in context/src/meta/id.rs.
// While they don't need to match, it proves both the bindings and
// rust code are generating the same IDs.
describe("gen_info_id", () => {
describe("gen_info_id()", () => {
it("generates ID properly for trunk", () => {
expect.hasAssertions();

Expand Down Expand Up @@ -213,4 +217,65 @@ describe("context-js", () => {
expect(base_result).not.toBe(expected);
});
});

// The frozen gen_test_case_guid contract, pinned identically in context/src/meta/id.rs.
describe("gen_test_case_guid()", () => {
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/);
});
});
});
24 changes: 24 additions & 0 deletions context-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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::<env::parser::CIInfo>()?;
Expand Down Expand Up @@ -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::<codeowners::BindingsOwners>()?;
m.add_function(wrap_pyfunction!(codeowners_parse, m)?)?;
Expand Down
50 changes: 49 additions & 1 deletion context-py/tests/test_meta.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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")
3 changes: 2 additions & 1 deletion context/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
99 changes: 98 additions & 1 deletion context/src/meta/id.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
use sha2::{Digest, Sha256};
use uuid::Uuid;

fn generate_checksum_uuid(values: Vec<&str>) -> String {
let info_id_input: String = values.join("#");
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,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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())
);
}
}
Loading