Skip to content

Commit ab7af01

Browse files
logannyeclaude
andcommitted
feat(receipt): wasm-friendly rosalind-receipt crate + in-browser "caught-you" verifier
Wave 1.3 of docs/GROWTH.md — the most shareable artifact: check a receipt's tamper-evidence in the browser, running the SAME shipped code (no reimplementation). - Extract src/provenance/ into a new leaf crate `rosalind-receipt` (std + blake3, no htslib) so it compiles to wasm. The root crate re-exports it as `provenance`, so `rosalind::provenance::*` and every call site / test are unchanged. The single MemoryBudget coupling is inlined; build-identity is injected at runtime via `set_build_identity` (the binary installs it at startup) instead of `env!`, keeping the crate free of build.rs/env! — the binary's build.rs is untouched. - Add `verify_manifest_str(json) -> ReceiptCheck` (TDD'd, 5 tests): a pure, file-free re-derivation of the claim self-hash + the independent measurement hash — exactly what `rosalind verify` checks, minus re-hashing the input/output files. - `crates/receipt-wasm` (standalone, excluded from the workspace): a wasm-bindgen `verify()` over the above, built by `scripts/build-wasm-verifier.sh` (wasm-pack) into a 71 KB module. `web/verify/index.html` is a static, fully client-side page (drag a receipt / edit a byte -> flips to TAMPERED), preloaded with a real sample receipt. - `crates/receipt/examples/verify_file.rs`: the native equivalent. Verified: fmt + clippy -D warnings + full suite green (incl. 42 receipt-crate tests); the verifier compiles to wasm32 and wasm-pack produces the bundle; the embedded sample verifies and a one-byte edit is caught (Verified -> Tampered). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d119728 commit ab7af01

17 files changed

Lines changed: 796 additions & 12 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
# Rust
22
/target
3+
# Build dirs for workspace members / the standalone wasm crate (wasm-pack).
4+
**/target/
35
**/*.rs.bk
46
*.pdb
7+
8+
# Built wasm bundle for the in-browser receipt verifier (regenerated by
9+
# scripts/build-wasm-verifier.sh).
10+
/web/verify/pkg/
511
# Cargo.lock IS tracked: rosalind ships a binary, so a pinned lockfile is part of
612
# the reproducible-build story behind the receipts (and stabilizes CI cache keys).
713

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@ license = "MIT OR Apache-2.0"
1010
keywords = ["genomics", "alignment", "variant-calling", "bioinformatics", "bwt", "fm-index"]
1111
categories = ["science::bioinformatics", "algorithms"]
1212

13+
[workspace]
14+
members = ["crates/receipt"]
15+
# The wasm-bindgen verifier is its own standalone crate (built via wasm-pack), kept
16+
# out of this workspace so the normal build/test/clippy loop is unaffected.
17+
exclude = ["crates/receipt-wasm"]
18+
1319
[dependencies]
20+
# Reproducibility receipt model + offline verification, factored into a leaf crate
21+
# with no htslib (std + blake3 only) so the verifier can compile to wasm.
22+
rosalind-receipt = { path = "crates/receipt", version = "0.1.0" }
1423
# OS bindings (read-only mmap of large reference/index files; peak-RSS readout)
1524
libc = "0.2"
1625
# Fast hashing for the reproducibility receipts

crates/receipt-wasm/Cargo.lock

Lines changed: 191 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/receipt-wasm/Cargo.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Standalone (its own [workspace]) so the main Rosalind workspace and its CI are
2+
# untouched; built only via `wasm-pack`. See web/verify/README.md.
3+
[package]
4+
name = "rosalind-receipt-wasm"
5+
version = "0.1.0"
6+
edition = "2021"
7+
rust-version = "1.83"
8+
license = "MIT OR Apache-2.0"
9+
description = "Browser (wasm) bindings for rosalind-receipt: drag in a receipt, verify its tamper-evident BLAKE3 self-hash entirely client-side."
10+
publish = false
11+
12+
[workspace]
13+
14+
[lib]
15+
crate-type = ["cdylib", "rlib"]
16+
17+
[dependencies]
18+
rosalind-receipt = { path = "../receipt" }
19+
wasm-bindgen = "0.2"

crates/receipt-wasm/src/lib.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//! Browser (wasm) bindings for the Rosalind receipt verifier.
2+
//!
3+
//! [`verify`] runs the SAME canonical-JSON + BLAKE3 self-hash check that ships in the
4+
//! `rosalind verify` CLI (via the `rosalind-receipt` crate) — entirely client-side, no
5+
//! upload, no server. It returns a small JSON string the page renders.
6+
7+
use rosalind_receipt::{verify_manifest_str, ReceiptVerdict};
8+
use wasm_bindgen::prelude::*;
9+
10+
/// Verify a receipt's JSON text. Returns a JSON object string:
11+
/// `{"verdict":"verified|tampered|unverifiable|unparseable","detail":"…",
12+
/// "self_hash_ok":true|false|null,"measurement_hash_ok":…,"schema_version":N|null,
13+
/// "subcommand":"…"|null}` — the page does `JSON.parse(verify(text))`.
14+
#[wasm_bindgen]
15+
pub fn verify(json: &str) -> String {
16+
let c = verify_manifest_str(json);
17+
let verdict = match c.verdict {
18+
ReceiptVerdict::Verified => "verified",
19+
ReceiptVerdict::Tampered => "tampered",
20+
ReceiptVerdict::Unverifiable => "unverifiable",
21+
ReceiptVerdict::Unparseable => "unparseable",
22+
};
23+
let tribool = |b: Option<bool>| match b {
24+
Some(true) => "true",
25+
Some(false) => "false",
26+
None => "null",
27+
};
28+
let opt_u32 = |n: Option<u32>| n.map(|v| v.to_string()).unwrap_or_else(|| "null".to_string());
29+
let opt_str = |s: Option<String>| match s {
30+
Some(v) => format!("\"{}\"", json_escape(&v)),
31+
None => "null".to_string(),
32+
};
33+
format!(
34+
"{{\"verdict\":\"{}\",\"detail\":\"{}\",\"self_hash_ok\":{},\"measurement_hash_ok\":{},\"schema_version\":{},\"subcommand\":{}}}",
35+
verdict,
36+
json_escape(&c.detail),
37+
tribool(c.self_hash_ok),
38+
tribool(c.measurement_hash_ok),
39+
opt_u32(c.schema_version),
40+
opt_str(c.subcommand),
41+
)
42+
}
43+
44+
/// Minimal JSON string escaping for the small set of characters in our detail/subcommand.
45+
fn json_escape(s: &str) -> String {
46+
let mut out = String::with_capacity(s.len());
47+
for ch in s.chars() {
48+
match ch {
49+
'"' => out.push_str("\\\""),
50+
'\\' => out.push_str("\\\\"),
51+
'\n' => out.push_str("\\n"),
52+
'\r' => out.push_str("\\r"),
53+
'\t' => out.push_str("\\t"),
54+
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
55+
c => out.push(c),
56+
}
57+
}
58+
out
59+
}

crates/receipt/Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[package]
2+
name = "rosalind-receipt"
3+
version = "0.1.0"
4+
edition = "2021"
5+
rust-version = "1.83"
6+
authors = ["Logan Nye <logannye@users.noreply.github.com>"]
7+
description = "Rosalind's canonical-JSON, self-hashing BLAKE3 reproducibility receipt: the RunManifest model, content-addressed claim hashing, and offline verification. No htslib — std + blake3 only, so it compiles to wasm."
8+
repository = "https://github.com/logannye/rosalind"
9+
license = "MIT OR Apache-2.0"
10+
keywords = ["reproducibility", "provenance", "blake3", "receipt", "genomics"]
11+
categories = ["science::bioinformatics", "cryptography"]
12+
13+
[dependencies]
14+
blake3 = "1.5"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//! Check a receipt's tamper-evident integrity from its `*.manifest.json` alone — the
2+
//! same self-hash check the in-browser verifier (and `rosalind verify`) runs, without
3+
//! re-hashing the input/output files.
4+
//!
5+
//! cargo run -p rosalind-receipt --example verify_file -- path/to/sample.manifest.json
6+
//!
7+
//! Exit code: 0 = Verified, 1 = anything else (Tampered / Unverifiable / Unparseable).
8+
9+
use std::process::ExitCode;
10+
11+
use rosalind_receipt::{verify_manifest_str, ReceiptVerdict};
12+
13+
fn main() -> ExitCode {
14+
let Some(path) = std::env::args().nth(1) else {
15+
eprintln!("usage: verify_file <path/to/*.manifest.json>");
16+
return ExitCode::from(2);
17+
};
18+
let json = match std::fs::read_to_string(&path) {
19+
Ok(s) => s,
20+
Err(e) => {
21+
eprintln!("cannot read {path}: {e}");
22+
return ExitCode::from(2);
23+
}
24+
};
25+
let c = verify_manifest_str(&json);
26+
println!(
27+
"{:?} (self_hash={:?}, measurement_hash={:?}, schema={:?}, subcommand={:?})\n {}",
28+
c.verdict, c.self_hash_ok, c.measurement_hash_ok, c.schema_version, c.subcommand, c.detail
29+
);
30+
match c.verdict {
31+
ReceiptVerdict::Verified => ExitCode::SUCCESS,
32+
_ => ExitCode::FAILURE,
33+
}
34+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ fn flag_to_key(flag: &str) -> String {
212212
#[cfg(test)]
213213
mod tests {
214214
use super::*;
215-
use crate::provenance::RunManifest;
215+
use crate::RunManifest;
216216

217217
// Build a capture WITHOUT touching the filesystem by injecting hashes directly.
218218
fn sample() -> CommandCapture {

0 commit comments

Comments
 (0)