From ce75021dfb71ca6f8a1dc589a05cf6ae1a855b77 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Mon, 31 Aug 2026 21:55:10 +0800 Subject: [PATCH] refactor(cli): depend on standalone capglyph-core via ../capglyph-core (CTX-0040) Extract embedded crates/capglyph-core (v0.1.0) to standalone repo CapGlyph/capglyph-core (canonical Rust Core). Update Cargo.toml: remove [workspace] members, change capglyph-core dep from path = "crates/capglyph-core" to path = "../capglyph-core" (isolated monorepo sibling, same as ../vectomancy). Delete crates/capglyph-core directory (mechanical extraction, no duplicate). Update CI/release workflows to checkout CapGlyph/capglyph-core sibling alongside vectomancy so path resolves, include its Cargo.lock in cache keys, and fix AUR PKGBUILD prepare() to symlink capglyph-core sibling + add capglyph-core source. Update docs/capglyph-core-api.md with CTX-0040 workspace layout and extraction checklist + dependency decision (path dep until crates.io publish). Verified: cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test, cargo check --lib --target wasm32-unknown-unknown clean (no clap/glob/trustmark/c2pa) in both repos; wasm tree clean. Part of CTX-0040. --- .github/workflows/ci.yml | 30 +- .github/workflows/release.yml | 34 +- Cargo.lock | 1 - Cargo.toml | 12 +- crates/capglyph-core/Cargo.toml | 25 - crates/capglyph-core/src/carrier.rs | 106 --- crates/capglyph-core/src/ecc.rs | 920 -------------------- crates/capglyph-core/src/framing.rs | 280 ------ crates/capglyph-core/src/geometry.rs | 96 -- crates/capglyph-core/src/interleave.rs | 81 -- crates/capglyph-core/src/keying.rs | 133 --- crates/capglyph-core/src/lib.rs | 19 - crates/capglyph-core/src/placement.rs | 23 - crates/capglyph-core/src/registration.rs | 744 ---------------- crates/capglyph-core/src/signal.rs | 140 --- crates/capglyph-core/src/spread_spectrum.rs | 194 ----- docs/capglyph-core-api.md | 51 +- 17 files changed, 93 insertions(+), 2796 deletions(-) delete mode 100644 crates/capglyph-core/Cargo.toml delete mode 100644 crates/capglyph-core/src/carrier.rs delete mode 100644 crates/capglyph-core/src/ecc.rs delete mode 100644 crates/capglyph-core/src/framing.rs delete mode 100644 crates/capglyph-core/src/geometry.rs delete mode 100644 crates/capglyph-core/src/interleave.rs delete mode 100644 crates/capglyph-core/src/keying.rs delete mode 100644 crates/capglyph-core/src/lib.rs delete mode 100644 crates/capglyph-core/src/placement.rs delete mode 100644 crates/capglyph-core/src/registration.rs delete mode 100644 crates/capglyph-core/src/signal.rs delete mode 100644 crates/capglyph-core/src/spread_spectrum.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31d720a..6e0656f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,11 @@ jobs: run: working-directory: capglyph-cli steps: - # CapGlyph (formerly Sigil) depends on the vectomancy crates via path deps (../vectomancy). - # Both repos are checked out side by side under the runner workspace; - # checkout@v7 rejects paths outside the primary repository dir, so the - # primary checkout itself lands in a subdirectory. + # CapGlyph depends on vectomancy crates + capglyph-core via path deps + # (../vectomancy, ../capglyph-core). Both siblings are checked out side + # by side under the runner workspace; checkout@v7 rejects paths outside + # the primary repository dir, so the primary checkout itself lands in a + # subdirectory. - uses: actions/checkout@v7 with: path: capglyph-cli @@ -29,6 +30,10 @@ jobs: with: repository: Xuepoo/vectomancy path: vectomancy + - uses: actions/checkout@v7 + with: + repository: CapGlyph/capglyph-core + path: capglyph-core - uses: dtolnay/rust-toolchain@stable with: @@ -43,8 +48,9 @@ jobs: ~/.cargo/registry ~/.cargo/git capglyph-cli/target + capglyph-core/target vectomancy/target - key: cargo-${{ runner.os }}-${{ hashFiles('capglyph-cli/Cargo.lock', 'vectomancy/Cargo.lock') }} + key: cargo-${{ runner.os }}-${{ hashFiles('capglyph-cli/Cargo.lock', 'capglyph-core/Cargo.lock', 'vectomancy/Cargo.lock') }} restore-keys: cargo-${{ runner.os }}- - name: Format check @@ -88,6 +94,10 @@ jobs: with: repository: Xuepoo/vectomancy path: vectomancy + - uses: actions/checkout@v7 + with: + repository: CapGlyph/capglyph-core + path: capglyph-core - uses: dtolnay/rust-toolchain@stable - uses: actions/cache@v6 with: @@ -95,8 +105,9 @@ jobs: ~/.cargo/registry ~/.cargo/git capglyph-cli/target + capglyph-core/target vectomancy/target - key: cargo-${{ runner.os }}-${{ hashFiles('capglyph-cli/Cargo.lock', 'vectomancy/Cargo.lock') }} + key: cargo-${{ runner.os }}-${{ hashFiles('capglyph-cli/Cargo.lock', 'capglyph-core/Cargo.lock', 'vectomancy/Cargo.lock') }} restore-keys: cargo-${{ runner.os }}- - name: Test (learned+c2pa features) run: cargo test --features learned,c2pa @@ -115,6 +126,10 @@ jobs: with: repository: Xuepoo/vectomancy path: vectomancy + - uses: actions/checkout@v7 + with: + repository: CapGlyph/capglyph-core + path: capglyph-core - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown @@ -124,8 +139,9 @@ jobs: ~/.cargo/registry ~/.cargo/git capglyph-cli/target + capglyph-core/target vectomancy/target - key: cargo-${{ runner.os }}-${{ hashFiles('capglyph-cli/Cargo.lock', 'vectomancy/Cargo.lock') }} + key: cargo-${{ runner.os }}-${{ hashFiles('capglyph-cli/Cargo.lock', 'capglyph-core/Cargo.lock', 'vectomancy/Cargo.lock') }} restore-keys: cargo-${{ runner.os }}- - name: Check lib compiles for wasm32 env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e91357..3973942 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,13 +50,19 @@ jobs: with: path: capglyph-cli - # CapGlyph (formerly Sigil) depends on the vectomancy crates via path deps (../vectomancy). + # CapGlyph depends on vectomancy crates + capglyph-core via path deps (../vectomancy, ../capglyph-core). - name: Checkout vectomancy (path dependency) uses: actions/checkout@v7.0.1 with: repository: Xuepoo/vectomancy path: vectomancy + - name: Checkout capglyph-core (path dependency, CTX-0040) + uses: actions/checkout@v7.0.1 + with: + repository: CapGlyph/capglyph-core + path: capglyph-core + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -68,8 +74,9 @@ jobs: ~/.cargo/registry ~/.cargo/git capglyph-cli/target + capglyph-core/target vectomancy/target - key: release-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('capglyph-cli/Cargo.lock', 'vectomancy/Cargo.lock') }} + key: release-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('capglyph-cli/Cargo.lock', 'capglyph-core/Cargo.lock', 'vectomancy/Cargo.lock') }} restore-keys: release-${{ runner.os }}-${{ matrix.target }}- - name: Build release (learned+c2pa features) @@ -191,16 +198,19 @@ jobs: url="https://github.com/CapGlyph/capglyph-cli" license=('Apache-2.0') _vmver=8.0.0 - source=("capglyph-\$pkgver::https://github.com/CapGlyph/capglyph-cli/archive/refs/tags/v\$pkgver.tar.gz" - "vectomancy-\$_vmver::https://github.com/Xuepoo/vectomancy/archive/refs/tags/v\$_vmver.tar.gz") - sha256sums=('SKIP' - 'SKIP') - makedepends=('cargo') - - prepare() { - # capglyph's Cargo.toml (formerly sigil) depends on ../vectomancy path deps - ln -sfn "\$srcdir/vectomancy-\$_vmver" "\$srcdir/capglyph-\$pkgver/../vectomancy" - } + source=("capglyph-\$pkgver::https://github.com/CapGlyph/capglyph-cli/archive/refs/tags/v\$pkgver.tar.gz" + "capglyph-core-\$pkgver::https://github.com/CapGlyph/capglyph-core/archive/refs/tags/v\$pkgver.tar.gz" + "vectomancy-\$_vmver::https://github.com/Xuepoo/vectomancy/archive/refs/tags/v\$_vmver.tar.gz") + sha256sums=('SKIP' + 'SKIP' + 'SKIP') + makedepends=('cargo') + + prepare() { + # capglyph's Cargo.toml depends on ../vectomancy + ../capglyph-core path deps (CTX-0040) + ln -sfn "\$srcdir/vectomancy-\$_vmver" "\$srcdir/capglyph-\$pkgver/../vectomancy" + ln -sfn "\$srcdir/capglyph-core-\$pkgver" "\$srcdir/capglyph-\$pkgver/../capglyph-core" + } build() { cd "capglyph-\$pkgver" diff --git a/Cargo.lock b/Cargo.lock index b42e8ed..ea73c47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -638,7 +638,6 @@ dependencies = [ "serde_bytes", "serde_json", "sha2 0.11.0", - "tempfile", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index e438be2..938ce77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,6 @@ keywords = ["watermark", "forensics", "image", "security", "tracing"] categories = ["command-line-utilities", "multimedia::images"] readme = "README.md" -[workspace] -members = ["crates/capglyph-core"] -resolver = "2" - [lib] name = "capglyph" path = "src/lib.rs" @@ -22,7 +18,13 @@ name = "capglyph" path = "src/main.rs" [dependencies] -capglyph-core = { path = "crates/capglyph-core" } +# capglyph-core is now a standalone repo (CTX-0040). Isolated monorepo layout +# keeps it at ../capglyph-core sibling (local: /capglyph/capglyph-core). +# CI checks out CapGlyph/capglyph-core alongside capglyph-cli and vectomancy +# so this sibling path resolves. Future crates.io publish will switch to +# `capglyph-core = "0.1"` version dep + `capglyph-core = { path = "../capglyph-core" }` +# dev override via [patch.crates-io] if needed. +capglyph-core = { path = "../capglyph-core" } # Image I/O image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } diff --git a/crates/capglyph-core/Cargo.toml b/crates/capglyph-core/Cargo.toml deleted file mode 100644 index 3d401e8..0000000 --- a/crates/capglyph-core/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "capglyph-core" -version = "0.1.0" -edition = "2021" -description = "Core codec and carrier primitives for capglyph (shared with capglyph-wasm and capglyphd)" -license = "Apache-2.0" -repository = "https://github.com/CapGlyph/capglyph-core" -keywords = ["watermark", "forensics", "image", "security", "tracing"] -categories = ["multimedia::images", "cryptography"] -readme = "README.md" - -[dependencies] -anyhow = "1" -image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -ciborium = "0.2" -serde_bytes = "0.11" -sha2 = "0.11.0" -hmac = "0.13.0" -digest = "0.11.3" -tracing = "0.1" - -[dev-dependencies] -tempfile = "3.27.0" diff --git a/crates/capglyph-core/src/carrier.rs b/crates/capglyph-core/src/carrier.rs deleted file mode 100644 index b5ce43e..0000000 --- a/crates/capglyph-core/src/carrier.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Carrier trait — abstracts embed/verify/extract + metrics for each watermark mode. -//! -//! Prepares mechanical extraction of `capglyph-core` (DEC-0003 Phase 1). The trait -//! is intentionally object-safe-ish but used as static dispatch via associated -//! types; `embed.rs` dispatches through `DctCarrier`/`DwtCarrier` impls so the -//! call sites are ready to become `capglyph_core::Carrier` after the crate split. - -use anyhow::Result; -use image::{ImageBuffer, Rgb}; - -use crate::geometry::GeometryFile; -use crate::placement::Placement; - -/// Unified interface for a watermark carrier (frequency-domain or spatial). -/// -/// Each carrier operates on an RGB image buffer and optional geometry. The -/// `Metrics` associated type carries the verification signal for that carrier. -pub trait Carrier { - /// Human-readable name (`"dct"`, `"dwt"`, `"alpha"`). - const NAME: &'static str; - - /// Verification metrics produced by this carrier. - type Metrics: std::fmt::Debug; - - /// Embed watermark into `img` in-place. - /// - /// Returns `(count, positions)` where `count` is the number of marked - /// blocks/coefficients and `positions` are the sorted coordinates used. - fn embed( - img: &mut ImageBuffer, Vec>, - geometry: &GeometryFile, - recipient_id: Option<&str>, - key: Option<&str>, - placement: &Placement, - ) -> Result<(u64, Vec<(u32, u32)>)>; - - /// Embed with explicit strength (DWT uses `dwt_strength`, DCT ignores it). - /// - /// Default impl forwards to `embed` so DCT callers need not branch. - fn embed_with_strength( - img: &mut ImageBuffer, Vec>, - geometry: &GeometryFile, - recipient_id: Option<&str>, - key: Option<&str>, - placement: &Placement, - strength: f32, - ) -> Result<(u64, Vec<(u32, u32)>)> { - let _ = strength; - Self::embed(img, geometry, recipient_id, key, placement) - } - - /// Verify watermark presence and return carrier-specific metrics. - /// - /// `placement` selects the coefficient placement arm. For DWT only - /// `Skeleton` is supported — `Edge`/`Prng` return an error. - fn verify( - img: &ImageBuffer, Vec>, - geometry: &GeometryFile, - placement: &Placement, - ) -> Result; - - /// Verify the key-derived secret layer (differential-pair mean). - /// - /// Returns mean signal: correct key → ≈ 2·delta, wrong key → ≈ 0. - fn verify_secret(img: &ImageBuffer, Vec>, key: &str) -> f64; - - /// Extract geometry-free recipient ID (self-sync PRNG recovered). - fn extract(img: &ImageBuffer, Vec>, id_length: usize) -> Result; - - /// Whether `metrics` indicates watermark presence at `threshold`. - fn metrics_is_present(metrics: &Self::Metrics, threshold: f64) -> bool; - - /// Mean signal extracted from `metrics` (for threshold comparisons). - fn metrics_mean_signal(metrics: &Self::Metrics) -> f64; -} - -// ── AlphaCarrier (presence-only, no recoverable bits) ────────────────────── - -/// Alpha-channel carrier (sparse semi-transparent pixels). -/// -/// Exists for completeness so `crate::core` can enumerate all carriers. Embed -/// is **not** implemented via the RGB `Carrier::embed` signature because alpha -/// compositing requires an `RgbaImage`; callers should continue using -/// `crate::embed::embed_to_image` for `Alpha`. Verify/extract helpers below -/// operate on `Rgba` buffers via `crate::signal`. -pub struct AlphaCarrier; - -impl AlphaCarrier { - /// Verify alpha presence on an `RgbaImage` byte buffer. - pub fn verify_rgba(pixels: &[u8], width: u32, height: u32, threshold: f64) -> bool { - let m = crate::signal::SignalMetrics::compute(pixels, width, height); - m.is_present(threshold) - } - - /// Verify alpha presence (v2) with minimum pixel count. - pub fn verify_rgba_v2( - pixels: &[u8], - width: u32, - height: u32, - threshold: f64, - min_pixels: u64, - ) -> bool { - let m = crate::signal::SignalMetrics::compute(pixels, width, height); - m.is_present_v2(threshold, min_pixels) - } -} diff --git a/crates/capglyph-core/src/ecc.rs b/crates/capglyph-core/src/ecc.rs deleted file mode 100644 index 9f30d4c..0000000 --- a/crates/capglyph-core/src/ecc.rs +++ /dev/null @@ -1,920 +0,0 @@ -#![allow(unused, dead_code, clippy::all)] - -//! Channel coding: Repetition-8 + BCH/RS + interleave + soft-bits. -//! -//! Stack per `capacity-robustness-and-threats.md` §3 and -//! `capglyph-core-api.md` §4.3 (legacy: `sigil-core-api.md`): -//! `Interleave → Modulate(±delta) → Channel → Demodulate → Deinterleave → Decode`. -//! -//! - `Repetition8` — 8× bit repetition, hard majority + LLR soft combine. -//! - `Bch { t }` — binary BCH (Hamming/31/63 variants, t-error) with brute-force -//! syndrome decode for small blocks (suitable for 128–256b credential). -//! - `RsInterleaved { n,k,depth }` — Reed-Solomon over GF(256) (QR-style) + -//! byte interleave. Implemented via compact GF(256) encoder; decoder is -//! Berlekamp-Massey/Chien/Forney for ≤16 parity bytes, hard-decision fallback -//! for larger. - -use anyhow::Result; - -// ── Public types ───────────────────────────────────────────────────────────── - -/// Soft-bit for LLR decoding: magnitude → confidence. -/// `hard` is the thresholded bit, `llr` is log P(1)/P(0) ≈ 2*y/σ². -#[derive(Debug, Clone, Copy)] -pub struct SoftBit { - pub hard: bool, - pub llr: f32, -} - -impl SoftBit { - pub fn new(hard: bool, llr: f32) -> Self { - Self { hard, llr } - } - /// Hard conversion from coefficient delta magnitude. - /// `coeff` is the signed residual at the known lattice position. - /// `sigma` is estimated noise std (typical LH/DCT coefficient std). - pub fn from_coeff(coeff: f32, sigma: f32) -> Self { - let sigma = sigma.max(1e-6); - let llr = 2.0 * coeff / sigma; - Self { - hard: coeff > 0.0, - llr, - } - } -} - -/// Profile selects the coding stack for a given image size / attack target. -/// CTX-0020 ships Repetition8 + CRC baseline plus BCH and RS+interleave; -/// LDPC is deferred (see threat matrix). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Profile { - /// Legacy: repetition-8 + hard majority (current spread_spectrum) — for compat. - Repetition8, - /// BCH (short) — for 61–256b payloads. `t` = correctable bit errors per block. - Bch { t: u8 }, - /// Reed-Solomon + byte interleave — for 128–1024b, burst (crop) resilience. - RsInterleaved { n: u8, k: u8, interleave_depth: u8 }, -} - -impl Default for Profile { - fn default() -> Self { - Self::Repetition8 - } -} - -impl Profile { - /// Interleave depth for burst handling (0 if none). - pub fn interleave_depth(self) -> u8 { - match self { - Self::RsInterleaved { - interleave_depth, .. - } => interleave_depth, - _ => 0, - } - } -} - -// ── Helpers: bit/byte pack ─────────────────────────────────────────────────── - -pub fn bytes_to_bits(bytes: &[u8]) -> Vec { - let mut out = Vec::with_capacity(bytes.len() * 8); - for &b in bytes { - for i in (0..8).rev() { - out.push((b >> i) & 1 == 1); - } - } - out -} - -pub fn bits_to_bytes(bits: &[bool]) -> Vec { - let mut out = Vec::with_capacity(bits.len().div_ceil(8)); - for chunk in bits.chunks(8) { - let mut byte = 0u8; - for &bit in chunk { - byte = (byte << 1) | (bit as u8); - } - // Pad last chunk with zeros on the right if not 8 - if chunk.len() < 8 { - byte <<= 8 - chunk.len(); - } - out.push(byte); - } - out -} - -/// Convenience: pack bool bits as 0/1 bytes (one byte per bit, 0/1 value). -fn bits_to_bitbytes(bits: &[bool]) -> Vec { - bits.iter().map(|&b| b as u8).collect() -} - -fn bitbytes_to_bits(bitbytes: &[u8]) -> Vec { - bitbytes.iter().map(|&b| b != 0).collect() -} - -// ── Repetition-8 ───────────────────────────────────────────────────────────── - -fn encode_repetition(bits: &[bool]) -> Vec { - let mut out = Vec::with_capacity(bits.len() * 8); - for &b in bits { - for _ in 0..8 { - out.push(b); - } - } - out -} - -fn decode_repetition_hard(bits: &[bool]) -> Result> { - anyhow::ensure!( - bits.len().is_multiple_of(8), - "repetition coded length must be multiple of 8" - ); - let mut out = Vec::with_capacity(bits.len() / 8); - for chunk in bits.chunks(8) { - let ones = chunk.iter().filter(|&&b| b).count(); - out.push(ones >= 4); // majority - } - Ok(out) -} - -fn decode_repetition_soft(soft: &[SoftBit]) -> Result> { - anyhow::ensure!( - soft.len().is_multiple_of(8), - "repetition soft length must be multiple of 8" - ); - let mut out = Vec::with_capacity(soft.len() / 8); - for chunk in soft.chunks(8) { - let llr_sum: f32 = chunk.iter().map(|s| s.llr).sum(); - out.push(llr_sum > 0.0); - } - Ok(out) -} - -// ── BCH (binary, Hamming/BCH(31) variants) ─────────────────────────────────── -// For small blocks we implement a brute-force syndrome decoder: -// encode is systematic via generator polynomial division; decode tries all -// error patterns up to `t` bits (feasible for n≤31, t≤3). -// -// Supported parameter sets (n,k,t): -// t=1 → Hamming(7,4) n=7 -// t=2 → BCH(15,7) n=15 -// t=3 → BCH(31,16) n=31 -// t=5 → BCH(63,36) n=63 (t=5 needs larger search; we use t=3 search + degrade) -// We chunk the input bits into k-bit data words, encode each to n bits. - -fn bch_params(t: u8) -> (usize, usize) { - match t { - 1 => (7, 4), - 2 => (15, 7), - 3..=4 => (31, 16), - _ => (63, 36), // t=5+ → longer block - } -} - -// Generator polynomials in systematic form (binary, MSB = x^n). -// We store as bit vectors low→high? Instead we use simple LFSR division -// using integer representation for n≤63 (fits in u64). -fn bch_generator_poly(t: u8) -> (u64, usize) { - // G(x) for each variant (including x^n term implicit leading 1): - // Hamming(7,4): x^3 + x + 1 = 0b1011 = 0xB (degree 3) - // BCH(15,7): x^8 + x^7 + x^6 + x^4 +1 = 0b1_1101_0001 → degree 8 - // BCH(31,16): x^15+ x^11+ x^10+ x^9+ x^8+ x^7+ x^5+ x^3+ x^2+ x +1 degree 15 - // BCH(63,36): x^27 + ... (degree 27) — we provide full 27-degree poly - match t { - 1 => (0b1011, 3), // degree 3, includes x^3 - 2 => (0b1_1101_0001, 8), - 3 | 4 => (0b1_1000_1111_0101_1111u64, 15), - _ => (0b1_0001_1000_1101_1110_1100_0111u64, 27), - } -} - -fn poly_degree(p: u64) -> usize { - 63 - p.leading_zeros() as usize -} - -/// Systematic BCH encode: data k bits → n bits (k data + parity). -/// Data bits are left-aligned: n-1 .. n-k are data, remainder parity. -/// We treat bits[0] as MSB (x^{k-1}). -fn bch_encode_word(data_bits: &[bool], t: u8) -> Vec { - let (n, k) = bch_params(t); - assert_eq!(data_bits.len(), k); - let (g, deg) = bch_generator_poly(t); - assert_eq!(deg, n - k); - // Build data polynomial: copy data into high bits of n-bit word - // Shift left by deg to make room for parity. - let mut word: u64 = 0; - for (i, &b) in data_bits.iter().enumerate() { - if b { - // data_bits[0] is MSB -> position n-1, ... data_bits[k-1] -> n-k - let pos = n - 1 - i; - word |= 1u64 << pos; - } - } - // Polynomial long division to compute remainder (syndrome) - // Divide word by g (degree deg, but stored with leading 1 at deg). - // Use standard binary division. - for i in (deg..n).rev() { - if (word >> i) & 1 == 1 { - word ^= g << (i - deg); - } - } - // Now word's low `deg` bits are remainder (parity), high bits are original data - // Re-assemble full codeword: original data in high, remainder in low. - // We already cleared high parity via XOR above, but need to re-add data bits. - let mut code = Vec::with_capacity(n); - // Rebuild data part again to have systematic form: data bits unchanged, parity = remainder - // Extract remainder - let mut remainder: u64 = word & ((1u64 << deg) - 1); - // Build code bits MSB→LSB - for pos in (0..n).rev() { - let bit = if pos >= deg { - // data region: pos = n-1 .. deg - let data_idx = n - 1 - pos; - data_bits[data_idx] - } else { - // parity region - ((remainder >> pos) & 1) == 1 - }; - code.push(bit); - } - debug_assert_eq!(code.len(), n); - code -} - -fn bch_decode_word(code_bits: &[bool], t: u8) -> Result> { - let (n, k) = bch_params(t); - assert_eq!(code_bits.len(), n); - let (g, deg) = bch_generator_poly(t); - // Compute syndrome: code_bits polynomial mod g should be 0 if no error. - let mut word: u64 = 0; - for (i, &b) in code_bits.iter().enumerate() { - if b { - let pos = n - 1 - i; - word |= 1u64 << pos; - } - } - // Check if syndrome zero - let mut syndrome_word = word; - for i in (deg..n).rev() { - if (syndrome_word >> i) & 1 == 1 { - syndrome_word ^= g << (i - deg); - } - } - let syndrome = syndrome_word & ((1u64 << deg) - 1); - if syndrome == 0 { - // No error — return data bits (high k bits) - let mut out = Vec::with_capacity(k); - for i in 0..k { - out.push(code_bits[i]); - } - return Ok(out); - } - // Brute-force all error patterns up to t bits for small n. - // For t up to 3 and n up to 63, worst combos C(63,3)=39711 feasible. - // For t=5 n=63 would be large — we cap brute force to t<=3 and fall back to - // repetition-style majority for larger t. - let max_t = if t > 3 { 3 } else { t } as usize; - // Try 1..=max_t flips - for flips in 1..=max_t { - // Generate combinations via recursion + early exit. - let mut idx_buf = vec![0usize; flips]; - if bch_try_flips(word, g, deg, n, &mut idx_buf, 0, 0, code_bits, k) { - // Found correction — reconstruct corrected codeword syndrome zero. - // We need to find the corrected word; instead of recomputing, we can - // search via helper that returns corrected bits. - if let Some(corrected) = bch_brute_force_correct(word, g, deg, n, max_t) { - let mut out = Vec::with_capacity(k); - for i in 0..k { - let pos = n - 1 - i; - out.push(((corrected >> pos) & 1) == 1); - } - return Ok(out); - } - } - } - // If brute force failed, fall back to hard split (no correction) — caller maps to error. - anyhow::bail!( - "BCH decode failed: uncorrectable errors (syndrome {:x})", - syndrome - ) -} - -// Helper to test existence of a flipping set that zeroes syndrome (quick existence check). -fn bch_try_flips( - word: u64, - g: u64, - deg: usize, - n: usize, - buf: &mut [usize], - depth: usize, - start: usize, - code_bits: &[bool], - _k: usize, -) -> bool { - if depth == buf.len() { - let mut trial = word; - for &idx in buf.iter() { - trial ^= 1u64 << (n - 1 - idx); - } - let mut s = trial; - for i in (deg..n).rev() { - if (s >> i) & 1 == 1 { - s ^= g << (i - deg); - } - } - return (s & ((1u64 << deg) - 1)) == 0; - } - for i in start..n { - buf[depth] = i; - if bch_try_flips(word, g, deg, n, buf, depth + 1, i + 1, code_bits, _k) { - return true; - } - } - false -} - -fn bch_brute_force_correct(word: u64, g: u64, deg: usize, n: usize, max_t: usize) -> Option { - for flips in 1..=max_t { - // iterate combinations via Gosper? Use simple recursive stack with iterative next_combination would be faster, but brute force via recursion is ok for n≤63. - // Generate all combos via iterative bitmask enumeration when n small? Use combination generator. - let mut combo: Vec = (0..flips).collect(); - loop { - let mut trial = word; - for &idx in &combo { - trial ^= 1u64 << (n - 1 - idx); - } - let mut s = trial; - for i in (deg..n).rev() { - if (s >> i) & 1 == 1 { - s ^= g << (i - deg); - } - } - if (s & ((1u64 << deg) - 1)) == 0 { - return Some(trial); - } - // next combination - if !next_combination(&mut combo, n) { - break; - } - } - } - None -} - -fn next_combination(combo: &mut [usize], n: usize) -> bool { - let k = combo.len(); - for i in (0..k).rev() { - if combo[i] < n - k + i { - combo[i] += 1; - for j in (i + 1)..k { - combo[j] = combo[j - 1] + 1; - } - return true; - } - } - false -} - -fn encode_bch(bits: &[bool], t: u8) -> Vec { - let (n, k) = bch_params(t); - let mut out = Vec::new(); - // Pad input bits to multiple of k - let mut padded = bits.to_vec(); - let pad = (k - bits.len() % k) % k; - padded.extend(std::iter::repeat(false).take(pad)); - for chunk in padded.chunks(k) { - let word = bch_encode_word(chunk, t); - out.extend(word); - } - out -} - -fn decode_bch_hard(bits: &[bool], t: u8) -> Result> { - let (n, _k) = bch_params(t); - anyhow::ensure!( - bits.len().is_multiple_of(n), - "BCH coded length must be multiple of n" - ); - let mut out = Vec::new(); - for chunk in bits.chunks(n) { - let word = bch_decode_word(chunk, t)?; - out.extend(word); - } - // Caller trims padding later via frame length. - Ok(out) -} - -fn decode_bch_soft(soft: &[SoftBit], t: u8) -> Result> { - // Convert soft to hard via sign for BCH, but could weight. - // For now, hard decision; LLR not used beyond sign (future: Chase). - let hard: Vec = soft.iter().map(|s| s.hard).collect(); - decode_bch_hard(&hard, t) -} - -// ── Reed-Solomon (GF(256)) ─────────────────────────────────────────────────── -// We implement a compact systematic RS encoder over GF(256) with primitive -// polynomial 0x11D (x^8 + x^4 + x^3 + x^2 +1). Generator g(x)= product (x - α^i) -// i=0..(n-k-1). For credential size we use RS(255,223) (32 parity) but also -// generic n/k for tests. Decoder is limited: for ≤16 parity we do -// Berlekamp-Massey + Chien + Forney; else we fall back to erasure-style -// hard decode that returns error if uncorrectable. - -mod rs { - const PRIMITIVE: u16 = 0x11d; - - // GF(256) log/antilog tables - fn gf_tables() -> ([u8; 512], [u8; 256]) { - let mut exp = [0u8; 512]; - let mut log = [0u8; 256]; - let mut x: u16 = 1; - for i in 0..255 { - exp[i] = x as u8; - log[x as usize] = i as u8; - x <<= 1; - if x & 0x100 != 0 { - x ^= PRIMITIVE; - } - } - for i in 255..512 { - exp[i] = exp[i - 255]; - } - (exp, log) - } - - fn gf_mul(a: u8, b: u8, exp: &[u8; 512], log: &[u8; 256]) -> u8 { - if a == 0 || b == 0 { - 0 - } else { - exp[(log[a as usize] as usize + log[b as usize] as usize) % 255] - } - } - - fn gf_div(a: u8, b: u8, exp: &[u8; 512], log: &[u8; 256]) -> u8 { - if a == 0 { - 0 - } else if b == 0 { - panic!("gf_div by zero"); - } else { - exp[(log[a as usize] as usize + 255 - log[b as usize] as usize) % 255] - } - } - - fn gf_pow(a: u8, n: usize, exp: &[u8; 512], log: &[u8; 256]) -> u8 { - if n == 0 { - 1 - } else if a == 0 { - 0 - } else { - exp[(log[a as usize] as usize * n) % 255] - } - } - - /// Generate RS generator polynomial coefficients (low→high, constant term first). - fn generator_poly(nsym: usize, exp: &[u8; 512], log: &[u8; 256]) -> Vec { - let mut g = vec![1u8]; - for i in 0..nsym { - let root = exp[i]; // α^i - // Multiply g by (x - root) = (x + root) since subtraction==addition in GF(256) - let mut next = vec![0u8; g.len() + 1]; - for (j, &coeff) in g.iter().enumerate() { - // g*j*x + g*j*root - next[j] ^= gf_mul(coeff, root, exp, log); - next[j + 1] ^= coeff; - } - g = next; - } - g - } - - pub fn encode(data: &[u8], nsym: usize) -> Vec { - if nsym == 0 { - return data.to_vec(); - } - let (exp, log) = gf_tables(); - let g = generator_poly(nsym, &exp, &log); - // Systematic: shift data by nsym, compute remainder - let mut msg = vec![0u8; data.len() + nsym]; - msg[..data.len()].copy_from_slice(data); - // Actually we need to place data at high (like QR): encode by dividing msg*x^nsym by g. - // Simpler: use standard long division where msg is data followed by nsym zeros, divide. - // We'll create buffer of data+nsym zeros and perform polynomial division. - let mut buf = vec![0u8; data.len() + nsym]; - buf[..data.len()].copy_from_slice(data); - // Copy to tmp for division (need big-endian: data first) - let mut tmp = buf.clone(); - for i in 0..data.len() { - let coeff = tmp[i]; - if coeff != 0 { - for j in 0..g.len() { - // g is low→high but we need high→low; reverse indexing: g highest is 1. - // Simpler: g_rev where g[0] is highest-degree term. Generator as computed is low→high (g[0]=const). - // We use division where divisor is reversed. - // Align: for position i, subtract coeff * g - // g length = nsym+1, g[nsym]=1 (leading) - let g_coeff = g[g.len() - 1 - j]; // not needed, we use full g low→high but shift. - } - // For systematic RS, we can use standard algorithm: - // for j, tmp[i+j] ^= gf_mul(coeff, g[j], ...) - // where g is reversed. - } - } - // Use the well-tested simple algorithm from wikiversity: synthetic. - // Let's use the standard encoder loop: initialize parity zero, for each data byte, feedback. - let mut parity = vec![0u8; nsym]; - for (i, &b) in data.iter().enumerate() { - let feedback = b ^ parity[0]; - // shift parity left by 1 (drop parity[0]) - parity.rotate_left(1); - parity[nsym - 1] = 0; - if feedback != 0 { - for j in 0..nsym { - // g coefficients for parity: g[0..nsym] (excluding leading 1) - // g_gen reversed: g[nsym]=1, g[nsym-1] ... g[0] - let g_coeff = { - // generator poly high→low: need g[(nsym - j -1)]? Keep using table directly. - // Recalc easier: precompute g as high→low. - let gen = generator_poly(nsym, &exp, &log); - // gen length nsym+1, gen[0]=const, gen[nsym]=1 - // For encoder we need gen[0..nsym] (excluding leading 1) reversed. - // feedback * gen[nsym-1 - j] ??? Let's brute. - let full = generator_poly(nsym, &exp, &log); - full[nsym - j - 1] // not correct, placeholder - }; - // To avoid confusion, re-implement using the known RS encode routine: - // This path is buggy — instead use a proven routine below. - } - } - let _ = i; - } - // Fallback: due to complexity, we ship a minimal self-contained RS encoder - // using the "reedsolomon crate's" simple method — for now we use a placeholder - // that appends zero parity and relies on interleave to handle burst; decode - // will detect mismatch and treat as hard error. - // For tests, this parity is deterministic and decode will succeed only if no errors. - // Full algebraic decode is deferred. - let mut out = Vec::with_capacity(data.len() + nsym); - out.extend_from_slice(data); - out.extend(vec![0u8; nsym]); - out - } - - // For now expose a test helper that just returns data+parity zeros; real RS - // parity generation is provided by the outer ecc layer using a tested - // GF(256) routine below (rs_encode_systematic). - pub fn encode_systematic(data: &[u8], nsym: usize) -> Vec { - // Use a known-good simple implementation from `reed-solomon` logic: - // Implement via brute polynomial division using GF tables indexed correctly. - if nsym == 0 { - return data.to_vec(); - } - let (exp, log) = gf_tables(); - let gen = generator_poly(nsym, &exp, &log); // low→high - // Parity via long division: msg polynomial = data * x^nsym - // Divide by gen poly, remainder is parity. - // Represent polynomials as coefficients from high-degree first (big endian). - // Data poly: degree = data.len() + nsym -1 down to nsym for data, 0..nsym-1 zeros. - // We do standard LFSR. - let mut parity = vec![0u8; nsym]; - for &byte in data { - let feedback = byte ^ parity[0]; - // shift parity - parity.rotate_left(1); - parity[nsym - 1] = 0; - if feedback != 0 { - for j in 0..nsym { - // gen is low→high with gen[nsym]=1, gen[0]=const - // For LFSR we need gen reversed: gen[nsym-1 - j]??? Let's look up formula: - // parity[j] ^= gf_mul(feedback, gen[nsym-1 - j]) - // Check against known QR encoders. - let g_coeff = gen[nsym - 1 - j]; // This matches QR spec where gen[0] is const term - parity[j] ^= gf_mul(feedback, g_coeff, &exp, &log); - } - } - } - let mut out = Vec::with_capacity(data.len() + nsym); - out.extend_from_slice(data); - out.extend(parity); - out - } - - pub fn decode(_data: &[u8], _nsym: usize) -> anyhow::Result> { - anyhow::bail!("RS decode not implemented for generic n/k — use hard check") - } -} - -// Public RS wrappers using the systematic encoder above. -fn encode_rs(data: &[u8], n: u8, k: u8) -> Vec { - let nsym = (n as usize).saturating_sub(k as usize); - if nsym == 0 || data.len() != k as usize { - // For variable-length payload, we pad/truncate to k. - // Simpler: treat data as arbitrary length, split into blocks of k. - // Encode each block separately and concatenate. - } - rs::encode_systematic(data, nsym) -} - -fn block_encode_rs(data: &[u8], n: usize, k: usize) -> Vec { - let nsym = n - k; - let mut out = Vec::new(); - let mut pos = 0; - while pos < data.len() { - let end = (pos + k).min(data.len()); - let chunk = &data[pos..end]; - // Pad last chunk with zeros - let mut padded = vec![0u8; k]; - padded[..chunk.len()].copy_from_slice(chunk); - let encoded = rs::encode_systematic(&padded, nsym); - out.extend(encoded); - pos += k; - } - out -} - -fn block_decode_rs(coded: &[u8], n: usize, k: usize) -> Result> { - let nsym = n - k; - anyhow::ensure!( - coded.len().is_multiple_of(n), - "RS coded length must be multiple of n" - ); - // For now, verify parity matches recomputed parity (detects errors) and extract data. - // No correction — returns error if any block has mismatched parity. - let mut out = Vec::new(); - for chunk in coded.chunks(n) { - let (data_part, parity_part) = chunk.split_at(k); - let recomputed = rs::encode_systematic(data_part, nsym); - if recomputed[k..] != parity_part[..] { - anyhow::bail!("RS parity mismatch (error detected, correction not yet implemented for this block)"); - } - out.extend_from_slice(data_part); - } - Ok(out) -} - -// ── Public API ─────────────────────────────────────────────────────────────── - -/// Byte interleave / de-interleave (QR-style, crop is burst error) — re-exported. -pub use crate::interleave::{deinterleave, interleave}; - -/// Encode `bytes` under `profile` → coded bytes (with parity/interleave). -/// For bit-level codes (Repetition, BCH) the output is a bit-byte vector where -/// each byte is 0/1 representing one coded bit (avoids byte-packing padding). -/// For byte-level RS, the output is regular bytes (including parity). -pub fn encode(bytes: &[u8], profile: Profile) -> Vec { - let coded = match profile { - Profile::Repetition8 => { - let bits = bytes_to_bits(bytes); - let rep = encode_repetition(&bits); - bits_to_bitbytes(&rep) - } - Profile::Bch { t } => { - let bits = bytes_to_bits(bytes); - let enc = encode_bch(&bits, t); - bits_to_bitbytes(&enc) - } - Profile::RsInterleaved { - n, - k, - interleave_depth, - } => { - let n_us = n as usize; - let k_us = k as usize; - if k_us == 0 || n_us <= k_us { - bytes.to_vec() - } else { - let enc = block_encode_rs(bytes, n_us, k_us); - if interleave_depth > 1 { - crate::interleave::interleave(&enc, interleave_depth) - } else { - enc - } - } - } - }; - // For RS with also generic interleave requested separately, handle Repetition/BCH interleave - // if profile's depth is set but variant is not RS? Currently only RS uses depth. - coded -} - -/// Hard-bit decode (for tests / backwards compat). -pub fn decode_hard(bits: &[bool], profile: Profile) -> Result> { - match profile { - Profile::Repetition8 => { - let decoded_bits = decode_repetition_hard(bits)?; - Ok(bits_to_bytes(&decoded_bits)) - } - Profile::Bch { t } => { - let decoded_bits = decode_bch_hard(bits, t)?; - Ok(bits_to_bytes(&decoded_bits)) - } - Profile::RsInterleaved { .. } => { - anyhow::bail!( - "RsInterleaved expects soft/batched decode via decode(), not bit-level decode_hard" - ) - } - } -} - -/// Expected coded bits length for a given sealed length under profile. -/// Used to slice soft-bit vector to exact length before decode. -/// For bit-level codes (Repetition, BCH) the coded vector is bitbytes (1 byte per bit), -/// so bits = coded.len(). For byte-level RS, bits = coded.len()*8. -pub fn coded_bits_len(sealed_len: usize, profile: Profile) -> usize { - let dummy = vec![0u8; sealed_len]; - let coded = encode(&dummy, profile); - match profile { - Profile::Repetition8 | Profile::Bch { .. } => coded.len(), - Profile::RsInterleaved { .. } => coded.len() * 8, - } -} - -/// Decode from soft-bits (LLR). -pub fn decode(bits: &[SoftBit], profile: Profile) -> Result> { - match profile { - Profile::Repetition8 => { - let decoded_bits = decode_repetition_soft(bits)?; - Ok(bits_to_bytes(&decoded_bits)) - } - Profile::Bch { t } => { - let decoded_bits = decode_bch_soft(bits, t)?; - Ok(bits_to_bytes(&decoded_bits)) - } - Profile::RsInterleaved { - n, - k, - interleave_depth, - } => { - // For RS, soft bits are first converted to bytes via hard decision, - // then deinterleaved, then block-decoded. - // Need to know coded byte length: each coded byte = 8 soft bits. - anyhow::ensure!( - bits.len().is_multiple_of(8), - "RS soft length must be 8× coded bytes" - ); - let mut hard_bytes = Vec::with_capacity(bits.len() / 8); - for chunk in bits.chunks(8) { - let mut byte = 0u8; - for s in chunk { - byte = (byte << 1) | (s.hard as u8); - } - hard_bytes.push(byte); - } - let deint = if interleave_depth > 1 { - crate::interleave::deinterleave(&hard_bytes, interleave_depth) - } else { - hard_bytes - }; - let n_us = n as usize; - let k_us = k as usize; - if k_us == 0 || n_us <= k_us { - anyhow::bail!("invalid RS n/k"); - } - block_decode_rs(&deint, n_us, k_us) - } - } -} - -/// Helper: bytes → SoftBit via coefficient magnitudes (for bench). -/// `coeffs` are signed residuals at lattice positions (one per coded bit). -/// `sigma` estimated noise. Returns SoftBits with LLR = 2*coeff/sigma. -pub fn soft_bits_from_coeffs(coeffs: &[f32], sigma: f32) -> Vec { - coeffs - .iter() - .map(|&c| SoftBit::from_coeff(c, sigma)) - .collect() -} - -/// Helper: bytes → hard bits (for interop with existing carrier). -pub fn bytes_to_hard_bits(bytes: &[u8]) -> Vec { - bytes_to_bits(bytes) -} - -/// Helper: estimate sigma from coeffs (MAD). -pub fn estimate_sigma(coeffs: &[f32]) -> f32 { - if coeffs.is_empty() { - return 8.0; - } - let mut abs_vals: Vec = coeffs.iter().map(|c| c.abs()).collect(); - abs_vals.sort_by(|a, b| a.partial_cmp(b).unwrap()); - let median = abs_vals[abs_vals.len() / 2]; - // MAD → sigma ≈ 1.4826 * MAD for Gaussian - (1.4826 * median).max(2.0) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn repetition_roundtrip() { - let data = b"hello credential 128b!!"; - let coded = encode(data, Profile::Repetition8); - let bits: Vec = coded.iter().map(|&b| b != 0).collect(); - let decoded = decode_hard(&bits, Profile::Repetition8).unwrap(); - assert_eq!(&decoded[..data.len()], data); - } - - #[test] - fn repetition_corrects_single_error_per_group() { - let data = b"AB"; - let coded = encode(data, Profile::Repetition8); - let mut bits: Vec = coded.iter().map(|&b| b != 0).collect(); - // Flip one bit per 8-group (should correct) - for i in (0..bits.len()).step_by(8) { - bits[i] = !bits[i]; - } - let decoded = decode_hard(&bits, Profile::Repetition8).unwrap(); - assert_eq!(&decoded[..data.len()], data); - } - - #[test] - fn repetition_soft_majority() { - // Soft path: positive LLR for 1, negative for 0 - let data = b"\xff"; // all ones - let coded = encode(data, Profile::Repetition8); - let bits: Vec = coded.iter().map(|&b| b != 0).collect(); - let mut soft: Vec = Vec::new(); - for &bit in &bits { - let coeff = if bit { 10.0 } else { -10.0 }; - soft.push(SoftBit::new(bit, coeff)); - } - // Flip a few hard decisions but keep soft sum positive - soft[0] = SoftBit::new(false, -10.0); - soft[1] = SoftBit::new(false, -10.0); - // remaining 6 in group are +10 → sum = 6*10 -2*10 = 40 >0 → decodes to 1 - let decoded = decode(&soft, Profile::Repetition8).unwrap(); - assert_eq!(decoded[0], 0xff); - } - - #[test] - fn bch_roundtrip_no_error() { - let data = b"hi"; // 16 bits - let coded = encode(data, Profile::Bch { t: 1 }); - let bits: Vec = coded.iter().map(|&b| b != 0).collect(); - let decoded = decode_hard(&bits, Profile::Bch { t: 1 }).unwrap(); - assert_eq!(&decoded[..data.len()], data); - } - - #[test] - fn bch_corrects_one_error_hamming() { - let data = b"\xaa"; // 10101010 - let coded = encode(data, Profile::Bch { t: 1 }); // Hamming(7,4) - let mut bits: Vec = coded.iter().map(|&b| b != 0).collect(); - // Flip one bit in first 7-bit codeword - bits[3] = !bits[3]; - let decoded = decode_hard(&bits, Profile::Bch { t: 1 }).unwrap(); - assert_eq!(&decoded[..data.len()], data); - } - - #[test] - fn rs_encode_lengths() { - let data = vec![1u8; 10]; - let coded = encode( - &data, - Profile::RsInterleaved { - n: 15, - k: 10, - interleave_depth: 2, - }, - ); - assert_eq!(coded.len(), 15); // one block 10→15 - let long: Vec = (0..20).collect(); - let coded2 = encode( - &long, - Profile::RsInterleaved { - n: 15, - k: 10, - interleave_depth: 0, - }, - ); - assert_eq!(coded2.len(), 30); // 2 blocks - } - - #[test] - fn interleave_identity_with_ecc() { - let data = b"interleaved burst test payload"; - let profile = Profile::RsInterleaved { - n: 15, - k: 10, - interleave_depth: 4, - }; - let coded = encode(data, profile); - let deint = deinterleave(&interleave(&coded, 4), 4); - assert_eq!(coded, deint); - } - - #[test] - fn soft_bits_llr_sign() { - let s = SoftBit::from_coeff(12.0, 4.0); - assert!(s.hard); - assert!((s.llr - 6.0).abs() < 1e-6); - let s2 = SoftBit::from_coeff(-8.0, 4.0); - assert!(!s2.hard); - assert!((s2.llr + 4.0).abs() < 1e-6); - } - - #[test] - fn estimate_sigma_reasonable() { - let coeffs = vec![0.5, -0.3, 1.2, -8.0, 9.0, -0.7, 0.2]; - let sigma = estimate_sigma(&coeffs); - assert!(sigma > 0.0); - } -} diff --git a/crates/capglyph-core/src/framing.rs b/crates/capglyph-core/src/framing.rs deleted file mode 100644 index f2bf92d..0000000 --- a/crates/capglyph-core/src/framing.rs +++ /dev/null @@ -1,280 +0,0 @@ -//! Framing layer: CBOR envelope + HMAC authentication. -//! -//! Stack: `Payload (raw bytes)` → CBOR frame (`version/type/flags/len + payload`) -//! → `frame_bytes || HMAC-SHA256(frame_bytes, K_mac)`. -//! -//! Carrier agnostic — same `seal/open` is used for DCT/DWT/learned. -//! Mirrors `capglyph-docs/research/media-credential/capglyph-core-api.md` §4.2 -//! (legacy: `sigil-docs/.../sigil-core-api.md`). - -use anyhow::{Context, Result}; -use hmac::{Hmac, Mac}; -use serde::{Deserialize, Serialize}; -use sha2::Sha256; - -type HmacSha256 = Hmac; - -/// Payload type discriminates credential vs pointer vs message vs locator. -/// Values match `capglyph-core-api.md` §4.2 (legacy `sigil-core-api.md`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[repr(u8)] -pub enum PayloadType { - Credential = 1, - Pointer = 2, - Message = 3, - Locator = 4, -} - -impl PayloadType { - pub fn from_u8(v: u8) -> Result { - match v { - 1 => Ok(Self::Credential), - 2 => Ok(Self::Pointer), - 3 => Ok(Self::Message), - 4 => Ok(Self::Locator), - _ => anyhow::bail!("unknown PayloadType {}", v), - } - } -} - -/// CBOR frame header — 6–12 bytes, always at front of sealed payload. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct FrameHeader { - pub version: u8, - pub payload_type: PayloadType, - pub flags: u8, - pub payload_len: u16, -} - -impl FrameHeader { - pub fn new(version: u8, payload_type: PayloadType, flags: u8, payload_len: u16) -> Self { - Self { - version, - payload_type, - flags, - payload_len, - } - } -} - -/// Framing params used at seal time (no K_mac here). -#[derive(Debug, Clone)] -pub struct Params { - pub version: u8, - pub payload_type: PayloadType, - pub flags: u8, -} - -impl Default for Params { - fn default() -> Self { - Self { - version: 1, - payload_type: PayloadType::Credential, - flags: 0, - } - } -} - -// Internal CBOR representation — deterministic, canonical array -// [version, payload_type, flags, payload_len, payload_bytes] (5 elements) -// Payload is byte-string (not array of ints) via serde_bytes. -#[derive(Debug, Serialize, Deserialize)] -struct CborFrame(u8, u8, u8, u16, #[serde(with = "serde_bytes")] Vec); - -// ── CBOR sub-module ────────────────────────────────────────────────────────── - -pub mod cbor { - use super::*; - - /// Encode payload → CBOR frame bytes (header + payload) — no crypto yet. - /// Encodes as CBOR array [v, t, flags, len, payload_bytes] for compactness. - pub fn encode(payload: &[u8], params: &Params) -> Vec { - let frame = CborFrame( - params.version, - params.payload_type as u8, - params.flags, - payload.len() as u16, - payload.to_vec(), - ); - let mut buf = Vec::new(); - ciborium::ser::into_writer(&frame, &mut buf).expect("CBOR serialize infallible"); - buf - } - - /// Decode and validate frame header; returns (header, payload_bytes). - pub fn decode(frame: &[u8]) -> Result<(FrameHeader, Vec)> { - let cf: CborFrame = ciborium::de::from_reader(frame).context("CBOR frame decode failed")?; - let payload_type = PayloadType::from_u8(cf.1)?; - if cf.4.len() != cf.3 as usize { - anyhow::bail!( - "payload length mismatch: header len {}, actual {}", - cf.3, - cf.4.len() - ); - } - let header = FrameHeader { - version: cf.0, - payload_type, - flags: cf.2, - payload_len: cf.3, - }; - Ok((header, cf.4)) - } - - /// Validate frame without returning payload (wasm preflight — no K_mac). - pub fn validate(frame: &[u8]) -> Result { - let (header, _) = decode(frame)?; - anyhow::ensure!( - header.version == 1, - "unsupported version {}", - header.version - ); - Ok(header) - } -} - -// ── Auth sub-module (HMAC) ─────────────────────────────────────────────────── - -pub mod auth { - use super::*; - - /// HMAC-SHA256 tag over frame bytes with K_mac. - pub fn tag(frame: &[u8], k_mac: &[u8; 32]) -> [u8; 32] { - let mut mac = - ::new_from_slice(k_mac).expect("HMAC key valid"); - mac.update(frame); - let out = mac.finalize().into_bytes(); - let mut tag = [0u8; 32]; - tag.copy_from_slice(&out); - tag - } - - pub fn verify(frame: &[u8], tag: &[u8; 32], k_mac: &[u8; 32]) -> Result<()> { - let mut mac = - ::new_from_slice(k_mac).expect("HMAC key valid"); - mac.update(frame); - mac.verify_slice(tag) - .map_err(|_| anyhow::anyhow!("HMAC verification failed"))?; - Ok(()) - } -} - -// ── High-level seal/open ───────────────────────────────────────────────────── - -/// Payload → CBOR frame → HMAC tag → (frame || tag) -pub fn seal(payload: &[u8], params: &Params, k_mac: &[u8; 32]) -> Vec { - let frame = cbor::encode(payload, params); - let tag = auth::tag(&frame, k_mac); - let mut out = Vec::with_capacity(frame.len() + 32); - out.extend_from_slice(&frame); - out.extend_from_slice(&tag); - out -} - -/// Inverse: (frame || tag) → verify tag → decode CBOR → payload -/// Returns (header, payload) on success. Fail-closed on tag mismatch. -pub fn open(sealed: &[u8], k_mac: &[u8; 32]) -> Result<(FrameHeader, Vec)> { - anyhow::ensure!(sealed.len() >= 32, "sealed frame too short for tag"); - let (frame, tag_bytes) = sealed.split_at(sealed.len() - 32); - let mut tag = [0u8; 32]; - tag.copy_from_slice(tag_bytes); - auth::verify(frame, &tag, k_mac).context("frame authentication failed")?; - let (header, payload) = cbor::decode(frame)?; - // Enforce version check after auth - anyhow::ensure!( - header.version == 1, - "unsupported version {}", - header.version - ); - Ok((header, payload)) -} - -/// Validate header without K_mac (browser preflight). -pub fn validate_frame(bytes: &[u8]) -> Result { - // bytes may be sealed (frame||tag) or just frame. Try stripped. - if bytes.len() >= 32 { - // Try as sealed first — strip tag and decode header. - let frame_part = &bytes[..bytes.len() - 32]; - if let Ok(h) = cbor::validate(frame_part) { - return Ok(h); - } - } - cbor::validate(bytes) -} - -/// Helper: sealed length for a given payload length (CBOR overhead + 32B tag). -/// Deterministic — uses dummy payload. -pub fn sealed_len(payload_len: usize, params: &Params) -> usize { - let dummy = vec![0u8; payload_len]; - cbor::encode(&dummy, params).len() + 32 -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_key() -> [u8; 32] { - [0x42u8; 32] - } - - #[test] - fn seal_open_roundtrip_credential_128b() { - let payload = b"\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff"; // 16 bytes = 128b - let params = Params { - version: 1, - payload_type: PayloadType::Credential, - flags: 0, - }; - let sealed = seal(payload, ¶ms, &test_key()); - let (hdr, out) = open(&sealed, &test_key()).unwrap(); - assert_eq!(hdr.version, 1); - assert_eq!(hdr.payload_type, PayloadType::Credential); - assert_eq!(hdr.payload_len, 16); - assert_eq!(out, payload); - } - - #[test] - fn seal_detects_tamper() { - let payload = b"hello credential"; - let sealed = seal(payload, &Params::default(), &test_key()); - let mut tampered = sealed.clone(); - tampered[5] ^= 0x01; - assert!(open(&tampered, &test_key()).is_err()); - } - - #[test] - fn wrong_key_fails() { - let sealed = seal(b"test", &Params::default(), &test_key()); - let wrong = [0x00u8; 32]; - assert!(open(&sealed, &wrong).is_err()); - } - - #[test] - fn cbor_validate_preflight() { - let frame = cbor::encode(b"abc", &Params::default()); - let hdr = cbor::validate(&frame).unwrap(); - assert_eq!(hdr.version, 1); - assert_eq!(hdr.payload_len, 3); - } - - #[test] - fn empty_payload_roundtrip() { - let sealed = seal(b"", &Params::default(), &test_key()); - let (_, out) = open(&sealed, &test_key()).unwrap(); - assert_eq!(out, b""); - } - - #[test] - fn pointer_type_roundtrip() { - let params = Params { - version: 1, - payload_type: PayloadType::Pointer, - flags: 1, - }; - let sealed = seal(b"pointer-data", ¶ms, &test_key()); - let (hdr, out) = open(&sealed, &test_key()).unwrap(); - assert_eq!(hdr.payload_type, PayloadType::Pointer); - assert_eq!(hdr.flags, 1); - assert_eq!(out, b"pointer-data"); - } -} diff --git a/crates/capglyph-core/src/geometry.rs b/crates/capglyph-core/src/geometry.rs deleted file mode 100644 index 8479f75..0000000 --- a/crates/capglyph-core/src/geometry.rs +++ /dev/null @@ -1,96 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Sigil geometry JSON format v1. -/// -/// Stores the Chaikin-smoothed polyline paths extracted from an image so they -/// can be re-rendered at a different stroke width without re-running the full -/// raster analysis pipeline. -/// -/// This is Sigil's own format — intentionally simpler than Vectomancy's -/// `MathExpressionAST` (no Fourier/Spline math, just polyline points). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeometryFile { - /// Format version (always 1 for now) - pub version: u32, - /// Original image width in pixels - pub original_width: u32, - /// Original image height in pixels - pub original_height: u32, - /// Analysis parameters used to produce these paths - pub analysis_params: AnalysisParams, - /// Extracted polyline paths - pub paths: Vec, - /// PRNG seed derived from original image pixels (FNV-1a of first 4096 bytes). - /// Set when paths is empty (solid-color fallback) so verification can - /// reconstruct the same pseudorandom block set without re-hashing a modified image. - #[serde(skip_serializing_if = "Option::is_none")] - pub prng_seed: Option, - /// Exact sorted 8×8 block coordinates used during DCT embed (for recipient ID extraction). - /// Stored as (block_x, block_y) tuples. When present, `extract` uses these directly - /// instead of re-deriving blocks from paths (which may differ after watermarking). - #[serde(skip_serializing_if = "Option::is_none")] - pub blocks: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnalysisParams { - pub detail: u8, - pub min_path_len: usize, - pub chaikin_iters: usize, - pub color: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PathEntry { - /// Stroke color as `[r, g, b]` in 0.0–1.0 (None → neutral gray 0.5) - #[serde(skip_serializing_if = "Option::is_none")] - pub color: Option<[f32; 3]>, - /// Polyline vertices as `[[x, y], ...]` in image pixel coordinates - pub points: Vec<[f64; 2]>, -} - -impl GeometryFile { - pub const CURRENT_VERSION: u32 = 1; - - /// Load a geometry file from JSON bytes. - pub fn from_json(bytes: &[u8]) -> anyhow::Result { - let gf: GeometryFile = serde_json::from_slice(bytes)?; - if gf.version != Self::CURRENT_VERSION { - anyhow::bail!( - "Unsupported geometry file version {} (expected {})", - gf.version, - Self::CURRENT_VERSION - ); - } - Ok(gf) - } - - /// Serialize to pretty-printed JSON bytes. - pub fn to_json(&self) -> anyhow::Result> { - Ok(serde_json::to_vec_pretty(self)?) - } - - /// Compute a stable 64-bit hash of the geometry. - /// - /// Uses sorted path endpoints (first/last point of each path) rather than - /// full point lists — endpoints survive minor coordinate drift from - /// RDP/Chaikin re-extraction better than interior points. - pub fn compute_geometry_hash(&self) -> u64 { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut endpoints: Vec<(u32, u32)> = Vec::new(); - for path in &self.paths { - if let (Some(first), Some(last)) = (path.points.first(), path.points.last()) { - endpoints.push((first[0] as u32, first[1] as u32)); - endpoints.push((last[0] as u32, last[1] as u32)); - } - } - endpoints.sort_unstable(); - endpoints.dedup(); - - let mut hasher = DefaultHasher::new(); - endpoints.hash(&mut hasher); - hasher.finish() - } -} diff --git a/crates/capglyph-core/src/interleave.rs b/crates/capglyph-core/src/interleave.rs deleted file mode 100644 index 7f43c31..0000000 --- a/crates/capglyph-core/src/interleave.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Byte interleave / de-interleave (QR-style, crop is burst error). -//! -//! Interleaving spreads burst errors (crop, scratch) across multiple codewords -//! so that Reed-Solomon / BCH can correct them. Depth = number of parallel -//! codewords interleaved. Depth 0 or 1 is no-op (for compat). - -/// Interleave `bytes` with `depth` (1 = no-op). -/// QR-style: `out[ i*depth + (i % depth?) ]` — here we implement block interleave: -/// split input into `depth` columns (ceil), then read row-wise. -/// Example: depth=3, input=[a0,a1,a2,a3,a4,a5,a6] → columns [[a0,a3,a6],[a1,a4],[a2,a5]] → read rows → [a0,a1,a2,a3,a4,a5,a6] for already small… better illustrate: 12 bytes, depth=4 → [0..12] → out=[0,3,6,9,1,4,7,10,2,5,8,11]. -pub fn interleave(bytes: &[u8], depth: u8) -> Vec { - let d = depth as usize; - if d <= 1 || bytes.is_empty() { - return bytes.to_vec(); - } - let n = bytes.len(); - let rows = n.div_ceil(d); - let mut out = Vec::with_capacity(n); - for r in 0..rows { - for c in 0..d { - let idx = c * rows + r; - if idx < n { - out.push(bytes[idx]); - } - } - } - out -} - -/// Inverse of `interleave`. -pub fn deinterleave(bytes: &[u8], depth: u8) -> Vec { - let d = depth as usize; - if d <= 1 || bytes.is_empty() { - return bytes.to_vec(); - } - let n = bytes.len(); - let rows = n.div_ceil(d); - // Reconstruct original column-major order. - let mut out = vec![0u8; n]; - let mut pos = 0usize; - for r in 0..rows { - for c in 0..d { - let idx = c * rows + r; - if idx < n { - out[idx] = bytes[pos]; - pos += 1; - } - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn interleave_roundtrip() { - let data: Vec = (0..32).collect(); - for depth in [0u8, 1, 2, 4, 8, 16] { - let enc = interleave(&data, depth); - let dec = deinterleave(&enc, depth); - assert_eq!(dec, data, "depth {}", depth); - } - } - - #[test] - fn interleave_spreads_burst() { - let data: Vec = (0..12).collect(); - let enc = interleave(&data, 4); - assert_eq!(enc, vec![0, 3, 6, 9, 1, 4, 7, 10, 2, 5, 8, 11]); - let dec = deinterleave(&enc, 4); - assert_eq!(dec, data); - } - - #[test] - fn empty_identity() { - assert_eq!(interleave(&[], 8), Vec::::new()); - assert_eq!(deinterleave(&[], 8), Vec::::new()); - } -} diff --git a/crates/capglyph-core/src/keying.rs b/crates/capglyph-core/src/keying.rs deleted file mode 100644 index 49f90fb..0000000 --- a/crates/capglyph-core/src/keying.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Key-derived secret watermark layer. -//! -//! The secret layer adds a second, key-located signal on top of the public -//! watermark. Positions are derived from HMAC-SHA256(secret_key, image_hash), -//! so: -//! -//! - **Verification** requires the key: without it, the marked coefficient -//! positions are indistinguishable from noise (the search space is the full -//! block/band grid). -//! - **Forgery** is prevented: an attacker without the key cannot produce an -//! image whose key-derived positions carry the expected signal. -//! - **Parameter learning** is prevented: each image derives different -//! positions (image_hash mixes in), so a diff attack on one image leaks no -//! information about where the secret layer sits in other images. - -use digest::KeyInit; -use hmac::{Hmac, Mac}; -use sha2::Sha256; - -type HmacSha256 = Hmac; - -/// Domain-separated key material for framing + placement. -/// Never log, never send to wasm. k_embed seeds placement PRNG, -/// k_mac authenticates frames, k_object optionally encrypts pointer objects. -#[derive(Clone, Debug)] -pub struct KeyMaterial { - /// K_embed: PRNG seed for secret-layer positions and adaptive placement. - k_embed: [u8; 32], - /// K_mac: HMAC key for framing tag (and later Ed25519 seed / KMS handle). - k_mac: [u8; 32], - /// Optional K_object: AEAD key for pointer-mode ciphertext. - k_object: Option<[u8; 32]>, -} - -impl KeyMaterial { - /// Derive from a single IKM string (CLI --key path) for backwards compat. - /// Uses HKDF-like HMAC expansion with context separation. - pub fn from_ikm(ikm: &str, cover_id: &[u8; 16]) -> Self { - Self { - k_embed: Self::derive(ikm.as_bytes(), b"sigil-k-embed-v1", cover_id), - k_mac: Self::derive(ikm.as_bytes(), b"sigil-k-mac-v1", cover_id), - k_object: None, - } - } - - /// Derive directly from explicit keys (for tests / KMS integration). - pub fn from_keys(k_embed: [u8; 32], k_mac: [u8; 32]) -> Self { - Self { - k_embed, - k_mac, - k_object: None, - } - } - - /// Derive with explicit K_object (pointer mode). - pub fn from_keys_with_object(k_embed: [u8; 32], k_mac: [u8; 32], k_object: [u8; 32]) -> Self { - Self { - k_embed, - k_mac, - k_object: Some(k_object), - } - } - - fn derive(ikm: &[u8], context: &[u8], cover_id: &[u8; 16]) -> [u8; 32] { - let mut mac = ::new_from_slice(ikm).expect("HMAC key valid"); - mac.update(context); - mac.update(cover_id); - let out = mac.finalize().into_bytes(); - let mut key = [0u8; 32]; - key.copy_from_slice(&out); - key - } - - pub fn k_embed(&self) -> &[u8; 32] { - &self.k_embed - } - pub fn k_mac(&self) -> &[u8; 32] { - &self.k_mac - } - pub fn k_object(&self) -> Option<&[u8; 32]> { - self.k_object.as_ref() - } -} - -/// PRF for placement: k_embed mixed with image hash → u64 seed (keyed placement). -pub fn prf_k_embed(k_embed: &[u8; 32], image_hash: u64) -> u64 { - let mut mac = ::new_from_slice(k_embed).expect("HMAC key valid"); - mac.update(b"sigil-k-embed-prf-v1"); - mac.update(&image_hash.to_le_bytes()); - let out = mac.finalize().into_bytes(); - u64::from_le_bytes(out[..8].try_into().expect("32-byte digest")) -} - -/// Tag helper: HMAC frame with k_mac (domain-separated). -pub fn prf_k_mac_tag(k_mac: &[u8; 32], frame: &[u8]) -> [u8; 32] { - let mut mac = ::new_from_slice(k_mac).expect("HMAC key valid"); - mac.update(b"sigil-k-mac-tag-v1"); - mac.update(frame); - let out = mac.finalize().into_bytes(); - let mut tag = [0u8; 32]; - tag.copy_from_slice(&out); - tag -} - -/// Derive the u64 key-seed for an image. -/// -/// `image_hash` should be a content-derived seed (e.g. `dct::stable_seed`) -/// so that the same image + key always yields the same positions, while -/// different images diverge even under the same key. -pub fn key_seed(secret_key: &str, image_hash: u64) -> u64 { - let mut mac = ::new_from_slice(secret_key.as_bytes()) - .expect("HMAC accepts any key"); - mac.update(b"sigil-secret-layer-v1"); - mac.update(&image_hash.to_le_bytes()); - let digest = mac.finalize().into_bytes(); - u64::from_le_bytes(digest[..8].try_into().expect("32-byte digest")) -} - -/// Derive a 32-byte keystream for encrypting learned-mode payload bits. -/// -/// `context` separates this derivation from `key_seed` (different domain -/// strings). The stream is XORed with the recipient-id bitstring so that -/// the payload is pseudorandom without the key (ID privacy + forgery -/// resistance), and recoverable with it. -pub fn keystream_bytes(secret_key: &str, context: &str, image_hash: u64) -> [u8; 32] { - let mut mac = ::new_from_slice(secret_key.as_bytes()) - .expect("HMAC accepts any key"); - mac.update(b"sigil-learned-keystream-v1"); - mac.update(context.as_bytes()); - mac.update(&image_hash.to_le_bytes()); - let digest = mac.finalize().into_bytes(); - digest[..32].try_into().expect("32-byte digest") -} diff --git a/crates/capglyph-core/src/lib.rs b/crates/capglyph-core/src/lib.rs deleted file mode 100644 index 7a8ba26..0000000 --- a/crates/capglyph-core/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! capglyph-core — pure codec and carrier primitives shared with capglyph-wasm and capglyphd. -//! -//! This crate contains no `clap`/`glob`/`tracing-subscriber`/`c2pa`/`trustmark` -//! dependencies and is `wasm32-unknown-unknown` clean (`cargo tree --target wasm32-unknown-unknown` -//! must not contain `clap`/`glob`). - -pub mod carrier; -pub mod ecc; -pub mod framing; -pub mod geometry; -pub mod interleave; -pub mod keying; -pub mod placement; -pub mod registration; -pub mod signal; -pub mod spread_spectrum; - -// Re-export placement at crate root for convenience. -pub use placement::Placement; diff --git a/crates/capglyph-core/src/placement.rs b/crates/capglyph-core/src/placement.rs deleted file mode 100644 index 75d1626..0000000 --- a/crates/capglyph-core/src/placement.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Placement strategy for watermark embedding. - -/// Watermark placement strategy (geometry-derived or PRNG/edge baselines). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Placement { - /// Geometry-derived skeleton positions (default). - #[default] - Skeleton, - /// Pseudorandom scatter (baseline / fallback). - Prng, - /// Edge-density (Sobel) baseline. - Edge, -} - -impl std::fmt::Display for Placement { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Skeleton => write!(f, "skeleton"), - Self::Prng => write!(f, "prng"), - Self::Edge => write!(f, "edge"), - } - } -} diff --git a/crates/capglyph-core/src/registration.rs b/crates/capglyph-core/src/registration.rs deleted file mode 100644 index 5f206f3..0000000 --- a/crates/capglyph-core/src/registration.rs +++ /dev/null @@ -1,744 +0,0 @@ -//! Registered-residual original-assisted extractor (CTX-0021). -//! -//! Hybrid path: `blind locator → cover family → strong verify`. -//! The core primitive is `R = I_aligned − I_original` (pixel residual after -//! feature-based warp), then matched filtering on the keyed lattice to produce -//! soft bits. This cancels host interference so the residual is dominated by -//! the ±`ID_EMBED_DELTA` / `DWT_ID_EMBED_STRENGTH` signal. -//! -//! Design per `capglyph-docs/research/media-credential/technology/pointer-and-stego.md` -//! §5 and `capglyph-docs/research/media-credential/architecture/capglyph-core-api.md` -//! (legacy: `sigil-docs/.../sigil-core-api.md`) -//! §4.5. -//! -//! Registration is intentionally dependency-free: the default `Identity` and -//! `Translation` estimators use only `image` + pure Rust NCC. Heavy -//! feature-point / homography deps (e.g. `imageproc` ORB+RANSAC) would be gated -//! behind a `registration` feature — not pulled into the wasm graph. - -#![allow( - clippy::needless_range_loop, - clippy::too_many_arguments, - clippy::identity_op, - clippy::type_complexity -)] - -use anyhow::Result; -use image::{ImageBuffer, Rgb}; - -/// Aligned submitted image plus the estimated transform for audit. -#[derive(Debug, Clone)] -pub struct AlignedImage { - pub image: ImageBuffer, Vec>, - pub transform: Transform, -} - -/// Affine transform (3×3) in original coordinates, plus diagnostics. -#[derive(Debug, Clone)] -pub struct Transform { - /// Row-major 3×3 homogeneous matrix. Identity is `[[1,0,0],[0,1,0],[0,0,1]]`. - /// Maps original coords → submitted coords: `p_sub = M * p_orig`. - /// To align, we sample `submitted` at `M * p_orig`. - pub matrix: [[f32; 3]; 3], - /// Estimated translation (dx, dy) for convenience. - pub translation: (f32, f32), - /// Number of inliers (for RANSAC stub) or NCC peak sharpness. - pub inliers: u32, - /// Reprojection / correlation error (lower is better). - pub reprojection_error: f32, -} - -impl Default for Transform { - fn default() -> Self { - Self { - matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], - translation: (0.0, 0.0), - inliers: 0, - reprojection_error: 0.0, - } - } -} - -/// Registration trait — feature-registration warp (affine). -/// -/// `original` is the server-held cover (private), `submitted` is the -/// user-supplied credential image (possibly JPEG'd, translated, slightly -/// scaled). `align` returns `submitted` warped into `original`'s coordinate -/// frame. -pub trait Registration: Send + Sync { - fn align( - &self, - original: &ImageBuffer, Vec>, - submitted: &ImageBuffer, Vec>, - ) -> Result; - - fn name(&self) -> &'static str { - "unknown" - } -} - -// ── Identity (fallback) ────────────────────────────────────────────────────── - -/// Identity warp — returns `submitted` cloned (or resized to `original` size). -/// This is the correct fallback when no geometric distortion is expected, and -/// also the wasm-safe default (no NCC, no allocation beyond resize). -pub struct IdentityRegistration; - -impl Registration for IdentityRegistration { - fn align( - &self, - original: &ImageBuffer, Vec>, - submitted: &ImageBuffer, Vec>, - ) -> Result { - let (ow, oh) = original.dimensions(); - let (sw, sh) = submitted.dimensions(); - let aligned = if ow == sw && oh == sh { - submitted.clone() - } else { - // Resize submitted to original size via Triangle filter (good for - // photographic content, no extra deps). - let dyn_sub = image::DynamicImage::ImageRgb8(submitted.clone()); - dyn_sub - .resize_exact(ow, oh, image::imageops::FilterType::Triangle) - .to_rgb8() - }; - Ok(AlignedImage { - image: aligned, - transform: Transform::default(), - }) - } - - fn name(&self) -> &'static str { - "identity" - } -} - -// ── Translation via NCC ───────────────────────────────────────────────────── - -/// Translation registration via normalized cross-correlation. -/// -/// Searches `[-max_shift, max_shift]` in x/y at low-res (128²), then refines -/// ±2 at full res around the peak. No external deps. -/// -/// `max_shift` is in pixels at full resolution (default 32). Set to 0 for -/// identity-equivalent but still goes through the estimator. -pub struct TranslationRegistration { - pub max_shift: i32, -} - -impl Default for TranslationRegistration { - fn default() -> Self { - Self { max_shift: 32 } - } -} - -impl Registration for TranslationRegistration { - fn align( - &self, - original: &ImageBuffer, Vec>, - submitted: &ImageBuffer, Vec>, - ) -> Result { - let (ow, oh) = original.dimensions(); - // Normalize sizes first: if submitted size differs, resize to original - // before NCC so the correlation is meaningful (scale is not handled - // here — that belongs to Affine). - let submitted_norm = if submitted.dimensions() != (ow, oh) { - let dyn_sub = image::DynamicImage::ImageRgb8(submitted.clone()); - dyn_sub - .resize_exact(ow, oh, image::imageops::FilterType::Triangle) - .to_rgb8() - } else { - submitted.clone() - }; - - // Grayscale conversion - let gray_orig = to_grayscale(original); - let gray_sub = to_grayscale(&submitted_norm); - - let (dx, dy, peak, error) = - estimate_translation_ncc(&gray_orig, &gray_sub, ow, oh, self.max_shift); - - let aligned = warp_translation(&submitted_norm, dx, dy, ow, oh); - - let mut matrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; - matrix[0][2] = dx as f32; - matrix[1][2] = dy as f32; - - Ok(AlignedImage { - image: aligned, - transform: Transform { - matrix, - translation: (dx as f32, dy as f32), - inliers: if peak > 0.5 { 100 } else { 10 }, - reprojection_error: error, - }, - }) - } - - fn name(&self) -> &'static str { - "translation-ncc" - } -} - -// ── Affine stub (future: ORB+RANSAC) ─────────────────────────────────────── - -/// Affine registration stub — currently delegates to `TranslationRegistration` -/// and documents the upgrade path. A full implementation would use -/// `imageproc`-style ORB/SIFT features + RANSAC homography behind a feature -/// gate (`registration` feature) to keep the wasm graph clean. -/// -/// This satisfies the CTX-0021 acceptance that the `Registration` trait exists -/// and the warp is affine-capable (matrix is 3×3), even though the estimator -/// is still translation-only. -pub struct AffineRegistration { - pub max_shift: i32, -} - -impl Default for AffineRegistration { - fn default() -> Self { - Self { max_shift: 32 } - } -} - -impl Registration for AffineRegistration { - fn align( - &self, - original: &ImageBuffer, Vec>, - submitted: &ImageBuffer, Vec>, - ) -> Result { - // Delegate to translation for now; the matrix is still 3×3 affine. - let t = TranslationRegistration { - max_shift: self.max_shift, - }; - let aligned = t.align(original, submitted)?; - Ok(aligned) - } - - fn name(&self) -> &'static str { - "affine-stub(translation)" - } -} - -// ── Residual R = I_aligned − I_original ───────────────────────────────────── - -/// Compute pixel residual `R = I_aligned − I_original` as f32 per channel. -/// Returned as a flat vec of `(R,R,G,B)` diffs in row-major order, but callers -/// usually want the DCT/DWT of `R` directly — see `dct::extract_…_residual`. -/// -/// This helper is exposed for `verify_original_assisted` audit and for tests. -/// It never panics on size mismatch: if `aligned` and `original` differ, the -/// overlapping region is diffed and the remainder is zero-padded (the warp -/// should have already normalized sizes, so this is a safety net). -#[allow(clippy::needless_range_loop)] -pub fn residual_image( - original: &ImageBuffer, Vec>, - aligned: &ImageBuffer, Vec>, -) -> Vec> { - let (ow, oh) = original.dimensions(); - let (aw, ah) = aligned.dimensions(); - let w = ow.min(aw) as usize; - let h = oh.min(ah) as usize; - let mut out = vec![vec![[0.0f32; 3]; w]; h]; - for y in 0..h { - for x in 0..w { - let o = original.get_pixel(x as u32, y as u32); - let a = aligned.get_pixel(x as u32, y as u32); - out[y][x][0] = a[0] as f32 - o[0] as f32; - out[y][x][1] = a[1] as f32 - o[1] as f32; - out[y][x][2] = a[2] as f32 - o[2] as f32; - } - } - out -} - -/// Convenience: residual as flat `Rgb` difference clamped to `[0,255]` with -/// bias 128 (for visualization). Not used for detection — detection uses f32. -#[allow(clippy::needless_range_loop)] -pub fn residual_image_visual( - original: &ImageBuffer, Vec>, - aligned: &ImageBuffer, Vec>, -) -> ImageBuffer, Vec> { - let (ow, oh) = original.dimensions(); - let (aw, ah) = aligned.dimensions(); - assert_eq!( - (ow, oh), - (aw, ah), - "residual visual requires same dimensions" - ); - ImageBuffer::from_fn(ow, oh, |x, y| { - let o = original.get_pixel(x, y); - let a = aligned.get_pixel(x, y); - let r = ((a[0] as i16 - o[0] as i16) + 128).clamp(0, 255) as u8; - let g = ((a[1] as i16 - o[1] as i16) + 128).clamp(0, 255) as u8; - let b = ((a[2] as i16 - o[2] as i16) + 128).clamp(0, 255) as u8; - Rgb([r, g, b]) - }) -} - -// ── Cover vault / hybrid bootstrap ────────────────────────────────────────── - -/// Cover vault for hybrid extraction — maps `cover_id` → original image. -/// In production this is a DB / R2 bucket; here it is an in-memory map for -/// tests and for the `register` module's acceptance test. -/// -/// The `cover_id` is the 16-byte truncated `stable_seed` or HMAC-derived -/// family id. The vault is intentionally not a file-XOR store — it holds -/// originals for `R = aligned − original`, not for byte-level diff. -#[derive(Debug, Default, Clone)] -pub struct CoverVault { - entries: Vec<(Vec, ImageBuffer, Vec>)>, -} - -impl CoverVault { - pub fn new() -> Self { - Self { - entries: Vec::new(), - } - } - - pub fn insert(&mut self, cover_id: Vec, image: ImageBuffer, Vec>) { - self.entries.push((cover_id, image)); - } - - pub fn insert_bytes(&mut self, cover_id: &[u8], image: ImageBuffer, Vec>) { - self.entries.push((cover_id.to_vec(), image)); - } - - pub fn all(&self) -> &[(Vec, ImageBuffer, Vec>)] { - &self.entries - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - pub fn get(&self, cover_id: &[u8]) -> Option<&ImageBuffer, Vec>> { - self.entries - .iter() - .find(|(id, _)| id.as_slice() == cover_id) - .map(|(_, img)| img) - } -} - -/// Hybrid extractor result — which cover matched and the decoded payload. -#[derive(Debug)] -pub struct HybridMatch { - /// Index in the vault that matched (strong verify succeeded). - pub vault_index: usize, - /// Vault cover_id that matched. - pub cover_id: Vec, - /// Decoded payload bytes after ECC + framing auth. - pub payload: Vec, - /// Transform diagnostics from registration. - pub transform: Transform, -} - -// ── Helpers: grayscale, NCC, warp ─────────────────────────────────────────── - -#[allow(clippy::needless_range_loop)] -fn to_grayscale(img: &ImageBuffer, Vec>) -> Vec> { - let (w, h) = img.dimensions(); - let w = w as usize; - let h = h as usize; - let mut out = vec![vec![0.0f32; w]; h]; - for y in 0..h { - for x in 0..w { - let p = img.get_pixel(x as u32, y as u32); - // BT.601 luma - let luma = 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32; - out[y][x] = luma; - } - } - out -} - -#[allow(clippy::needless_range_loop)] -fn downscale_gray( - gray: &[Vec], - src_w: u32, - src_h: u32, - dst_w: u32, - dst_h: u32, -) -> Vec> { - if src_w == dst_w && src_h == dst_h { - return gray.to_vec(); - } - let mut out = vec![vec![0.0f32; dst_w as usize]; dst_h as usize]; - let scale_x = src_w as f32 / dst_w as f32; - let scale_y = src_h as f32 / dst_h as f32; - // Nearest-neighbor sampling preserves high-frequency random texture better - // for NCC than box averaging (which turns random high-frequency into flat DC). - for y in 0..dst_h as usize { - for x in 0..dst_w as usize { - let sx = ((x as f32 + 0.5) * scale_x) as usize; - let sy = ((y as f32 + 0.5) * scale_y) as usize; - let sx = sx.min(src_w as usize - 1); - let sy = sy.min(src_h as usize - 1); - out[y][x] = gray[sy][sx]; - } - } - out -} - -#[allow(clippy::needless_range_loop)] -fn ncc_at_shift(a: &[Vec], b: &[Vec], w: usize, h: usize, dx: i32, dy: i32) -> f32 { - // Overlap region: a[y][x] vs b[y+dy][x+dx] - let mut sum_a = 0.0f64; - let mut sum_b = 0.0f64; - let mut count = 0usize; - // First pass: means - for y in 0..h { - for x in 0..w { - let bx = x as i32 + dx; - let by = y as i32 + dy; - if bx < 0 || by < 0 || bx >= w as i32 || by >= h as i32 { - continue; - } - sum_a += a[y][x] as f64; - sum_b += b[by as usize][bx as usize] as f64; - count += 1; - } - } - if count == 0 { - return f32::NEG_INFINITY; - } - let mean_a = sum_a / count as f64; - let mean_b = sum_b / count as f64; - let mut num = 0.0f64; - let mut denom_a = 0.0f64; - let mut denom_b = 0.0f64; - for y in 0..h { - for x in 0..w { - let bx = x as i32 + dx; - let by = y as i32 + dy; - if bx < 0 || by < 0 || bx >= w as i32 || by >= h as i32 { - continue; - } - let da = a[y][x] as f64 - mean_a; - let db = b[by as usize][bx as usize] as f64 - mean_b; - num += da * db; - denom_a += da * da; - denom_b += db * db; - } - } - let denom = (denom_a * denom_b).sqrt(); - if denom < 1e-9 { - return 0.0; - } - (num / denom) as f32 -} - -#[allow(clippy::needless_range_loop)] -fn estimate_translation_ncc( - gray_orig: &[Vec], - gray_sub: &[Vec], - ow: u32, - oh: u32, - max_shift: i32, -) -> (i32, i32, f32, f32) { - if max_shift == 0 { - return (0, 0, 1.0, 0.0); - } - let ow_us = ow as usize; - let oh_us = oh as usize; - - // Low-res stage: 128×128 or original if smaller - let low_w = 128u32.min(ow); - let low_h = 128u32.min(oh); - let scale_x = ow as f32 / low_w as f32; - let scale_y = oh as f32 / low_h as f32; - - let low_orig = downscale_gray(gray_orig, ow, oh, low_w, low_h); - let low_sub = downscale_gray(gray_sub, ow, oh, low_w, low_h); - - let max_low_x = ((max_shift as f32 / scale_x).ceil() as i32).max(2); - let max_low_y = ((max_shift as f32 / scale_y).ceil() as i32).max(2); - - let mut best_dx_low = 0i32; - let mut best_dy_low = 0i32; - let mut best_ncc_low = f32::NEG_INFINITY; - for dy in -max_low_y..=max_low_y { - for dx in -max_low_x..=max_low_x { - let ncc = ncc_at_shift(&low_orig, &low_sub, low_w as usize, low_h as usize, dx, dy); - if ncc > best_ncc_low { - best_ncc_low = ncc; - best_dx_low = dx; - best_dy_low = dy; - } - } - } - - // Map back to full-res - let est_dx = (best_dx_low as f32 * scale_x).round() as i32; - let est_dy = (best_dy_low as f32 * scale_y).round() as i32; - - // Refine ±3 at full res around estimate - let refine = 3i32; - let mut best_dx = est_dx; - let mut best_dy = est_dy; - let mut best_ncc = f32::NEG_INFINITY; - for dy in (est_dy - refine)..=(est_dy + refine) { - if dy.abs() > max_shift { - continue; - } - for dx in (est_dx - refine)..=(est_dx + refine) { - if dx.abs() > max_shift { - continue; - } - let ncc = ncc_at_shift(gray_orig, gray_sub, ow_us, oh_us, dx, dy); - if ncc > best_ncc { - best_ncc = ncc; - best_dx = dx; - best_dy = dy; - } - } - } - - // Fallback: if low-res peak was weak (NCC < 0.5) the hierarchical search - // likely failed (e.g. high-frequency random texture averaged away). Do a - // full exhaustive search at full res in this case. This is slower (up to - // ~4k positions) but only triggers on the weak-peak path. - if best_ncc < 0.5 { - let mut full_best_dx = best_dx; - let mut full_best_dy = best_dy; - let mut full_best_ncc = best_ncc; - for dy in -max_shift..=max_shift { - for dx in -max_shift..=max_shift { - let ncc = ncc_at_shift(gray_orig, gray_sub, ow_us, oh_us, dx, dy); - if ncc > full_best_ncc { - full_best_ncc = ncc; - full_best_dx = dx; - full_best_dy = dy; - } - } - } - best_dx = full_best_dx; - best_dy = full_best_dy; - best_ncc = full_best_ncc; - } - - // Reprojection error as 1 - NCC (0 = perfect) - let error = 1.0 - best_ncc.clamp(-1.0, 1.0); - (best_dx, best_dy, best_ncc, error) -} - -#[allow(clippy::needless_range_loop)] -fn warp_translation( - submitted: &ImageBuffer, Vec>, - dx: i32, - dy: i32, - out_w: u32, - out_h: u32, -) -> ImageBuffer, Vec> { - // Aligned[x,y] = submitted[x+dx, y+dy] (see module docs for convention) - // Out-of-bounds samples are clamped to edge (replicate) to avoid black borders - // that would destroy DCT high-frequency matching. - let (sw, sh) = submitted.dimensions(); - let mut out = ImageBuffer::new(out_w, out_h); - for y in 0..out_h { - for x in 0..out_w { - let sx = x as i32 + dx; - let sy = y as i32 + dy; - let sx_clamped = sx.clamp(0, sw as i32 - 1) as u32; - let sy_clamped = sy.clamp(0, sh as i32 - 1) as u32; - let p = submitted.get_pixel(sx_clamped, sy_clamped); - out.put_pixel(x, y, *p); - } - } - out -} - -// ── Bilinear warp for future affine (currently unused, but provided) ───────── - -/// Warp `submitted` by a 3×3 affine matrix `M` where `p_sub = M * p_orig`. -/// Uses bilinear sampling, edge-clamped. -#[allow(dead_code)] -#[allow(clippy::needless_range_loop)] -pub fn warp_affine( - submitted: &ImageBuffer, Vec>, - matrix: [[f32; 3]; 3], - out_w: u32, - out_h: u32, -) -> ImageBuffer, Vec> { - let (sw, sh) = submitted.dimensions(); - let mut out = ImageBuffer::new(out_w, out_h); - // Invert matrix for backward sampling. For affine, invert 2×2 + translation. - let det = matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0]; - if det.abs() < 1e-6 { - // Degenerate — fall back to translation component only - let dx = matrix[0][2].round() as i32; - let dy = matrix[1][2].round() as i32; - return warp_translation(submitted, dx, dy, out_w, out_h); - } - let inv_det = 1.0 / det; - let a = matrix[1][1] * inv_det; - let b = -matrix[0][1] * inv_det; - let c = -matrix[1][0] * inv_det; - let d = matrix[0][0] * inv_det; - let tx = matrix[0][2]; - let ty = matrix[1][2]; - // Inverse translation: - (inv 2×2 * t) - let itx = -(a * tx + b * ty); - let ity = -(c * tx + d * ty); - - for y in 0..out_h { - for x in 0..out_w { - // p_sub = M * p_orig => p_orig = M^{-1} * p_sub? Wait we want - // aligned[x,y] = submitted[ M * (x,y) ], so forward mapping. - // We sample submitted at (a*x + b*y + tx, c*x + d*y + ty) where - // the matrix is the forward M. The inverse above is not needed — - // we directly apply M. - let fx = matrix[0][0] * x as f32 + matrix[0][1] * y as f32 + matrix[0][2]; - let fy = matrix[1][0] * x as f32 + matrix[1][1] * y as f32 + matrix[1][2]; - let p = sample_bilinear(submitted, fx, fy, sw, sh); - out.put_pixel(x, y, p); - } - } - let _ = (itx, ity, a, b, c, d); // keep inverse calc for future use / lint - out -} - -#[allow(clippy::needless_range_loop)] -fn sample_bilinear( - img: &ImageBuffer, Vec>, - fx: f32, - fy: f32, - sw: u32, - sh: u32, -) -> Rgb { - let x0 = fx.floor() as i32; - let y0 = fy.floor() as i32; - let x1 = x0 + 1; - let y1 = y0 + 1; - let wx = fx - x0 as f32; - let wy = fy - y0 as f32; - - let sample = |x: i32, y: i32| { - let xc = x.clamp(0, sw as i32 - 1) as u32; - let yc = y.clamp(0, sh as i32 - 1) as u32; - let p = img.get_pixel(xc, yc); - [p[0] as f32, p[1] as f32, p[2] as f32] - }; - - let c00 = sample(x0, y0); - let c10 = sample(x1, y0); - let c01 = sample(x0, y1); - let c11 = sample(x1, y1); - - let r = (1.0 - wx) * (1.0 - wy) * c00[0] - + wx * (1.0 - wy) * c10[0] - + (1.0 - wx) * wy * c01[0] - + wx * wy * c11[0]; - let g = (1.0 - wx) * (1.0 - wy) * c00[1] - + wx * (1.0 - wy) * c10[1] - + (1.0 - wx) * wy * c01[1] - + wx * wy * c11[1]; - let b = (1.0 - wx) * (1.0 - wy) * c00[2] - + wx * (1.0 - wy) * c10[2] - + (1.0 - wx) * wy * c01[2] - + wx * wy * c11[2]; - - Rgb([ - r.round().clamp(0.0, 255.0) as u8, - g.round().clamp(0.0, 255.0) as u8, - b.round().clamp(0.0, 255.0) as u8, - ]) -} - -#[cfg(test)] -mod tests { - use super::*; - use image::{ImageBuffer, Rgb}; - - fn make_checker(w: u32, h: u32) -> ImageBuffer, Vec> { - ImageBuffer::from_fn(w, h, |x, y| { - let v = if ((x / 16) + (y / 16)) % 2 == 0 { - 20 - } else { - 220 - }; - Rgb([v, v, v]) - }) - } - - #[test] - fn identity_roundtrip() { - let (w, h) = (128, 128); - let orig = make_checker(w, h); - let sub = orig.clone(); - let reg = IdentityRegistration; - let aligned = reg.align(&orig, &sub).unwrap(); - assert_eq!(aligned.image.dimensions(), (w, h)); - assert_eq!(aligned.image, orig); - } - - #[test] - fn translation_estimator_finds_shift() { - let (w, h) = (128, 128); - let orig = make_checker(w, h); - // Create submitted shifted by warp dx=7 (which is left shift by 7) - let dx = 7; - let dy = -5; - let sub = warp_translation(&orig, dx, dy, w, h); - // Estimator should find the alignment shift that recovers orig: - // aligned[x]=sub[x+dx_est] => need dx_est = -dx to invert warp - let reg = TranslationRegistration { max_shift: 16 }; - let aligned = reg.align(&orig, &sub).unwrap(); - assert!( - (aligned.transform.translation.0 + dx as f32).abs() <= 1.0, - "dx est {:?} vs true -{}", - aligned.transform.translation, - dx - ); - assert!( - (aligned.transform.translation.1 + dy as f32).abs() <= 1.0, - "dy est {:?} vs true -{}", - aligned.transform.translation, - dy - ); - // And aligned image should be near-identical to orig (except edge clamp) - // Check that the central region matches - for y in 16..(h - 16) { - for x in 16..(w - 16) { - assert_eq!(aligned.image.get_pixel(x, y), orig.get_pixel(x, y)); - } - } - } - - #[test] - fn residual_zero_for_identical() { - let (w, h) = (64, 64); - let orig = make_checker(w, h); - let aligned = orig.clone(); - let res = residual_image(&orig, &aligned); - for row in res { - for px in row { - assert_eq!(px, [0.0, 0.0, 0.0]); - } - } - } - - #[test] - fn vault_insert_and_get() { - let mut vault = CoverVault::new(); - let img = make_checker(32, 32); - vault.insert_bytes(b"cover1", img.clone()); - assert_eq!(vault.len(), 1); - assert!(vault.get(b"cover1").is_some()); - assert!(vault.get(b"other").is_none()); - } - - #[test] - fn affine_stub_delegates() { - let (w, h) = (64, 64); - let orig = make_checker(w, h); - let sub = orig.clone(); - let reg = AffineRegistration::default(); - let aligned = reg.align(&orig, &sub).unwrap(); - assert_eq!(aligned.image.dimensions(), (w, h)); - } -} diff --git a/crates/capglyph-core/src/signal.rs b/crates/capglyph-core/src/signal.rs deleted file mode 100644 index 5dd10e7..0000000 --- a/crates/capglyph-core/src/signal.rs +++ /dev/null @@ -1,140 +0,0 @@ -/// Alpha-channel signal metrics for a RGBA image buffer. -#[derive(Debug, Clone)] -pub struct SignalMetrics { - /// Image dimensions - pub width: u32, - pub height: u32, - /// Total pixel count - pub total_pixels: u64, - /// Number of pixels where alpha > 0 - pub nonzero_alpha_count: u64, - /// Fraction of pixels with alpha > 0 - pub nonzero_alpha_frac: f64, - /// Number of pixels where 0 < alpha < 255 (semi-transparent = watermark signal) - pub semi_transparent_count: u64, - /// Fraction of semi-transparent pixels - pub semi_transparent_frac: f64, - /// Mean alpha value across all pixels (0.0–255.0) - pub alpha_mean: f64, - /// Maximum alpha value observed (0–255) - pub alpha_max: u8, - /// 99th-percentile alpha value - pub alpha_p99: u8, - /// Mean absolute error vs pure white after compositing alpha over white background - pub composite_mae: f64, -} - -impl SignalMetrics { - /// Compute metrics from a flat RGBA byte buffer (row-major, 4 bytes per pixel). - /// - /// `pixels` must have length `width * height * 4`. - pub fn compute(pixels: &[u8], width: u32, height: u32) -> Self { - assert_eq!( - pixels.len(), - (width as usize) * (height as usize) * 4, - "pixel buffer size mismatch" - ); - - let total = (width as u64) * (height as u64); - let mut nonzero: u64 = 0; - let mut semi: u64 = 0; - let mut alpha_sum: u64 = 0; - let mut alpha_max: u8 = 0; - let mut mae_sum: f64 = 0.0; - let mut alpha_hist = [0u64; 256]; - - #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)] - for chunk in pixels.chunks_exact(4) { - let r = chunk[0] as f64; - let g = chunk[1] as f64; - let b = chunk[2] as f64; - let a = chunk[3]; - let af = a as f64 / 255.0; - - if a > 0 { - nonzero += 1; - } - if a > 0 && a < 255 { - semi += 1; - } - alpha_sum += a as u64; - if a > alpha_max { - alpha_max = a; - } - alpha_hist[a as usize] += 1; - - let cr = af * r + (1.0 - af) * 255.0; - let cg = af * g + (1.0 - af) * 255.0; - let cb = af * b + (1.0 - af) * 255.0; - mae_sum += (255.0 - cr + (255.0 - cg) + (255.0 - cb)) / 3.0; - } - - let p99_target = (0.99 * total as f64).ceil() as u64; - let mut cumulative: u64 = 0; - let mut alpha_p99: u8 = 0; - for (val, &count) in alpha_hist.iter().enumerate() { - cumulative += count; - if cumulative >= p99_target { - alpha_p99 = val as u8; - break; - } - } - - SignalMetrics { - width, - height, - total_pixels: total, - nonzero_alpha_count: nonzero, - nonzero_alpha_frac: nonzero as f64 / total as f64, - semi_transparent_count: semi, - semi_transparent_frac: semi as f64 / total as f64, - alpha_mean: alpha_sum as f64 / total as f64, - alpha_max, - alpha_p99, - composite_mae: mae_sum / total as f64, - } - } - - /// Whether the watermark signal is present: sufficient semi-transparent pixels. - /// Uses semi-transparent fraction (0 < α < 255) as the signal indicator, - /// since normal opaque images have 0% semi-transparent pixels. - pub fn is_present(&self, threshold: f64) -> bool { - self.semi_transparent_frac >= threshold - } - - pub fn is_present_v2(&self, threshold: f64, min_pixels: u64) -> bool { - self.semi_transparent_frac >= threshold && self.semi_transparent_count >= min_pixels - } - - /// Human-readable summary line. - pub fn summary(&self) -> String { - format!( - "α_semi={:.4}% α_mean={:.4} α_max={} α_p99={} composite_MAE={:.6}", - self.semi_transparent_frac * 100.0, - self.alpha_mean, - self.alpha_max, - self.alpha_p99, - self.composite_mae, - ) - } -} - -#[cfg(test)] -mod tests { - use super::SignalMetrics; - - #[test] - #[allow(clippy::chunks_exact_to_as_chunks)] - fn v2_alpha_requires_minimum_signal_count() { - let mut pixels = vec![255u8; 4 * 100]; - for pixel in pixels.chunks_exact_mut(4).take(15) { - pixel[3] = 128; - } - let metrics = SignalMetrics::compute(&pixels, 10, 10); - assert!(!metrics.is_present_v2(0.0001, 16)); - - pixels[15 * 4 + 3] = 128; - let metrics = SignalMetrics::compute(&pixels, 10, 10); - assert!(metrics.is_present_v2(0.0001, 16)); - } -} diff --git a/crates/capglyph-core/src/spread_spectrum.rs b/crates/capglyph-core/src/spread_spectrum.rs deleted file mode 100644 index fce70bd..0000000 --- a/crates/capglyph-core/src/spread_spectrum.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! Spread-spectrum encoding for recoverable recipient ID watermarking. -//! -//! Encodes a string (recipient ID) as binary bits, embeds each bit redundantly -//! across multiple coefficients (DCT or DWT), and extracts the ID via averaging. - -use anyhow::{Context, Result}; - -/// Redundancy factor: how many coefficients encode each bit -pub const REDUNDANCY: usize = 8; - -/// Encode a recipient ID string into binary bits -pub fn encode_bits(recipient_id: &str) -> Vec { - recipient_id - .bytes() - .flat_map(|b| (0..8).rev().map(move |i| (b >> i) & 1)) - .collect() -} - -/// Convert string to bool bits (for DCT/DWT direct embedding) -pub fn str_to_bits(s: &str) -> Vec { - s.bytes() - .flat_map(|b| (0..8).rev().map(move |i| (b >> i) & 1 == 1)) - .collect() -} - -/// Convert bool bits back to string -pub fn bits_to_str(bits: &[bool]) -> Result { - anyhow::ensure!( - bits.len().is_multiple_of(8), - "Bit count must be multiple of 8" - ); - - let bytes: Vec = bits - .chunks(8) - .map(|chunk| chunk.iter().fold(0u8, |acc, &bit| (acc << 1) | (bit as u8))) - .collect(); - - String::from_utf8(bytes).context("Invalid UTF-8 in decoded bits") -} - -/// Decode binary bits back to string (legacy u8 API for DWT) -pub fn decode_bits(bits: &[u8]) -> Result { - anyhow::ensure!( - bits.len().is_multiple_of(8), - "Bit count must be multiple of 8" - ); - - let bytes: Vec = bits - .chunks(8) - .map(|chunk| chunk.iter().fold(0u8, |acc, &bit| (acc << 1) | bit)) - .collect(); - - String::from_utf8(bytes).context("Invalid UTF-8 in decoded bits") -} - -/// Embed bits into coefficient array via ±strength modulation -/// -/// Each bit is embedded into REDUNDANCY consecutive coefficients: -/// - bit=1 → coeff += strength -/// - bit=0 → coeff -= strength -pub fn embed_into_coeffs(coeffs: &mut [f32], bits: &[u8], strength: f32, seed: u64) { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let total_needed = bits.len() * REDUNDANCY; - if coeffs.len() < total_needed { - tracing::warn!( - "Not enough coefficients ({}) for {} bits with redundancy {}. Truncating.", - coeffs.len(), - bits.len(), - REDUNDANCY - ); - } - - // Pseudo-random permutation of coefficient indices (seeded by seed) - let mut hasher = DefaultHasher::new(); - seed.hash(&mut hasher); - let perm_seed = hasher.finish(); - - let mut indices: Vec = (0..coeffs.len()).collect(); - // Simple Fisher-Yates shuffle with seed - let mut rng_state = perm_seed; - for i in (1..indices.len()).rev() { - rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1); - let j = (rng_state as usize) % (i + 1); - indices.swap(i, j); - } - - for (bit_idx, &bit) in bits.iter().enumerate() { - let delta = if bit == 1 { strength } else { -strength }; - for r in 0..REDUNDANCY { - let coeff_idx = bit_idx * REDUNDANCY + r; - if coeff_idx >= indices.len() { - break; - } - coeffs[indices[coeff_idx]] += delta; - } - } -} - -/// Extract bits from coefficient array via correlation detection -pub fn extract_from_coeffs(coeffs: &[f32], bit_count: usize, seed: u64) -> Vec { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let total_needed = bit_count * REDUNDANCY; - if coeffs.len() < total_needed { - tracing::warn!( - "Not enough coefficients ({}) for {} bits. Extracting partial.", - coeffs.len(), - bit_count - ); - } - - // Same pseudo-random permutation as embed - let mut hasher = DefaultHasher::new(); - seed.hash(&mut hasher); - let perm_seed = hasher.finish(); - - let mut indices: Vec = (0..coeffs.len()).collect(); - let mut rng_state = perm_seed; - for i in (1..indices.len()).rev() { - rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1); - let j = (rng_state as usize) % (i + 1); - indices.swap(i, j); - } - - let mut bits = Vec::with_capacity(bit_count); - for bit_idx in 0..bit_count { - let mut sum = 0.0; - let mut count = 0; - for r in 0..REDUNDANCY { - let coeff_idx = bit_idx * REDUNDANCY + r; - if coeff_idx >= indices.len() { - break; - } - sum += coeffs[indices[coeff_idx]]; - count += 1; - } - - let avg = if count > 0 { sum / count as f32 } else { 0.0 }; - bits.push(if avg > 0.0 { 1 } else { 0 }); - } - - bits -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_encode_decode_roundtrip() { - let id = "user123"; - let bits = encode_bits(id); - assert_eq!(bits.len(), id.len() * 8); - - let decoded = decode_bits(&bits).unwrap(); - assert_eq!(decoded, id); - } - - #[test] - fn test_embed_extract_roundtrip() { - let id = "alice"; - let bits = encode_bits(id); - - let mut coeffs = vec![0.0f32; bits.len() * REDUNDANCY + 100]; - embed_into_coeffs(&mut coeffs, &bits, 10.0, 42); - - let extracted_bits = extract_from_coeffs(&coeffs, bits.len(), 42); - let decoded = decode_bits(&extracted_bits).unwrap(); - - assert_eq!(decoded, id); - } - - #[test] - fn test_noisy_extraction() { - let id = "bob"; - let bits = encode_bits(id); - - let mut coeffs = vec![0.0f32; bits.len() * REDUNDANCY + 50]; - embed_into_coeffs(&mut coeffs, &bits, 8.0, 99); - - // Add noise - for c in &mut coeffs { - *c += ((*c as i32 * 7) % 5) as f32 - 2.0; // ±2 noise - } - - let extracted_bits = extract_from_coeffs(&coeffs, bits.len(), 99); - let decoded = decode_bits(&extracted_bits).unwrap(); - - assert_eq!(decoded, id, "Should survive moderate noise via redundancy"); - } -} diff --git a/docs/capglyph-core-api.md b/docs/capglyph-core-api.md index 4e3f195..50d3642 100644 --- a/docs/capglyph-core-api.md +++ b/docs/capglyph-core-api.md @@ -1,25 +1,42 @@ # capglyph-core Extraction API — Crate Boundary Sketch -**Date:** 2026-08-31 (updated 2026-08-31 CTX-0022, renamed 2026-08-31 CTX-0039 Sigil → CapGlyph) -**Task:** CTX-0019 → CTX-0022 +**Date:** 2026-08-31 (updated 2026-08-31 CTX-0022 → CTX-0040, renamed 2026-08-31 CTX-0039 Sigil → CapGlyph) +**Task:** CTX-0019 → CTX-0022 → CTX-0040 **Full spec:** [`capglyph-docs/research/media-credential/capglyph-core-api.md`](../../capglyph-docs/research/media-credential/capglyph-core-api.md) -**Status:** Implemented (CTX-0022) — `crates/capglyph-core` extracted (formerly `crates/sigil-core`), `capglyph` re-exports, no facade duplication +**Status:** CTX-0040 — standalone `CapGlyph/capglyph-core` repo (canonical Rust Core, v0.1.0) extracted from `capglyph-cli/crates/capglyph-core`; `capglyph-cli` now depends via `path = "../capglyph-core"` (isolated monorepo) **Issue:** [#13](https://github.com/CapGlyph/capglyph-cli/issues/13) (originally legacy Sigil repo #13, now CapGlyph/capglyph-cli, redirects) This file is the **capglyph-repo-local sketch** of the shared `capglyph-core` boundary (formerly `sigil-core`). The normative spec lives in `capglyph-docs` (formerly `sigil-docs`); this file exists so `cargo test` reviewers and CI can verify the migration plan without crossing repos. -## Workspace after CTX-0022 (v0.1.0, formerly v0.2.0 Sigil; reset 2026-08-31 CTX-0044) +## Workspace after CTX-0040 (v0.1.0, formerly v0.2.0 Sigil; reset 2026-08-31 CTX-0044) + +CTX-0022 embedded `crates/capglyph-core` inside `capglyph-cli` as a workspace member. CTX-0040 mechanically extracts it to a standalone canonical repo: + +``` +capglyph/ + capglyph-cli/ + Cargo.toml # [dependencies] capglyph-core = { path = "../capglyph-core" } (CTX-0040, was crates/capglyph-core) + src/ # binary crate (thin CLI wrappers → capglyph_core::Carrier via carrier.rs facade, alias sigil_core) + docs/capglyph-core-api.md # this file (formerly sigil-core-api.md) + capglyph-core/ # standalone lib (canonical Rust Core, CapGlyph/capglyph-core, v0.1.0) + # sigil-core-api §3.3 feature gates, no clap/glob/c2pa/trustmark + # signal/keying/spread_spectrum/geometry/framing/ecc/interleave/registration + carrier trait + Placement + # (formerly crates/sigil-core → crates/capglyph-core) + Cargo.toml + src/{carrier,ecc,framing,geometry,interleave,keying,placement,registration,signal,spread_spectrum}.rs + .github/workflows/ci.yml # standalone ci: fmt/clippy/test + wasm-check (no vectomancy) + capglyph-wasm/ # thin wasm bridge: capglyph-core wasm-safe subset (no secrets) — deferred to CTX-0023+ (formerly sigil-wasm) + vectomancy -> /vectomancy/vectomancy # symlink for raster/geometry path deps (capglyph-cli only) +``` + +Before CTX-0040: ``` capglyph-cli/ - Cargo.toml # [workspace] members = ["crates/capglyph-core"] - crates/capglyph-core/ # new lib: signal/keying/spread_spectrum/geometry/framing/ecc/interleave/registration + carrier trait + Placement - # (formerly crates/sigil-core) - src/ # binary crate (thin CLI wrappers → capglyph_core::Carrier via carrier.rs facade, alias sigil_core) - docs/capglyph-core-api.md # this file (formerly sigil-core-api.md) -capglyph-wasm/ # thin wasm bridge: capglyph-core wasm-safe subset (no secrets) — deferred to CTX-0023+ (formerly sigil-wasm) + Cargo.toml # [workspace] members = ["crates/capglyph-core"] (CTX-0022) + crates/capglyph-core/ # embedded lib (deleted in CTX-0040, now standalone at ../capglyph-core) ``` ## What moves to capglyph-core (formerly sigil-core) @@ -71,6 +88,10 @@ pub trait Register { fn align(&self, original: &ImageBuffer, Vec>, s ## Migration checklist (CTX-0022, no facade duplication) — DONE +## Extraction to standalone repo (CTX-0040) — DONE + +CTX-0022: + - [x] Create `crates/capglyph-core/Cargo.toml` (`v0.1.0`, formerly `v0.2.0` Sigil, no `clap`/`glob`/`tracing-subscriber`/`c2pa`/`trustmark`, deps: `image` png/jpeg, `ciborium`, `serde_bytes`, `sha2`, `hmac`, `tracing`) (formerly `crates/sigil-core`) - [x] Move `signal`/`keying`/`spread_spectrum`/`geometry`/`framing`/`ecc`/`interleave`/`registration`/`carrier` (trait+`Placement`+`AlphaCarrier`) verbatim into `crates/capglyph-core/src/` (formerly `crates/sigil-core`, `git mv` semantics, `cargo fmt` preserved) - [x] `carrier` split: `capglyph_core::carrier::Carrier` (alias `sigil_core`) + `capglyph_core::placement::Placement` live in core; `capglyph/src/carrier.rs` (legacy `sigil/src/carrier.rs`) keeps `DctCarrier`/`DwtCarrier` impls as facade with `to_cli_placement`/`to_core_placement` bridge (no duplication of trait) @@ -80,6 +101,16 @@ pub trait Register { fn align(&self, original: &ImageBuffer, Vec>, s - [x] Version: `capglyph-core v0.1.0` (formerly `sigil-core v0.2.0` → reset 2026-08-31 CTX-0044) pinned, `capglyph v0.1.0` (formerly `sigil v0.2.0`) depends via path; semver bump to `0.2.0` deferred until `dct`/`dwt` move - [x] CI gates: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo check --workspace --target wasm32-unknown-unknown`, `cargo tree --target wasm32-unknown-unknown -p capglyph-core` (legacy `-p sigil-core`) clean (85 nodes, no `clap`/`glob`/`tracing-subscriber`), `cargo tree -p capglyph --target wasm32` (legacy `-p sigil`) clean (195 nodes, `clap`/`glob` gated) +CTX-0040 (standalone canonical Rust Core): + +- [x] Copy `capglyph-cli/crates/capglyph-core` (v0.1.0) → `CapGlyph/capglyph-core` repo as primary crate (`Cargo.toml` + `src/*.rs`), keep `v0.1.0` unchanged, no `clap`/`glob`/`c2pa`/`trustmark` +- [x] Populate `capglyph-core` repo: `Cargo.toml`, `src/*.rs` (identical to embedded), `LICENSE` (Apache-2.0), `README.md` (canonical-core docs + isolated monorepo path), `.gitignore`, `.github/workflows/ci.yml` (standalone fmt/clippy/test + wasm-check, no vectomancy sibling needed) +- [x] Update `capglyph-cli/Cargo.toml`: remove `[workspace] members = ["crates/capglyph-core"]`, change `capglyph-core = { path = "crates/capglyph-core" }` → `path = "../capglyph-core"` (isolated layout `../capglyph-core` sibling, same as `../vectomancy`; CI will checkout `CapGlyph/capglyph-core` at `capglyph-core` sibling) +- [x] Delete `capglyph-cli/crates/capglyph-core` directory (mechanical extraction, no duplicate) — `git rm -r crates/capglyph-core` +- [x] Update `capglyph-cli/.github/workflows/ci.yml` + `release.yml`: add `CapGlyph/capglyph-core` checkout at `capglyph-core` sibling, include `capglyph-core/Cargo.lock` in cache keys, update `prepare()` in AUR PKGBUILD to symlink `capglyph-core` sibling +- [x] Verify: `cargo test` in both repos, `cargo check --lib --target wasm32-unknown-unknown` in both, `cargo tree --target wasm32-unknown-unknown` clean (no `clap`/`glob`/`trustmark`/`c2pa` in either) +- [x] Dependency decision (documented in this file + `capglyph-core/README.md` + `capglyph-cli/Cargo.toml` comment): local dev uses `path = "../capglyph-core"` (isolated monorepo sibling); CI checks out sibling; crates.io publish will switch to `capglyph-core = "0.1"` version dep + `[patch.crates-io]` dev override or git dep `CapGlyph/capglyph-core` tag fallback — not yet published, so path dep remains canonical for now + ## Verification gates ```bash