From fa4826b9020c9b55917a795a95d7487c53b7e025 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Thu, 17 Sep 2026 14:19:40 -0600 Subject: [PATCH 1/5] Carry BLAKE3 in-tree and drop every runtime dependency The crate's entire runtime graph - ten crates, a build script and a C compiler invocation - existed to serve four lines in emojihash that ask BLAKE3 for nine bytes of XOF output from a 32-byte public key. For a library whose point is being a small, auditable primitive you embed next to key material, that graph was the main defect. src/blake3.rs implements the part actually needed: unkeyed, one-shot, extendable output. Dropping the incremental API is what makes it small - upstream carries a chunk state, a 54-deep chaining-value stack and block bookkeeping because update() may be handed any prefix, whereas a caller that supplies the whole slice lets all of that collapse into one recursive descent over the subtree structure. Full input range is kept: capping at one chunk would have saved about twenty lines and eventually handed someone a wrong answer for a 2 KB input. Correctness is checked three ways rather than assumed. The frozen vectors under vectors/ pass byte-for-byte unchanged, which is the proof that matters. tests/blake3_equivalence.rs keeps the upstream crate as a dev-only oracle and diffs against it across every block, chunk and subtree boundary. Published known-answer vectors are pinned in unit tests so agreement does not rest solely on the oracle - if this code and the oracle were wrong the same way, only a fixed external answer would notice. serde_json goes too, replaced by a strict reader for the generated vector shape; it rejects anything outside that shape, including the key set, so a malformed vector fails loudly instead of leaving an assertion silently unexercised. With nothing left to link, the crate becomes no_std + alloc and builds for thumbv7em-none-eabihf as well as wasm32. --- Cargo.lock | 96 --------- Cargo.toml | 9 +- README.md | 15 +- src/blake3.rs | 388 ++++++++++++++++++++++++++++++++++++ src/emojihash.rs | 9 +- src/lib.rs | 13 ++ src/randomart.rs | 4 + tests/blake3_equivalence.rs | 106 ++++++++++ tests/conformance.rs | 313 +++++++++++++++++++++++++---- 9 files changed, 810 insertions(+), 143 deletions(-) create mode 100644 src/blake3.rs create mode 100644 tests/blake3_equivalence.rs diff --git a/Cargo.lock b/Cargo.lock index 0f2a748..b90455c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,117 +65,21 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - [[package]] name = "visual-hashing" version = "0.1.3" dependencies = [ "blake3", - "serde_json", ] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 8b9e585..180325e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,9 +28,8 @@ include = [ "vectors/randomart/*.json", ] -[dependencies] -# the only dependency: emojihash slices a BLAKE3-XOF digest into 6-bit symbols -blake3 = "1" - [dev-dependencies] -serde_json = "1" +# Not a dependency of the library: the upstream implementation is kept as an +# oracle so `tests/blake3_equivalence.rs` can prove that `src/blake3.rs` agrees +# with it byte for byte. Dev-dependencies are never built by consumers. +blake3 = "1" diff --git a/README.md b/README.md index 8817c2d..223276b 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,19 @@ The 64-emoji alphabet and the randomart character ramp are a **wire contract**: versions is worse than useless. Pre-`1.0` the alphabet is considered stable but reserves the right to fix outright mistakes. -The only dependency is [`blake3`](https://crates.io/crates/blake3). No `unsafe`, no I/O, -`wasm32`-friendly. +**No dependencies.** Not a short list — none. The crate carries its own one-shot +BLAKE3 (`src/blake3.rs`), so there is no build script, no C compiler and nothing +transitive to audit in a crate you are embedding next to key material. It is +`#![forbid(unsafe_code)]`, `no_std` + `alloc`, does no I/O, and builds for +`wasm32` and bare-metal targets unchanged. + +That implementation is not taken on trust: the upstream +[`blake3`](https://crates.io/crates/blake3) crate is kept as a *dev*-dependency +purely as an oracle, and `tests/blake3_equivalence.rs` diffs the two across every +block, chunk and subtree boundary on each CI run. Published known-answer vectors +are pinned separately, so agreement is checked against a fixed external answer +too. Being portable rather than SIMD, it is built for auditability over +throughput — the right trade for fingerprinting keys and checksums. ## Provenance diff --git a/src/blake3.rs b/src/blake3.rs new file mode 100644 index 0000000..a367c5c --- /dev/null +++ b/src/blake3.rs @@ -0,0 +1,388 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! A one-shot BLAKE3 extendable-output function. +//! +//! This crate needs exactly one thing from BLAKE3: the XOF stream of a byte +//! slice it already holds in full — in practice nine bytes of it, derived from +//! a 32-byte public key. That is a small enough slice of the function to carry +//! in-tree, and carrying it keeps `visual-hashing` at zero runtime +//! dependencies. +//! +//! What this is: unkeyed hashing of a whole input, with extendable output, +//! portable and allocation-free. What it deliberately is not: the keyed and +//! `derive_key` modes, an incremental `update` API, SIMD, or multithreading. +//! +//! Dropping the incremental API is what makes it small. Upstream carries a +//! chunk state, a 54-deep chaining-value stack and block bookkeeping because +//! `update` may be handed any prefix of the input; a caller that supplies the +//! whole slice at once lets all of that collapse into one recursive descent +//! over the subtree structure. +//! +//! Correctness is checked, not assumed. The unit tests below pin published +//! known-answer vectors, and `tests/blake3_equivalence.rs` diffs this +//! implementation against the upstream `blake3` crate across every block, +//! chunk and subtree boundary. + +const BLOCK_LEN: usize = 64; +const CHUNK_LEN: usize = 1024; + +const CHUNK_START: u32 = 1 << 0; +const CHUNK_END: u32 = 1 << 1; +const PARENT: u32 = 1 << 2; +const ROOT: u32 = 1 << 3; + +const IV: [u32; 8] = [ + 0x6A09_E667, + 0xBB67_AE85, + 0x3C6E_F372, + 0xA54F_F53A, + 0x510E_527F, + 0x9B05_688C, + 0x1F83_D9AB, + 0x5BE0_CD19, +]; + +const MSG_PERMUTATION: [usize; 16] = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8]; + +/// The quarter-round mixing function. +#[allow(clippy::too_many_arguments)] +fn g(state: &mut [u32; 16], a: usize, b: usize, c: usize, d: usize, mx: u32, my: u32) { + state[a] = state[a].wrapping_add(state[b]).wrapping_add(mx); + state[d] = (state[d] ^ state[a]).rotate_right(16); + state[c] = state[c].wrapping_add(state[d]); + state[b] = (state[b] ^ state[c]).rotate_right(12); + state[a] = state[a].wrapping_add(state[b]).wrapping_add(my); + state[d] = (state[d] ^ state[a]).rotate_right(8); + state[c] = state[c].wrapping_add(state[d]); + state[b] = (state[b] ^ state[c]).rotate_right(7); +} + +/// One round: mix the four columns, then the four diagonals. +fn round(state: &mut [u32; 16], m: &[u32; 16]) { + g(state, 0, 4, 8, 12, m[0], m[1]); + g(state, 1, 5, 9, 13, m[2], m[3]); + g(state, 2, 6, 10, 14, m[4], m[5]); + g(state, 3, 7, 11, 15, m[6], m[7]); + g(state, 0, 5, 10, 15, m[8], m[9]); + g(state, 1, 6, 11, 12, m[10], m[11]); + g(state, 2, 7, 8, 13, m[12], m[13]); + g(state, 3, 4, 9, 14, m[14], m[15]); +} + +fn permute(m: &mut [u32; 16]) { + let mut permuted = [0u32; 16]; + for (dst, &src) in permuted.iter_mut().zip(MSG_PERMUTATION.iter()) { + *dst = m[src]; + } + *m = permuted; +} + +/// The compression function: seven rounds over a 16-word state, then feed-forward. +fn compress( + cv: &[u32; 8], + block: &[u32; 16], + counter: u64, + block_len: u32, + flags: u32, +) -> [u32; 16] { + let mut state = [ + cv[0], + cv[1], + cv[2], + cv[3], + cv[4], + cv[5], + cv[6], + cv[7], + IV[0], + IV[1], + IV[2], + IV[3], + counter as u32, + (counter >> 32) as u32, + block_len, + flags, + ]; + let mut block = *block; + + round(&mut state, &block); + for _ in 0..6 { + permute(&mut block); + round(&mut state, &block); + } + + for i in 0..8 { + state[i] ^= state[i + 8]; + state[i + 8] ^= cv[i]; + } + state +} + +fn words_from_le_bytes(block: &[u8; BLOCK_LEN]) -> [u32; 16] { + let mut words = [0u32; 16]; + for (word, bytes) in words.iter_mut().zip(block.chunks_exact(4)) { + *word = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + } + words +} + +/// The final compression of a chunk or parent node, held un-run. +/// +/// A node's last compression has two possible readings — a chaining value fed +/// to its parent, or the root of the whole tree — and which one applies is not +/// known until the caller decides. Keeping the inputs lets both be taken. +struct Output { + cv: [u32; 8], + block: [u32; 16], + counter: u64, + block_len: u32, + flags: u32, +} + +impl Output { + fn chaining_value(&self) -> [u32; 8] { + let state = compress( + &self.cv, + &self.block, + self.counter, + self.block_len, + self.flags, + ); + let mut cv = [0u32; 8]; + cv.copy_from_slice(&state[..8]); + cv + } + + /// Fill `out` with the root extendable output. + /// + /// Each compression yields one 64-byte output block; the block index takes + /// the place of the node counter, which is how BLAKE3 extends past 64 + /// bytes. + fn root_bytes(&self, out: &mut [u8]) { + for (index, slot) in out.chunks_mut(BLOCK_LEN).enumerate() { + let words = compress( + &self.cv, + &self.block, + index as u64, + self.block_len, + self.flags | ROOT, + ); + for (dst, word) in slot.chunks_mut(4).zip(words.iter()) { + let bytes = word.to_le_bytes(); + dst.copy_from_slice(&bytes[..dst.len()]); + } + } + } +} + +/// Compress one chunk (at most [`CHUNK_LEN`] bytes) into its final output. +fn chunk_output(chunk: &[u8], chunk_counter: u64) -> Output { + debug_assert!(chunk.len() <= CHUNK_LEN); + + let mut cv = IV; + let mut compressed = 0usize; + let mut rest = chunk; + + // Every block but the last is absorbed here; the last is left to `Output` + // because only the caller knows whether this chunk is the root. + while rest.len() > BLOCK_LEN { + let (block, tail) = rest.split_at(BLOCK_LEN); + let mut bytes = [0u8; BLOCK_LEN]; + bytes.copy_from_slice(block); + let flags = if compressed == 0 { CHUNK_START } else { 0 }; + let state = compress( + &cv, + &words_from_le_bytes(&bytes), + chunk_counter, + BLOCK_LEN as u32, + flags, + ); + cv.copy_from_slice(&state[..8]); + compressed += 1; + rest = tail; + } + + let mut bytes = [0u8; BLOCK_LEN]; + bytes[..rest.len()].copy_from_slice(rest); + Output { + cv, + block: words_from_le_bytes(&bytes), + counter: chunk_counter, + block_len: rest.len() as u32, + flags: CHUNK_END | if compressed == 0 { CHUNK_START } else { 0 }, + } +} + +fn parent_output(left: &[u32; 8], right: &[u32; 8]) -> Output { + let mut block = [0u32; 16]; + block[..8].copy_from_slice(left); + block[8..].copy_from_slice(right); + Output { + cv: IV, + block, + counter: 0, + block_len: BLOCK_LEN as u32, + flags: PARENT, + } +} + +/// The number of bytes belonging to the left subtree of a multi-chunk input. +/// +/// BLAKE3's tree is left-full: the left side takes the largest power-of-two +/// number of chunks that still leaves at least one byte on the right. +fn left_len(content_len: usize) -> usize { + let full_chunks = (content_len - 1) / CHUNK_LEN; + (((full_chunks / 2) + 1).next_power_of_two()) * CHUNK_LEN +} + +/// Reduce `input` to the output of the subtree rooted at `chunk_counter`. +/// +/// Depth is `log2(len / CHUNK_LEN)` — at most 54 frames for a 2^64-byte input. +fn subtree_output(input: &[u8], chunk_counter: u64) -> Output { + if input.len() <= CHUNK_LEN { + return chunk_output(input, chunk_counter); + } + let split = left_len(input.len()); + let (left, right) = input.split_at(split); + let left_cv = subtree_output(left, chunk_counter).chaining_value(); + let right_cv = + subtree_output(right, chunk_counter + (split / CHUNK_LEN) as u64).chaining_value(); + parent_output(&left_cv, &right_cv) +} + +/// Fill `out` with the unkeyed BLAKE3 extendable output of `data`. +pub(crate) fn hash_xof(data: &[u8], out: &mut [u8]) { + subtree_output(data, 0).root_bytes(out); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The input pattern used by the published BLAKE3 test vectors: the bytes + /// 0, 1, 2, … 250, repeating. + fn pattern(len: usize) -> alloc::vec::Vec { + (0..len).map(|i| (i % 251) as u8).collect() + } + + fn hex(bytes: &[u8]) -> alloc::string::String { + use core::fmt::Write as _; + let mut s = alloc::string::String::new(); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s + } + + /// Known answers from the BLAKE3 reference test vectors, chosen to cross + /// every structural boundary: empty, sub-block, exact block, sub-chunk, + /// exact chunk, and the first three subtree splits. + /// + /// These are independent of the `blake3` dev-dependency on purpose — if + /// both this code and the oracle were wrong in the same way, only a fixed + /// external answer would notice. + #[test] + fn published_known_answers() { + const CASES: &[(usize, &str)] = &[ + ( + 0, + "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262", + ), + ( + 1, + "2d3adedff11b61f14c886e35afa036736dcd87a74d27b5c1510225d0f592e213", + ), + ( + 63, + "e9bc37a594daad83be9470df7f7b3798297c3d834ce80ba85d6e207627b7db7b", + ), + ( + 64, + "4eed7141ea4a5cd4b788606bd23f46e212af9cacebacdc7d1f4c6dc7f2511b98", + ), + ( + 1023, + "10108970eeda3eb932baac1428c7a2163b0e924c9a9e25b35bba72b28f70bd11", + ), + ( + 1024, + "42214739f095a406f3fc83deb889744ac00df831c10daa55189b5d121c855af7", + ), + ( + 1025, + "d00278ae47eb27b34faecf67b4fe263f82d5412916c1ffd97c8cb7fb814b8444", + ), + ( + 2048, + "e776b6028c7cd22a4d0ba182a8bf62205d2ef576467e838ed6f2529b85fba24a", + ), + ( + 2049, + "5f4d72f40d7a5f82b15ca2b2e44b1de3c2ef86c426c95c1af0b6879522563030", + ), + ( + 3072, + "b98cb0ff3623be03326b373de6b9095218513e64f1ee2edd2525c7ad1e5cffd2", + ), + ]; + + for &(len, want) in CASES { + let mut got = [0u8; 32]; + hash_xof(&pattern(len), &mut got); + assert_eq!(hex(&got), want, "input_len {len}"); + } + } + + /// 131 bytes of output spans three root blocks, so this pins the output + /// counter as well as the digest — a 64-byte-only test would not. + #[test] + fn published_extended_output() { + const CASES: &[(usize, &str)] = &[ + ( + 0, + "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262\ + e00f03e7b69af26b7faaf09fcd333050338ddfe085b8cc869ca98b206c08243a\ + 26f5487789e8f660afe6c99ef9e0c52b92e7393024a80459cf91f476f9ffdbda\ + 7001c22e159b402631f277ca96f2defdf1078282314e763699a31c5363165421\ + cce14d", + ), + ( + 1025, + "d00278ae47eb27b34faecf67b4fe263f82d5412916c1ffd97c8cb7fb814b8444\ + f4c4a22b4b399155358a994e52bf255de60035742ec71bd08ac275a1b51cc6bf\ + e332b0ef84b409108cda080e6269ed4b3e2c3f7d722aa4cdc98d16deb554e562\ + 7be8f955c98e1d5f9565a9194cad0c4285f93700062d9595adb992ae68ff1280\ + 0ab67a", + ), + ]; + + for &(len, want) in CASES { + let mut got = [0u8; 131]; + hash_xof(&pattern(len), &mut got); + assert_eq!(hex(&got), want, "input_len {len}"); + } + } + + #[test] + fn left_len_is_left_full() { + assert_eq!(left_len(CHUNK_LEN + 1), CHUNK_LEN); + assert_eq!(left_len(2 * CHUNK_LEN), CHUNK_LEN); + assert_eq!(left_len(2 * CHUNK_LEN + 1), 2 * CHUNK_LEN); + assert_eq!(left_len(4 * CHUNK_LEN), 2 * CHUNK_LEN); + assert_eq!(left_len(4 * CHUNK_LEN + 1), 4 * CHUNK_LEN); + } + + /// The output length must not change the bytes that shorter reads saw. + #[test] + fn output_is_a_prefix_stream() { + let data = pattern(5000); + let mut long = [0u8; 200]; + hash_xof(&data, &mut long); + for n in [1usize, 7, 31, 32, 63, 64, 65, 127, 128, 129, 200] { + let mut short = alloc::vec![0u8; n]; + hash_xof(&data, &mut short); + assert_eq!(short[..], long[..n], "output_len {n}"); + } + } +} diff --git a/src/emojihash.rs b/src/emojihash.rs index fd38ca0..bf89e99 100644 --- a/src/emojihash.rs +++ b/src/emojihash.rs @@ -7,6 +7,10 @@ //! alphabet that favours common animals and then familiar foods over abstract, //! confusable symbols — a fingerprint only helps if a human can read it back. +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + /// The 64-entry emoji alphabet (a 6-bit digit set). pub const EMOJI: [&str; 64] = [ "🐵", "🐶", "🐺", "🦊", "🐱", "🦁", "🐯", "🐴", "🦄", "🦓", "🦌", "🐮", "🐷", "🐗", "🐭", "🐹", @@ -91,10 +95,7 @@ pub fn emoji_indices(data: &[u8], length: usize) -> Vec { let wanted = length.max(1); let nbytes = (wanted * 6).div_ceil(8); let mut digest = vec![0u8; nbytes]; - blake3::Hasher::new() - .update(data) - .finalize_xof() - .fill(&mut digest); + crate::blake3::hash_xof(data, &mut digest); let mut out = Vec::with_capacity(wanted); let mut acc: u64 = 0; diff --git a/src/lib.rs b/src/lib.rs index c70f0b8..af9e5a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,20 @@ //! //! Both renderings are byte-for-byte deterministic and gated by a frozen //! conformance corpus, so independent implementations agree exactly. +//! +//! The crate has **no runtime dependencies** and needs only `alloc`, so it +//! works anywhere you can allocate a `String` — including `no_std` targets and +//! wasm. + +#![no_std] +#![forbid(unsafe_code)] + +extern crate alloc; + +#[cfg(test)] +extern crate std; +mod blake3; mod emojihash; mod randomart; diff --git a/src/randomart.rs b/src/randomart.rs index 3b9e317..389ab0b 100644 --- a/src/randomart.rs +++ b/src/randomart.rs @@ -8,6 +8,10 @@ //! The counts are rendered through a character ramp; the start and end squares //! are marked `S` and `E`. This is a byte-for-byte port of the Python reference. +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + const WIDTH: usize = 17; const HEIGHT: usize = 9; diff --git a/tests/blake3_equivalence.rs b/tests/blake3_equivalence.rs new file mode 100644 index 0000000..efe291c --- /dev/null +++ b/tests/blake3_equivalence.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Proof that the in-tree BLAKE3 agrees with the upstream implementation. +//! +//! `visual-hashing` carries its own one-shot BLAKE3 so that it ships with no +//! runtime dependencies (see `src/blake3.rs`). The `blake3` crate is kept as a +//! dev-dependency purely as the oracle for this file — it is never built by +//! consumers of the library. +//! +//! The comparison runs through the public API rather than the private hash, so +//! it covers the 6-bit slicing in `emoji_indices` as well as the digest. + +use visual_hashing::emoji_indices; + +/// SplitMix64, so the sweep is wide but exactly reproducible on every run and +/// every platform — a failing case can be re-examined without hunting a seed. +struct SplitMix64(u64); + +impl SplitMix64 { + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + fn bytes(&mut self, len: usize) -> Vec { + let mut out = Vec::with_capacity(len); + while out.len() < len { + out.extend_from_slice(&self.next_u64().to_le_bytes()); + } + out.truncate(len); + out + } +} + +/// What `emoji_indices` must produce, computed from the upstream digest. +fn oracle_indices(data: &[u8], length: usize) -> Vec { + let wanted = length.max(1); + let mut digest = vec![0u8; (wanted * 6).div_ceil(8)]; + blake3::Hasher::new() + .update(data) + .finalize_xof() + .fill(&mut digest); + + let mut out = Vec::with_capacity(wanted); + let mut acc: u64 = 0; + let mut bits: u32 = 0; + for byte in digest { + acc = (acc << 8) | u64::from(byte); + bits += 8; + while bits >= 6 && out.len() < wanted { + bits -= 6; + out.push(((acc >> bits) & 0x3f) as usize); + } + acc &= (1u64 << bits) - 1; + } + out +} + +/// Input lengths at every structural boundary: block (64), chunk (1024), and +/// the subtree splits where the recursive descent in `src/blake3.rs` divides. +const INPUT_LENS: &[usize] = &[ + 0, 1, 2, 3, 31, 32, 63, 64, 65, 127, 128, 129, 1023, 1024, 1025, 2047, 2048, 2049, 3072, 4095, + 4096, 4097, 6144, 8192, 8193, 16384, 16385, 31337, +]; + +/// Output lengths chosen so the digest crosses the 64-byte root-output block: +/// a `length` of 85 needs exactly 64 bytes, 86 needs 65, 170 needs 128. +const EMOJI_LENS: &[usize] = &[1, 2, 3, 11, 41, 42, 43, 84, 85, 86, 87, 169, 170, 171, 256]; + +#[test] +fn matches_upstream_across_every_boundary() { + let mut rng = SplitMix64(0x5653_4841_5348_0001); + + for &input_len in INPUT_LENS { + let data = rng.bytes(input_len); + for &emoji_len in EMOJI_LENS { + assert_eq!( + emoji_indices(&data, emoji_len), + oracle_indices(&data, emoji_len), + "input_len {input_len}, emoji_len {emoji_len}" + ); + } + } +} + +/// Structured inputs can expose carry and padding mistakes that random bytes +/// mask, so repeat the sweep over degenerate patterns. +#[test] +fn matches_upstream_for_degenerate_inputs() { + for &input_len in INPUT_LENS { + for fill in [0x00u8, 0xff, 0x55] { + let data = vec![fill; input_len]; + for &emoji_len in [1usize, 11, 85, 86, 171].iter() { + assert_eq!( + emoji_indices(&data, emoji_len), + oracle_indices(&data, emoji_len), + "fill {fill:#04x}, input_len {input_len}, emoji_len {emoji_len}" + ); + } + } + } +} diff --git a/tests/conformance.rs b/tests/conformance.rs index 779eec6..76eb043 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -3,7 +3,15 @@ //! Conformance against the frozen vectors (the Python reference is the oracle). //! The standalone repository keeps the reference vectors under `vectors/`. +//! +//! The vectors are read by the small strict parser below rather than by a JSON +//! crate. They have a fixed, generated shape — flat objects of strings, +//! non-negative integers and integer arrays — so the parser stays short, and +//! every departure from that shape is a hard error rather than a shrug. A +//! lenient reader is the real hazard here: a vector that silently parses to +//! the wrong thing would weaken exactly the check this file exists to make. +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use visual_hashing::{emoji_indices, emojihash, emojihash_labels, randomart}; @@ -23,56 +31,289 @@ fn unhex(s: &str) -> Vec { .collect() } +// --- the strict vector reader ------------------------------------------------- + +#[derive(Debug)] +enum Value { + Str(String), + Uint(u64), + Uints(Vec), +} + +impl Value { + fn str(&self) -> &str { + match self { + Value::Str(s) => s, + other => panic!("expected a string, found {other:?}"), + } + } + + fn uint(&self) -> u64 { + match self { + Value::Uint(n) => *n, + other => panic!("expected an integer, found {other:?}"), + } + } + + fn uints(&self) -> &[u64] { + match self { + Value::Uints(v) => v, + other => panic!("expected an integer array, found {other:?}"), + } + } +} + +struct Reader<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Reader { bytes, pos: 0 } + } + + fn fail(&self, what: &str) -> ! { + panic!("malformed vector at byte {}: {what}", self.pos); + } + + fn skip_ws(&mut self) { + while matches!(self.bytes.get(self.pos), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.pos += 1; + } + } + + fn eat(&mut self, byte: u8) { + self.skip_ws(); + if self.bytes.get(self.pos) != Some(&byte) { + self.fail(&format!("expected {:?}", byte as char)); + } + self.pos += 1; + } + + fn peek(&mut self) -> u8 { + self.skip_ws(); + match self.bytes.get(self.pos) { + Some(&b) => b, + None => self.fail("unexpected end of input"), + } + } + + /// Multi-byte UTF-8 needs no special handling: its continuation bytes are + /// never `"` or `\`, so accumulating raw bytes and validating once at the + /// close quote is both simpler and stricter than decoding as we go. + fn string(&mut self) -> String { + self.eat(b'"'); + let mut out: Vec = Vec::new(); + loop { + let byte = match self.bytes.get(self.pos) { + Some(&b) => b, + None => self.fail("unterminated string"), + }; + self.pos += 1; + match byte { + b'"' => { + return String::from_utf8(out) + .unwrap_or_else(|_| panic!("invalid UTF-8 in vector string")) + } + b'\\' => { + let escape = match self.bytes.get(self.pos) { + Some(&b) => b, + None => self.fail("unterminated escape"), + }; + self.pos += 1; + out.push(match escape { + b'"' => b'"', + b'\\' => b'\\', + b'/' => b'/', + b'b' => 0x08, + b'f' => 0x0c, + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + // The generators write UTF-8 directly (`ensure_ascii=False`), + // so a `\u` escape means the corpus format changed and this + // reader must be revisited rather than guessed at. + _ => self.fail("unsupported escape; extend the reader"), + }); + } + _ => out.push(byte), + } + } + } + + fn uint(&mut self) -> u64 { + self.skip_ws(); + let start = self.pos; + while matches!(self.bytes.get(self.pos), Some(b'0'..=b'9')) { + self.pos += 1; + } + if start == self.pos { + self.fail("expected a non-negative integer"); + } + std::str::from_utf8(&self.bytes[start..self.pos]) + .expect("ascii digits") + .parse() + .unwrap_or_else(|_| panic!("integer out of range in vector")) + } + + fn value(&mut self) -> Value { + match self.peek() { + b'"' => Value::Str(self.string()), + b'[' => { + self.eat(b'['); + let mut items = Vec::new(); + if self.peek() == b']' { + self.eat(b']'); + return Value::Uints(items); + } + loop { + items.push(self.uint()); + match self.peek() { + b',' => self.eat(b','), + b']' => { + self.eat(b']'); + return Value::Uints(items); + } + _ => self.fail("expected ',' or ']'"), + } + } + } + b'0'..=b'9' => Value::Uint(self.uint()), + _ => self.fail("vectors hold only strings, integers and integer arrays"), + } + } + + fn object(&mut self) -> BTreeMap { + let mut fields = BTreeMap::new(); + self.eat(b'{'); + if self.peek() == b'}' { + self.eat(b'}'); + return fields; + } + loop { + let key = self.string(); + self.eat(b':'); + let value = self.value(); + if fields.insert(key.clone(), value).is_some() { + panic!("duplicate key {key:?} in vector"); + } + match self.peek() { + b',' => self.eat(b','), + b'}' => { + self.eat(b'}'); + return fields; + } + _ => self.fail("expected ',' or '}'"), + } + } + } +} + +/// Read one vector, insisting on exactly `expected` keys. +/// +/// The exact-key-set assertion is what makes a hand-written reader safe to +/// rely on: a renamed, dropped or added field fails loudly instead of leaving +/// an assertion silently unexercised. +fn read_vector(path: &Path, expected: &[&str]) -> BTreeMap { + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("reading {path:?}: {e}")); + let mut reader = Reader::new(&bytes); + let fields = reader.object(); + reader.skip_ws(); + assert_eq!(reader.pos, bytes.len(), "trailing content in {path:?}"); + + let found: Vec<&str> = fields.keys().map(String::as_str).collect(); + let mut want: Vec<&str> = expected.to_vec(); + want.sort_unstable(); + assert_eq!(found, want, "unexpected field set in {path:?}"); + + fields +} + +fn vector_files(kind: &str) -> Vec { + let mut paths: Vec = std::fs::read_dir(vectors_dir(kind)) + .unwrap_or_else(|e| panic!("vectors/{kind} must exist: {e}")) + .map(|entry| entry.expect("readable dir entry").path()) + .filter(|path| path.extension().and_then(|e| e.to_str()) == Some("json")) + .collect(); + paths.sort(); + paths +} + +// --- the conformance checks --------------------------------------------------- + #[test] fn emojihash_vectors() { - let dir = vectors_dir("emojihash"); - let mut count = 0; - for entry in std::fs::read_dir(&dir).expect("vectors/emojihash must exist") { - let path = entry.unwrap().path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - let json: serde_json::Value = - serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - let data = unhex(json["data"].as_str().unwrap()); - let length = json["length"].as_u64().unwrap() as usize; - let want_indices: Vec = json["indices"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_u64().unwrap() as usize) - .collect(); + let paths = vector_files("emojihash"); + for path in &paths { + let v = read_vector(path, &["data", "emoji", "indices", "labels", "length"]); + let data = unhex(v["data"].str()); + let length = v["length"].uint() as usize; + let want_indices: Vec = v["indices"].uints().iter().map(|&i| i as usize).collect(); assert_eq!(emoji_indices(&data, length), want_indices, "{path:?}"); - assert_eq!(emojihash(&data, length), json["emoji"].as_str().unwrap()); + assert_eq!(emojihash(&data, length), v["emoji"].str(), "{path:?}"); assert_eq!( emojihash_labels(&data, length), - json["labels"].as_str().unwrap() + v["labels"].str(), + "{path:?}" ); - count += 1; } - assert!(count >= 4, "expected emojihash vectors, found {count}"); + assert!( + paths.len() >= 4, + "expected emojihash vectors, found {}", + paths.len() + ); } #[test] fn randomart_vectors() { - let dir = vectors_dir("randomart"); - let mut count = 0; - for entry in std::fs::read_dir(&dir).expect("vectors/randomart must exist") { - let path = entry.unwrap().path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - let json: serde_json::Value = - serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - let data = unhex(json["data"].as_str().unwrap()); - let label = json["label"].as_str().unwrap(); + let paths = vector_files("randomart"); + for path in &paths { + let v = read_vector(path, &["art", "data", "label"]); + let data = unhex(v["data"].str()); assert_eq!( - randomart(&data, label), - json["art"].as_str().unwrap(), + randomart(&data, v["label"].str()), + v["art"].str(), "{path:?}" ); - count += 1; } - assert!(count >= 5, "expected randomart vectors, found {count}"); + assert!( + paths.len() >= 5, + "expected randomart vectors, found {}", + paths.len() + ); +} + +#[test] +fn reader_rejects_malformed_vectors() { + let cases: &[(&str, &str)] = &[ + ("{\"a\": 1,}", "trailing comma"), + ("{\"a\": 1} junk", "trailing content"), + ("{\"a\": -1}", "negative number"), + ("{\"a\": 1.5}", "float"), + ("{\"a\": true}", "boolean"), + ("{\"a\": [1, \"x\"]}", "mixed array"), + ("{\"a\": 1, \"a\": 2}", "duplicate key"), + ("{\"a\": \"\\u0041\"}", "unicode escape"), + ("{\"a\": \"unterminated}", "unterminated string"), + ]; + + // These cases panic by design; keep the expected noise out of the output. + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + + for (input, what) in cases { + let result = std::panic::catch_unwind(|| { + let bytes = input.as_bytes(); + let mut reader = Reader::new(bytes); + let fields = reader.object(); + reader.skip_ws(); + assert_eq!(reader.pos, bytes.len()); + fields + }); + assert!(result.is_err(), "reader accepted {what}: {input}"); + } + + std::panic::set_hook(hook); } From 30c558aef1e56bdc1c369fb96be7757bf1e9676a Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Thu, 17 Sep 2026 14:22:31 -0600 Subject: [PATCH 2/5] Enforce the licensing and dependency claims in CI The dependency audit for this repository came out clean, but a clean audit is a fact about one afternoon, not a property of the repository. This turns each claim into a gate. scripts/check-licenses.py checks that every tracked file declares the expected SPDX expression - by header, or through a REUSE.toml annotation for formats that cannot carry a comment - that REUSE.toml agrees with those headers, that the root LICENSE-* files match their LICENSES/*.txt counterparts, and that the library still has no runtime or build dependencies. It found one gap on its first run: REUSE.toml carried no header of its own. It is a first-party script rather than a third-party action on purpose. Every action in this workflow is SHA-pinned, and widening the supply chain to run what amounts to a grep would be a poor trade. deny.toml covers what the script cannot: advisories, sources, duplicate versions, and the licences of the dev-only graph. The allow-list is short and annotated with which crate needs each entry, so adding one requires looking at the new dependency rather than waving it through. CI also now builds for thumbv7em-none-eabihf, because a no_std claim is only worth what a target with no std to fall back on says about it. --- .github/workflows/ci.yml | 35 +++++++- CONTRIBUTING.md | 10 +++ REUSE.toml | 3 + deny.toml | 41 +++++++++ scripts/check-licenses.py | 172 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 deny.toml create mode 100644 scripts/check-licenses.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cc400d..0cc2361 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: clippy, rustfmt - targets: wasm32-unknown-unknown + targets: wasm32-unknown-unknown, thumbv7em-none-eabihf - name: Cache Cargo dependencies uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 @@ -56,6 +56,39 @@ jobs: if: runner.os == 'Linux' run: cargo build --lib --target wasm32-unknown-unknown + # Proves the `no_std` claim on a target that has no `std` to fall back on. + - name: Bare-metal library build + if: runner.os == 'Linux' + run: cargo build --lib --target thumbv7em-none-eabihf + - name: Package dry-run if: runner.os == 'Linux' run: cargo publish --locked --dry-run + + licensing: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Cache Cargo dependencies + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + + # SPDX headers, licence-text digests, and the no-runtime-dependencies + # claim. Kept as a first-party script rather than a third-party action: + # every action here is SHA-pinned, and adding one to run a grep would + # widen the supply chain for no gain. + - name: License and dependency claims + run: python3 scripts/check-licenses.py + + - name: Install cargo-deny + uses: taiki-e/install-action@76c2e6406e52637deed7160d77bded76bd83e06e # v2.87.14 + with: + tool: cargo-deny + + - name: Dependency licences, advisories and sources + run: cargo deny check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13f8a6c..f0ec671 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,10 +19,20 @@ cargo clippy --all-targets -- -D warnings cargo test --locked RUSTDOCFLAGS="-D warnings" cargo doc --no-deps cargo build --lib --target wasm32-unknown-unknown +cargo build --lib --target thumbv7em-none-eabihf +cargo deny check +python3 scripts/check-licenses.py cargo package --locked --list cargo publish --locked --dry-run ``` +The library must keep **zero runtime dependencies** — that claim is in the +README and is the reason the dependency licence surface stays small, so +`scripts/check-licenses.py` fails the build if one appears. `src/blake3.rs` +exists for the same reason; if you change it, `tests/blake3_equivalence.rs` +must still agree with the upstream `blake3` crate, and the frozen vectors must +still pass byte-for-byte. + The vector scripts live under `python/scripts/`. Regenerate vectors only when the public rendering contract intentionally changes, then review the JSON diff. diff --git a/REUSE.toml b/REUSE.toml index f53a0ae..09a748e 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. +# SPDX-License-Identifier: MIT OR Apache-2.0 + version = 1 SPDX-PackageName = "visual-hashing" SPDX-PackageSupplier = "Blackcat Informatics® Inc. " diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..62e0411 --- /dev/null +++ b/deny.toml @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Supply-chain and licence gate. The library itself has no runtime +# dependencies (scripts/check-licenses.py enforces that separately); everything +# audited here reaches the tree through dev-dependencies, which consumers never +# build. They are still checked, because they are what CI executes. + +[graph] +all-features = true + +[licenses] +# Every licence below was read from the dependency manifests, not assumed. The +# list is deliberately short: an addition should require someone to look at the +# new crate and decide, rather than pass silently. +allow = [ + "MIT", # most of the tree + "Apache-2.0", # most of the tree + "MulanPSL-2.0", # this crate's third disjunct + "Apache-2.0 WITH LLVM-exception", # blake3 + "CC0-1.0", # blake3, constant_time_eq + "BSD-2-Clause", # arrayref +] +confidence-threshold = 0.93 + +# A disjunct that never gets selected is not a problem: an `A OR B` dependency +# matches whichever entry comes first, and MulanPSL-2.0 is offered by this +# crate rather than required of anything in the graph. +unused-allowed-license = "allow" + +[bans] +multiple-versions = "deny" +wildcards = "deny" + +[advisories] +yanked = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/scripts/check-licenses.py b/scripts/check-licenses.py new file mode 100644 index 0000000..c882ba0 --- /dev/null +++ b/scripts/check-licenses.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Enforce this repository's licensing claims instead of asserting them. + +Four things are checked, each of which has quietly drifted in real projects: + +1. Every tracked file declares the expected SPDX expression - either in its own + header, or through a ``REUSE.toml`` annotation for formats that cannot carry + a comment (JSON vectors, the lockfile). +2. The ``REUSE.toml`` annotations declare that same expression, so the two + mechanisms cannot disagree. +3. The root ``LICENSE-*`` files are byte-identical to their ``LICENSES/*.txt`` + counterparts, which is the convention this repository already follows. +4. The library has **no runtime dependencies**. That is a headline claim in the + README and the reason the dependency licence surface is as small as it is, + so it is worth a gate rather than a habit. + +Run from anywhere; paths are resolved against the repository root. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +EXPECTED_SPDX = "MIT OR Apache-2.0" +"""The SPDX expression every first-party file must declare.""" + +LICENSE_PAIRS = { + "LICENSE-MIT": "LICENSES/MIT.txt", + "LICENSE-APACHE": "LICENSES/Apache-2.0.txt", + "LICENSE-MULAN": "LICENSES/MulanPSL-2.0.txt", +} + +MULAN_SHA256 = "eb7a1d713eb919b146787629e22e4c975cb701f529a65d4d7e0fcd417558bf1c" +"""SHA-256 of the official bilingual Mulan PSL v2 text. + +Pinned from the SPDX license-list-data mirror because the canonical host +(license.coscl.org.cn) was unreachable when this was adopted: +https://raw.githubusercontent.com/spdx/license-list-data/main/text/MulanPSL-2.0.txt +""" + + +def tracked_files() -> list[str]: + out = subprocess.run( + ["git", "ls-files"], cwd=ROOT, capture_output=True, text=True, check=True + ) + return [line for line in out.stdout.splitlines() if line] + + +def reuse_annotations() -> dict[str, str]: + """Map each annotated path pattern to the SPDX expression it declares.""" + with (ROOT / "REUSE.toml").open("rb") as handle: + config = tomllib.load(handle) + + patterns: dict[str, str] = {} + for block in config.get("annotations", []): + paths = block["path"] + if isinstance(paths, str): + paths = [paths] + for path in paths: + patterns[path] = block["SPDX-License-Identifier"] + return patterns + + +def matches(pattern: str, path: str) -> bool: + if pattern.endswith("/**"): + return path.startswith(pattern[:-2]) + return pattern == path + + +def check_headers(failures: list[str]) -> None: + annotations = reuse_annotations() + expected_line = f"SPDX-License-Identifier: {EXPECTED_SPDX}" + + for pattern, declared in annotations.items(): + if declared != EXPECTED_SPDX: + failures.append( + f"REUSE.toml: {pattern} declares {declared!r}, expected {EXPECTED_SPDX!r}" + ) + + license_texts = set(LICENSE_PAIRS) | set(LICENSE_PAIRS.values()) + + for path in tracked_files(): + if path in license_texts: + continue + if any(matches(pattern, path) for pattern in annotations): + continue + + text = (ROOT / path).read_text(encoding="utf-8", errors="replace") + if expected_line not in text: + failures.append(f"{path}: missing '{expected_line}'") + + +def check_license_texts(failures: list[str]) -> None: + for root_name, licenses_name in LICENSE_PAIRS.items(): + root_file = ROOT / root_name + licenses_file = ROOT / licenses_name + wanted = "MulanPSL-2.0" in EXPECTED_SPDX or root_name != "LICENSE-MULAN" + + if not root_file.exists() or not licenses_file.exists(): + if wanted: + failures.append(f"{root_name} / {licenses_name}: missing licence text") + continue + + if root_file.read_bytes() != licenses_file.read_bytes(): + failures.append(f"{root_name} and {licenses_name} differ") + + mulan = ROOT / "LICENSE-MULAN" + if "MulanPSL-2.0" in EXPECTED_SPDX and mulan.exists(): + digest = hashlib.sha256(mulan.read_bytes()).hexdigest() + if digest != MULAN_SHA256: + failures.append( + f"LICENSE-MULAN sha256 {digest} does not match the pinned {MULAN_SHA256}" + ) + + +def check_no_runtime_dependencies(failures: list[str]) -> None: + out = subprocess.run( + ["cargo", "metadata", "--format-version", "1", "--no-deps"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + package = json.loads(out.stdout)["packages"][0] + + # `kind` is null for a normal dependency, "dev" or "build" otherwise. + runtime = [ + dep["name"] for dep in package["dependencies"] if dep.get("kind") is None + ] + build = [ + dep["name"] for dep in package["dependencies"] if dep.get("kind") == "build" + ] + + if runtime: + failures.append( + "the library must have no runtime dependencies, found: " + + ", ".join(sorted(runtime)) + ) + if build: + failures.append( + "the library must have no build dependencies, found: " + + ", ".join(sorted(build)) + ) + + +def main() -> int: + failures: list[str] = [] + check_headers(failures) + check_license_texts(failures) + check_no_runtime_dependencies(failures) + + if failures: + print("license check failed:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + + print(f"license check passed ({EXPECTED_SPDX}, no runtime dependencies)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e196773c1b0a478fe57abed5ba1a7f4c2d4bf27b Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Thu, 17 Sep 2026 14:25:48 -0600 Subject: [PATCH 3/5] Offer visual-hashing under MulanPSL-2.0 as a third option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crate is now available under MIT OR Apache-2.0 OR MulanPSL-2.0. "OR" is the operative word: a user picks one of the three and complies with that one. Nothing is taken away from anyone who was already using MIT or Apache-2.0. Blackcat Informatics is the sole author of everything in this tree - two commits, one author, no vendored corpora - so the grant is unilateral and needed no coordination. MulanPSL-2.0 deserves to be documented rather than listed. Its §6 makes the Chinese text controlling where the Chinese and English versions diverge, which is not true of the other two and is the kind of thing a user should learn from LICENSING.md rather than discover later. That file now also states what else differs: MIT grants no patent rights, Apache-2.0 and Mulan both grant patents and both end that grant on patent litigation, Mulan §3 withholds trademarks explicitly, and §4 carries the notice-retention obligation. Every claim there was read off the shipped text, not recalled. The canonical host (license.coscl.org.cn) is unreachable, so LICENSE-MULAN is the official bilingual text taken from the SPDX license-list-data mirror and pinned by SHA-256, which CI re-verifies on every run. vectors/ takes the same grant. Those files are generated by this repository's own scripts from its own reference implementation, and byte-identical copies are redistributed by purrdf and gmeow-gts; naming this repository canonical for them settles a declaration that differed between the three trees. Released as 0.9.0. For a 0.x crate that is a semver-incompatible jump, so a "0.1.3" requirement will not pick it up - which is the right default given the crate it lands in is a different shape now, even though the API and every rendering are unchanged. --- .github/CODEOWNERS | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/copilot-instructions.md | 2 +- .github/dependabot.yml | 2 +- .github/pull_request_template.md | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .gitignore | 2 +- CHANGELOG.md | 42 ++++++- CITATION.cff | 5 +- CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 7 +- Cargo.lock | 2 +- Cargo.toml | 7 +- LICENSE-MULAN | 130 +++++++++++++++++++++ LICENSES/MulanPSL-2.0.txt | 130 +++++++++++++++++++++ LICENSING.md | 107 ++++++++++++++++- README.md | 20 +++- REUSE.toml | 6 +- SECURITY.md | 2 +- deny.toml | 2 +- python/requirements.txt | 2 +- python/scripts/gen_emojihash_vectors.py | 2 +- python/scripts/gen_randomart_vectors.py | 2 +- python/scripts/reference_visual_hashing.py | 2 +- scripts/check-licenses.py | 4 +- src/blake3.rs | 2 +- src/emojihash.rs | 2 +- src/lib.rs | 2 +- src/randomart.rs | 2 +- tests/blake3_equivalence.rs | 2 +- tests/conformance.rs | 2 +- 34 files changed, 458 insertions(+), 48 deletions(-) create mode 100644 LICENSE-MULAN create mode 100644 LICENSES/MulanPSL-2.0.txt diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8017d85..86df29c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 * @paudley @ErinAudley diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6486b51..75a84a9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 name: Bug report description: Report a reproducible defect in emojihash, randomart, docs, or release tooling. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2e634a0..4d238bf 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 blank_issues_enabled: false contact_links: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 2848cf5..e4c47ac 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 name: Feature request description: Propose an enhancement to visual-hashing. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index afefd9f..ff6ac71 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,5 @@ - + # GitHub Copilot Instructions This repository contains the `visual-hashing` Rust crate. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ddf7955..fbe44a3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 version: 2 updates: - package-ecosystem: cargo diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 95a73cc..9af113d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ ## Summary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cc2361..9cd421e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 name: CI on: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00a2c37..9903142 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 name: Release # Requires a crates.io Trusted Publisher for: diff --git a/.gitignore b/.gitignore index cb0ddcd..8e71b31 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 /target/ /.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb075f..30c6748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog @@ -8,6 +8,46 @@ All notable changes to `visual-hashing` are recorded here. ## [Unreleased] +## [0.9.0] - 2026-09-17 + +### Added + +- **A third licence option: `MulanPSL-2.0`.** The crate is now offered under + `MIT OR Apache-2.0 OR MulanPSL-2.0` — pick any one. Note that MulanPSL-2.0 is + bilingual and its §6 makes the Chinese text controlling where the versions + diverge; `LICENSING.md` covers what else differs between the three. The + pinned bilingual text ships as `LICENSE-MULAN`, digest-verified in CI. +- `no_std` support. The library needs only `alloc` and builds for bare-metal + targets (CI proves it on `thumbv7em-none-eabihf`) as well as `wasm32`. +- `scripts/check-licenses.py` and `deny.toml`, which turn this project's + licensing and dependency claims into CI gates rather than assertions. + +### Removed + +- **Every runtime dependency.** `blake3` was the last one, and with it went a + build script, a C-compiler requirement and nine transitive crates. The crate + now carries the part of BLAKE3 it needs in `src/blake3.rs` — unkeyed, + one-shot, extendable output, `#![forbid(unsafe_code)]`. + + Output is unchanged: the frozen conformance vectors pass byte-for-byte, and + `tests/blake3_equivalence.rs` diffs the implementation against the upstream + `blake3` crate (now a dev-only oracle) across every block, chunk and subtree + boundary on each CI run. + + Being portable rather than SIMD, it favours auditability over throughput. If + you hash large inputs in bulk, hash them with `blake3` directly and pass the + digest in. + +- The `serde_json` dev-dependency, replaced by a strict reader for the + generated vector format. + +### Note for existing users + +`0.1.3 → 0.9.0` is a semver-incompatible jump for a `0.x` crate, so a +`visual-hashing = "0.1.3"` requirement will **not** pick this up; update the +requirement deliberately. The public API is unchanged and all renderings are +byte-identical. + ## [0.1.3] - 2026-06-22 ### Changed diff --git a/CITATION.cff b/CITATION.cff index 26935d9..4f4dce2 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,11 +11,12 @@ authors: - family-names: "Audley" given-names: "Patrick" orcid: "https://orcid.org/0000-0003-4382-7625" -version: "0.1.3" -date-released: "2026-06-22" +version: "0.9.0" +date-released: "2026-09-17" license: - Apache-2.0 - MIT + - MulanPSL-2.0 repository-code: "https://github.com/Blackcat-Informatics/visual-hashing" keywords: - blake3 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index c7284c6..06ea572 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,6 +1,6 @@ # Contributor Code of Conduct diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0ec671..853fdc1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to visual-hashing @@ -36,4 +36,7 @@ still pass byte-for-byte. The vector scripts live under `python/scripts/`. Regenerate vectors only when the public rendering contract intentionally changes, then review the JSON diff. -Contributions are accepted under **Apache-2.0 OR MIT**. +Contributions are accepted under **MIT OR Apache-2.0 OR MulanPSL-2.0**. By +contributing, you agree that your contribution may be distributed under any of +the three. See [LICENSING.md](LICENSING.md) for what the three grants differ on +— in particular that MulanPSL-2.0 is bilingual and its Chinese text prevails. diff --git a/Cargo.lock b/Cargo.lock index b90455c..0ae681b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "visual-hashing" -version = "0.1.3" +version = "0.9.0" dependencies = [ "blake3", ] diff --git a/Cargo.toml b/Cargo.toml index 180325e..6538d76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 [package] name = "visual-hashing" description = "Human-friendly visual fingerprints for keys and checksums: a nameable 64-emoji BLAKE3 hash (emojihash) and OpenSSH-style drunken-bishop randomart" -version = "0.1.3" +version = "0.9.0" edition = "2021" rust-version = "1.81" -license = "MIT OR Apache-2.0" +license = "MIT OR Apache-2.0 OR MulanPSL-2.0" repository = "https://github.com/Blackcat-Informatics/visual-hashing" homepage = "https://github.com/Blackcat-Informatics/visual-hashing" documentation = "https://docs.rs/visual-hashing" @@ -20,6 +20,7 @@ include = [ "README.md", "LICENSE-APACHE", "LICENSE-MIT", + "LICENSE-MULAN", "LICENSES/**", "LICENSING.md", "src/**", diff --git a/LICENSE-MULAN b/LICENSE-MULAN new file mode 100644 index 0000000..cbafa43 --- /dev/null +++ b/LICENSE-MULAN @@ -0,0 +1,130 @@ + +木兰宽松许可证, 第2版 + +木兰宽松许可证, 第2版 + +2020年1月 http://license.coscl.org.cn/MulanPSL2 + +您对“软件”的复制、使用、修改及分发受木兰宽松许可证,第2版(“本许可证”)的如下条款的约束: + +0. 定义 + +“软件” 是指由“贡献”构成的许可在“本许可证”下的程序和相关文档的集合。 + +“贡献” 是指由任一“贡献者”许可在“本许可证”下的受版权法保护的作品。 + +“贡献者” 是指将受版权法保护的作品许可在“本许可证”下的自然人或“法人实体”。 + +“法人实体” 是指提交贡献的机构及其“关联实体”。 + +“关联实体” 是指,对“本许可证”下的行为方而言,控制、受控制或与其共同受控制的机构,此处的控制是指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。 + +1. 授予版权许可 + +每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可以复制、使用、修改、分发其“贡献”,不论修改与否。 + +2. 授予专利许可 + +每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其“贡献”或以其他方式转移其“贡献”。前述专利许可仅限于“贡献者”现在或将来拥有或控制的其“贡献”本身或其“贡献”与许可“贡献”时的“软件”结合而将必然会侵犯的专利权利要求,不包括对“贡献”的修改或包含“贡献”的其他结合。如果您或您的“关联实体”直接或间接地,就“软件”或其中的“贡献”对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或其他专利维权行动,指控其侵犯专利权,则“本许可证”授予您对“软件”的专利许可自您提起诉讼或发起维权行动之日终止。 + +3. 无商标许可 + +“本许可证”不提供对“贡献者”的商品名称、商标、服务标志或产品名称的商标许可,但您为满足第4条规定的声明义务而必须使用除外。 + +4. 分发限制 + +您可以在任何媒介中将“软件”以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供“本许可证”的副本,并保留“软件”中的版权、商标、专利及免责声明。 + +5. 免责声明与责任限制 + +“软件”及其中的“贡献”在提供时不带任何明示或默示的担保。在任何情况下,“贡献者”或版权所有者不对任何人因使用“软件”或其中的“贡献”而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于何种法律理论,即使其曾被建议有此种损失的可能性。 + +6. 语言 + +“本许可证”以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文版为准。 + +条款结束 + +如何将木兰宽松许可证,第2版,应用到您的软件 + +如果您希望将木兰宽松许可证,第2版,应用到您的新软件,为了方便接收者查阅,建议您完成如下三步: + +1, 请您补充如下声明中的空白,包括软件名、软件的首次发表年份以及您作为版权人的名字; + +2, 请您在软件包的一级目录下创建以“LICENSE”为名的文件,将整个许可证文本放入该文件中; + +3, 请将如下声明文本放入每个源文件的头部注释中。 + +Copyright (c) [Year] [name of copyright holder] +[Software Name] is licensed under Mulan PSL v2. +You can use this software according to the terms and conditions of the Mulan PSL v2. +You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 +THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, +EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, +MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. +See the Mulan PSL v2 for more details. + +Mulan Permissive Software License,Version 2 + +Mulan Permissive Software License,Version 2 (Mulan PSL v2) + +January 2020 http://license.coscl.org.cn/MulanPSL2 + +Your reproduction, use, modification and distribution of the Software shall be subject to Mulan PSL v2 (this License) with the following terms and conditions: + +0. Definition + +Software means the program and related documents which are licensed under this License and comprise all Contribution(s). + +Contribution means the copyrightable work licensed by a particular Contributor under this License. + +Contributor means the Individual or Legal Entity who licenses its copyrightable work under this License. + +Legal Entity means the entity making a Contribution and all its Affiliates. + +Affiliates means entities that control, are controlled by, or are under common control with the acting entity under this License, ‘control’ means direct or indirect ownership of at least fifty percent (50%) of the voting power, capital or other securities of controlled or commonly controlled entity. + +1. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable copyright license to reproduce, use, modify, or distribute its Contribution, with modification or not. + +2. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable (except for revocation under this Section) patent license to make, have made, use, offer for sale, sell, import or otherwise transfer its Contribution, where such patent license is only limited to the patent claims owned or controlled by such Contributor now or in future which will be necessarily infringed by its Contribution alone, or by combination of the Contribution with the Software to which the Contribution was contributed. The patent license shall not apply to any modification of the Contribution, and any other combination which includes the Contribution. If you or your Affiliates directly or indirectly institute patent litigation (including a cross claim or counterclaim in a litigation) or other patent enforcement activities against any individual or entity by alleging that the Software or any Contribution in it infringes patents, then any patent license granted to you under this License for the Software shall terminate as of the date such litigation or activity is filed or taken. + +3. No Trademark License + +No trademark license is granted to use the trade names, trademarks, service marks, or product names of Contributor, except as required to fulfill notice requirements in section 4. + +4. Distribution Restriction + +You may distribute the Software in any medium with or without modification, whether in source or executable forms, provided that you provide recipients with a copy of this License and retain copyright, patent, trademark and disclaimer statements in the Software. + +5. Disclaimer of Warranty and Limitation of Liability + +THE SOFTWARE AND CONTRIBUTION IN IT ARE PROVIDED WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED. IN NO EVENT SHALL ANY CONTRIBUTOR OR COPYRIGHT HOLDER BE LIABLE TO YOU FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO ANY DIRECT, OR INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING FROM YOUR USE OR INABILITY TO USE THE SOFTWARE OR THE CONTRIBUTION IN IT, NO MATTER HOW IT’S CAUSED OR BASED ON WHICH LEGAL THEORY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +6. Language + +THIS LICENSE IS WRITTEN IN BOTH CHINESE AND ENGLISH, AND THE CHINESE VERSION AND ENGLISH VERSION SHALL HAVE THE SAME LEGAL EFFECT. IN THE CASE OF DIVERGENCE BETWEEN THE CHINESE AND ENGLISH VERSIONS, THE CHINESE VERSION SHALL PREVAIL. + +END OF THE TERMS AND CONDITIONS + +How to Apply the Mulan Permissive Software License,Version 2 (Mulan PSL v2) to Your Software + +To apply the Mulan PSL v2 to your work, for easy identification by recipients, you are suggested to complete following three steps: + +i. Fill in the blanks in following statement, including insert your software name, the year of the first publication of your software, and your name identified as the copyright owner; +ii. Create a file named "LICENSE" which contains the whole context of this License in the first directory of your software package; +iii. Attach the statement to the appropriate annotated syntax at the beginning of each source file. + +Copyright (c) [Year] [name of copyright holder] +[Software Name] is licensed under Mulan PSL v2. +You can use this software according to the terms and conditions of the Mulan PSL v2. +You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 +THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, +EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, +MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. +See the Mulan PSL v2 for more details. diff --git a/LICENSES/MulanPSL-2.0.txt b/LICENSES/MulanPSL-2.0.txt new file mode 100644 index 0000000..cbafa43 --- /dev/null +++ b/LICENSES/MulanPSL-2.0.txt @@ -0,0 +1,130 @@ + +木兰宽松许可证, 第2版 + +木兰宽松许可证, 第2版 + +2020年1月 http://license.coscl.org.cn/MulanPSL2 + +您对“软件”的复制、使用、修改及分发受木兰宽松许可证,第2版(“本许可证”)的如下条款的约束: + +0. 定义 + +“软件” 是指由“贡献”构成的许可在“本许可证”下的程序和相关文档的集合。 + +“贡献” 是指由任一“贡献者”许可在“本许可证”下的受版权法保护的作品。 + +“贡献者” 是指将受版权法保护的作品许可在“本许可证”下的自然人或“法人实体”。 + +“法人实体” 是指提交贡献的机构及其“关联实体”。 + +“关联实体” 是指,对“本许可证”下的行为方而言,控制、受控制或与其共同受控制的机构,此处的控制是指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。 + +1. 授予版权许可 + +每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可以复制、使用、修改、分发其“贡献”,不论修改与否。 + +2. 授予专利许可 + +每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其“贡献”或以其他方式转移其“贡献”。前述专利许可仅限于“贡献者”现在或将来拥有或控制的其“贡献”本身或其“贡献”与许可“贡献”时的“软件”结合而将必然会侵犯的专利权利要求,不包括对“贡献”的修改或包含“贡献”的其他结合。如果您或您的“关联实体”直接或间接地,就“软件”或其中的“贡献”对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或其他专利维权行动,指控其侵犯专利权,则“本许可证”授予您对“软件”的专利许可自您提起诉讼或发起维权行动之日终止。 + +3. 无商标许可 + +“本许可证”不提供对“贡献者”的商品名称、商标、服务标志或产品名称的商标许可,但您为满足第4条规定的声明义务而必须使用除外。 + +4. 分发限制 + +您可以在任何媒介中将“软件”以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供“本许可证”的副本,并保留“软件”中的版权、商标、专利及免责声明。 + +5. 免责声明与责任限制 + +“软件”及其中的“贡献”在提供时不带任何明示或默示的担保。在任何情况下,“贡献者”或版权所有者不对任何人因使用“软件”或其中的“贡献”而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于何种法律理论,即使其曾被建议有此种损失的可能性。 + +6. 语言 + +“本许可证”以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文版为准。 + +条款结束 + +如何将木兰宽松许可证,第2版,应用到您的软件 + +如果您希望将木兰宽松许可证,第2版,应用到您的新软件,为了方便接收者查阅,建议您完成如下三步: + +1, 请您补充如下声明中的空白,包括软件名、软件的首次发表年份以及您作为版权人的名字; + +2, 请您在软件包的一级目录下创建以“LICENSE”为名的文件,将整个许可证文本放入该文件中; + +3, 请将如下声明文本放入每个源文件的头部注释中。 + +Copyright (c) [Year] [name of copyright holder] +[Software Name] is licensed under Mulan PSL v2. +You can use this software according to the terms and conditions of the Mulan PSL v2. +You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 +THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, +EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, +MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. +See the Mulan PSL v2 for more details. + +Mulan Permissive Software License,Version 2 + +Mulan Permissive Software License,Version 2 (Mulan PSL v2) + +January 2020 http://license.coscl.org.cn/MulanPSL2 + +Your reproduction, use, modification and distribution of the Software shall be subject to Mulan PSL v2 (this License) with the following terms and conditions: + +0. Definition + +Software means the program and related documents which are licensed under this License and comprise all Contribution(s). + +Contribution means the copyrightable work licensed by a particular Contributor under this License. + +Contributor means the Individual or Legal Entity who licenses its copyrightable work under this License. + +Legal Entity means the entity making a Contribution and all its Affiliates. + +Affiliates means entities that control, are controlled by, or are under common control with the acting entity under this License, ‘control’ means direct or indirect ownership of at least fifty percent (50%) of the voting power, capital or other securities of controlled or commonly controlled entity. + +1. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable copyright license to reproduce, use, modify, or distribute its Contribution, with modification or not. + +2. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable (except for revocation under this Section) patent license to make, have made, use, offer for sale, sell, import or otherwise transfer its Contribution, where such patent license is only limited to the patent claims owned or controlled by such Contributor now or in future which will be necessarily infringed by its Contribution alone, or by combination of the Contribution with the Software to which the Contribution was contributed. The patent license shall not apply to any modification of the Contribution, and any other combination which includes the Contribution. If you or your Affiliates directly or indirectly institute patent litigation (including a cross claim or counterclaim in a litigation) or other patent enforcement activities against any individual or entity by alleging that the Software or any Contribution in it infringes patents, then any patent license granted to you under this License for the Software shall terminate as of the date such litigation or activity is filed or taken. + +3. No Trademark License + +No trademark license is granted to use the trade names, trademarks, service marks, or product names of Contributor, except as required to fulfill notice requirements in section 4. + +4. Distribution Restriction + +You may distribute the Software in any medium with or without modification, whether in source or executable forms, provided that you provide recipients with a copy of this License and retain copyright, patent, trademark and disclaimer statements in the Software. + +5. Disclaimer of Warranty and Limitation of Liability + +THE SOFTWARE AND CONTRIBUTION IN IT ARE PROVIDED WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED. IN NO EVENT SHALL ANY CONTRIBUTOR OR COPYRIGHT HOLDER BE LIABLE TO YOU FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO ANY DIRECT, OR INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING FROM YOUR USE OR INABILITY TO USE THE SOFTWARE OR THE CONTRIBUTION IN IT, NO MATTER HOW IT’S CAUSED OR BASED ON WHICH LEGAL THEORY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +6. Language + +THIS LICENSE IS WRITTEN IN BOTH CHINESE AND ENGLISH, AND THE CHINESE VERSION AND ENGLISH VERSION SHALL HAVE THE SAME LEGAL EFFECT. IN THE CASE OF DIVERGENCE BETWEEN THE CHINESE AND ENGLISH VERSIONS, THE CHINESE VERSION SHALL PREVAIL. + +END OF THE TERMS AND CONDITIONS + +How to Apply the Mulan Permissive Software License,Version 2 (Mulan PSL v2) to Your Software + +To apply the Mulan PSL v2 to your work, for easy identification by recipients, you are suggested to complete following three steps: + +i. Fill in the blanks in following statement, including insert your software name, the year of the first publication of your software, and your name identified as the copyright owner; +ii. Create a file named "LICENSE" which contains the whole context of this License in the first directory of your software package; +iii. Attach the statement to the appropriate annotated syntax at the beginning of each source file. + +Copyright (c) [Year] [name of copyright holder] +[Software Name] is licensed under Mulan PSL v2. +You can use this software according to the terms and conditions of the Mulan PSL v2. +You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 +THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, +EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, +MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. +See the Mulan PSL v2 for more details. diff --git a/LICENSING.md b/LICENSING.md index 1d02980..79fc791 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -1,15 +1,110 @@ # Licensing -`visual-hashing` is licensed under **Apache-2.0 OR MIT** at your option. +`visual-hashing` is offered under **three** open-source licences: -Blackcat Informatics® Inc. is the sole owner of the project name and trademarks. +```text +MIT OR Apache-2.0 OR MulanPSL-2.0 +``` + +`OR` means what it says. You choose **one** of the three and comply with that +one; you are not subject to the other two, and you do not need permission to +choose. If you have no preference, `MIT` or `Apache-2.0` is the conventional +choice in the Rust ecosystem and nothing here discourages it. + +## Choosing between them + +They are all permissive, and for most users the practical difference is small. +Where they differ: + +| | MIT | Apache-2.0 | MulanPSL-2.0 | +|---|---|---|---| +| Express patent grant | no | yes | yes | +| Patent grant ends if you sue over patents | — | yes | yes | +| Express trademark grant | no | no (explicitly withheld) | no (explicitly withheld) | +| Must state that you changed files | no | yes | no | +| Governing text | English | English | **bilingual; Chinese prevails** | + +**MulanPSL-2.0 §6 states that the licence is published in Chinese and English, +that both versions have equal legal effect, and that where the two conflict, +the Chinese version prevails.** Anyone relying on the English text should know +that it is not the controlling one. This is the reason the licence is worth +naming precisely rather than treating as "another permissive licence". + +All three require you to pass the licence on: MulanPSL-2.0 §4 obliges you to +give recipients a copy of the licence and to retain the copyright, patent, +trademark and disclaimer statements in the software, which is the same shape of +obligation MIT and Apache-2.0 impose. + +MulanPSL-2.0 is [OSI-approved](https://opensource.org/license/mulanpsl-2-0) and +carries the SPDX identifier `MulanPSL-2.0`. + +### The text we ship + +`LICENSE-MULAN` (mirrored at `LICENSES/MulanPSL-2.0.txt`) is the official +bilingual text, pinned by digest: + +```text +source https://raw.githubusercontent.com/spdx/license-list-data/main/text/MulanPSL-2.0.txt +sha256 eb7a1d713eb919b146787629e22e4c975cb701f529a65d4d7e0fcd417558bf1c +``` + +It was taken from the SPDX license-list-data mirror because the canonical host +(`license.coscl.org.cn/MulanPSL2`) was unreachable at adoption time. +`scripts/check-licenses.py` re-verifies the digest on every CI run, so the text +cannot drift without the build noticing. + +## Third-party material + +**The library has no runtime dependencies at all.** Nothing third-party is +compiled into a build that depends on `visual-hashing`, so there is no +third-party notice obligation attached to using it. `src/blake3.rs` is a +first-party implementation of the part of BLAKE3 this crate needs, written for +that reason; it is not a copy of the BLAKE3 reference implementation. + +Development and CI pull in the upstream +[`blake3`](https://crates.io/crates/blake3) crate as a test oracle, along with +its dependencies. Those are **dev-dependencies**: Cargo never builds them for +consumers of this library, and they are not part of anything this project +distributes. They are permissively licensed (`CC0-1.0 OR Apache-2.0 OR +Apache-2.0 WITH LLVM-exception` for `blake3`, `BSD-2-Clause` for `arrayref`, +`MIT OR Apache-2.0` for the rest), and `deny.toml` holds the list that CI +enforces. + +`src/randomart.rs` implements OpenSSH's "Drunken Bishop" algorithm from its +description rather than from OpenSSH source. The one literal it shares with +OpenSSH is the 15-character output ramp `" .o+=*BOX@%&#/^"`, which is a +functional constant required for the renderings to match — not copied +expression. No OpenSSH code is present in this repository. + +## The conformance vectors + +`vectors/` is first-party and carries the same three-licence grant as the code. +The files are generated by `python/scripts/gen_emojihash_vectors.py` and +`python/scripts/gen_randomart_vectors.py` in this repository, from this +repository's own Python reference implementation. + +Byte-identical copies of these vectors are redistributed in +[`purrdf`](https://github.com/Blackcat-Informatics/purrdf) and +[`gmeow-gts`](https://github.com/Blackcat-Informatics/gmeow-gts), which consume +this crate. **This repository is canonical for them.** The broader GTS +conformance corpora carried by those projects (`cose/`, `openpgp/`, `proofs/`, +and others) are not present here and are governed by the GTS program, not by +this file. + +## Trademarks + +Blackcat Informatics® Inc. is the sole owner of the project name and +trademarks. None of the three licences grants trademark rights — Apache-2.0 and +MulanPSL-2.0 withhold them explicitly, and MIT does not address them. Nominative references such as "compatible with visual-hashing" are permitted; uses that imply endorsement are not. -Contributions are accepted under **Apache-2.0 OR MIT** unless explicitly stated -otherwise. By contributing, you agree that your contribution may be distributed -under either license. +## Contributions + +Contributions are accepted under the same terms: **MIT OR Apache-2.0 OR +MulanPSL-2.0**. By contributing, you agree that your contribution may be +distributed under any of the three. diff --git a/README.md b/README.md index 223276b..0e2244f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # visual-hashing [![crates.io](https://img.shields.io/crates/v/visual-hashing.svg)](https://crates.io/crates/visual-hashing) [![docs.rs](https://docs.rs/visual-hashing/badge.svg)](https://docs.rs/visual-hashing) -[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license) +[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0%20OR%20MulanPSL--2.0-blue.svg)](#license) **Human-friendly visual fingerprints** for keys, checksums, and any byte string you need a person to compare out-of-band — the *"is this the right key?"* glance. @@ -141,6 +141,16 @@ portable beyond Rust. ## License -Licensed under either of [MIT](https://github.com/Blackcat-Informatics/visual-hashing/blob/main/LICENSE-MIT) -or [Apache-2.0](https://github.com/Blackcat-Informatics/visual-hashing/blob/main/LICENSE-APACHE) -at your option. © Blackcat Informatics® Inc. +Licensed under any one of +[MIT](https://github.com/Blackcat-Informatics/visual-hashing/blob/main/LICENSE-MIT), +[Apache-2.0](https://github.com/Blackcat-Informatics/visual-hashing/blob/main/LICENSE-APACHE), or +[MulanPSL-2.0](https://github.com/Blackcat-Informatics/visual-hashing/blob/main/LICENSE-MULAN), +at your option — pick one and comply with that one. + +Note that MulanPSL-2.0 is published in Chinese and English, and that **its §6 +makes the Chinese version controlling** where the two diverge. See +[LICENSING.md](https://github.com/Blackcat-Informatics/visual-hashing/blob/main/LICENSING.md) +for what differs between the three and for the vector and third-party +provenance. + +© Blackcat Informatics® Inc. diff --git a/REUSE.toml b/REUSE.toml index 09a748e..5f2193c 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 version = 1 SPDX-PackageName = "visual-hashing" @@ -14,10 +14,10 @@ path = [ ] precedence = "aggregate" SPDX-FileCopyrightText = "2026 Blackcat Informatics® Inc. " -SPDX-License-Identifier = "MIT OR Apache-2.0" +SPDX-License-Identifier = "MIT OR Apache-2.0 OR MulanPSL-2.0" [[annotations]] path = "vectors/**" precedence = "aggregate" SPDX-FileCopyrightText = "2026 Blackcat Informatics® Inc. " -SPDX-License-Identifier = "MIT OR Apache-2.0" +SPDX-License-Identifier = "MIT OR Apache-2.0 OR MulanPSL-2.0" diff --git a/SECURITY.md b/SECURITY.md index 1e72150..467e232 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy diff --git a/deny.toml b/deny.toml index 62e0411..98560e6 100644 --- a/deny.toml +++ b/deny.toml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 # Supply-chain and licence gate. The library itself has no runtime # dependencies (scripts/check-licenses.py enforces that separately); everything diff --git a/python/requirements.txt b/python/requirements.txt index bfe7236..52c07e2 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -1,3 +1,3 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 blake3>=1 diff --git a/python/scripts/gen_emojihash_vectors.py b/python/scripts/gen_emojihash_vectors.py index c1bdb05..05c2ea3 100644 --- a/python/scripts/gen_emojihash_vectors.py +++ b/python/scripts/gen_emojihash_vectors.py @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 """Regenerate the emojihash conformance vectors. emojihash is a deterministic BLAKE3-XOF → 6-bit → 64-emoji mapping, so every diff --git a/python/scripts/gen_randomart_vectors.py b/python/scripts/gen_randomart_vectors.py index c7691a4..3e234d3 100644 --- a/python/scripts/gen_randomart_vectors.py +++ b/python/scripts/gen_randomart_vectors.py @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 """Regenerate the randomart conformance vectors. randomart is the OpenSSH-style "Drunken Bishop" walk: a deterministic function diff --git a/python/scripts/reference_visual_hashing.py b/python/scripts/reference_visual_hashing.py index 78a7721..b17c39a 100644 --- a/python/scripts/reference_visual_hashing.py +++ b/python/scripts/reference_visual_hashing.py @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 """Small Python reference implementation used only to regenerate vectors.""" from __future__ import annotations diff --git a/scripts/check-licenses.py b/scripts/check-licenses.py index c882ba0..0c7b895 100644 --- a/scripts/check-licenses.py +++ b/scripts/check-licenses.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 """Enforce this repository's licensing claims instead of asserting them. Four things are checked, each of which has quietly drifted in real projects: @@ -30,7 +30,7 @@ ROOT = Path(__file__).resolve().parent.parent -EXPECTED_SPDX = "MIT OR Apache-2.0" +EXPECTED_SPDX = "MIT OR Apache-2.0 OR MulanPSL-2.0" """The SPDX expression every first-party file must declare.""" LICENSE_PAIRS = { diff --git a/src/blake3.rs b/src/blake3.rs index a367c5c..3298e21 100644 --- a/src/blake3.rs +++ b/src/blake3.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 //! A one-shot BLAKE3 extendable-output function. //! diff --git a/src/emojihash.rs b/src/emojihash.rs index bf89e99..8fa63a6 100644 --- a/src/emojihash.rs +++ b/src/emojihash.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 //! A nameable 64-emoji visual hash. //! diff --git a/src/lib.rs b/src/lib.rs index af9e5a4..9e028f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 //! Human-friendly **visual fingerprints** for keys, checksums, and any byte //! string you need a human to compare out-of-band. diff --git a/src/randomart.rs b/src/randomart.rs index 389ab0b..9843277 100644 --- a/src/randomart.rs +++ b/src/randomart.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 //! OpenSSH-style "Drunken Bishop" ASCII-art fingerprint. //! diff --git a/tests/blake3_equivalence.rs b/tests/blake3_equivalence.rs index efe291c..7e320b2 100644 --- a/tests/blake3_equivalence.rs +++ b/tests/blake3_equivalence.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 //! Proof that the in-tree BLAKE3 agrees with the upstream implementation. //! diff --git a/tests/conformance.rs b/tests/conformance.rs index 76eb043..cc4fc6e 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2026 Blackcat Informatics® Inc. -// SPDX-License-Identifier: MIT OR Apache-2.0 +// SPDX-License-Identifier: MIT OR Apache-2.0 OR MulanPSL-2.0 //! Conformance against the frozen vectors (the Python reference is the oracle). //! The standalone repository keeps the reference vectors under `vectors/`. From 36f99ce7ffe8d9bf40efbbc16c2acea88ec6511b Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Thu, 17 Sep 2026 14:37:01 -0600 Subject: [PATCH 4/5] Summarise the licence grant in Chinese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopting a licence whose Chinese text is controlling while documenting it only in English serves the wrong audience. LICENSING.md now carries a 中文说明 section: the three-way choice, a table of what actually differs between the options, §6's Chinese-prevails rule, §4's notice obligation, and the trademark position - each keyed to the article numbers and using the licence's own Chinese terminology rather than a back-translation of the English summary. It is explicitly marked as a convenience summary that grants nothing; the three licence texts govern, and the summary defers to them where they disagree. blackcatinformatics.cn is given as the contact point. The licence text itself is untouched, as it must be: the only URL inside LICENSE-MULAN is the licence's own canonical host, which recipients are directed to under the "How to Apply" terms, and those bytes are what the CI digest check pins. Pointing it at our own infrastructure would misrepresent the licence. --- LICENSING.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/LICENSING.md b/LICENSING.md index 79fc791..1fe1c28 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -57,6 +57,50 @@ It was taken from the SPDX license-list-data mirror because the canonical host `scripts/check-licenses.py` re-verifies the digest on every CI run, so the text cannot drift without the build noticing. +## 中文说明(许可证摘要) + +> 本节为便利性摘要,**不构成许可授权**。授权以 `LICENSE-MIT`、`LICENSE-APACHE` +> 及 `LICENSE-MULAN` 三份许可证文本为准;本摘要如与许可证文本不一致,以许可证 +> 文本为准。 + +`visual-hashing` 以下列三种开源许可证提供: + +```text +MIT OR Apache-2.0 OR MulanPSL-2.0 +``` + +`OR` 意为「任选其一」:您选定其中一种后,只需遵守该种许可证,不受另外两种约束, +也无需另行取得许可。 + +三者的主要差异: + +| | MIT | Apache-2.0 | 木兰宽松许可证,第2版 | +|---|---|---|---| +| 明示专利许可 | 无 | 有 | 有(第2条) | +| 提起专利诉讼即终止专利许可 | — | 是 | 是(第2条) | +| 明示商标许可 | 未涉及 | 明确排除 | 明确排除(第3条) | +| 须声明文件已被修改 | 否 | 是 | 否 | +| 文本语言 | 英文 | 英文 | 中英文双语,**以中文版为准** | + +木兰宽松许可证第6条规定:「本许可证」以中英文双语表述,中英文版本具有同等法律 +效力;如果中英文版本存在任何冲突不一致,**以中文版为准**。因此,阅读英文本的 +使用者应注意英文本并非最终依据。 + +第4条要求:您在任何媒介中重新分发本软件时,必须向接收者提供「本许可证」的副本, +并保留软件中的版权、商标、专利及免责声明。 + +本仓库所附的 `LICENSE-MULAN`(副本位于 `LICENSES/MulanPSL-2.0.txt`)为官方中英文 +双语文本,并以 SHA-256 摘要固定(见上文 *The text we ship*);持续集成在每次运行 +时重新校验该摘要,以确保文本未被改动。 + +**运行时依赖**:本库没有任何运行时依赖,因此使用本库不会引入第三方的声明义务。 + +**商标**:Blackcat Informatics® Inc. 为本项目名称及商标的唯一所有人。上述三种 +许可证均未授予商标权利。允许「兼容 visual-hashing」一类的指称性使用;不得用于 +暗示背书。 + +中文咨询请访问 。 + ## Third-party material **The library has no runtime dependencies at all.** Nothing third-party is diff --git a/README.md b/README.md index 0e2244f..4a47303 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,6 @@ Note that MulanPSL-2.0 is published in Chinese and English, and that **its §6 makes the Chinese version controlling** where the two diverge. See [LICENSING.md](https://github.com/Blackcat-Informatics/visual-hashing/blob/main/LICENSING.md) for what differs between the three and for the vector and third-party -provenance. +provenance — it includes a 中文说明 summarising the grant in Chinese. © Blackcat Informatics® Inc. From 210e4c56578b08cf6eeaae79d2a9cd10b31192b9 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Thu, 17 Sep 2026 14:44:26 -0600 Subject: [PATCH 5/5] Release as 1.0.0 and make the wire contract binding The API and both renderings are frozen, so there is no reason to ship 0.9.0 and then 1.0.0 a week later. Going straight to 1.0 states the guarantee the README was already describing in the future tense. What 1.0 commits to: the 64-emoji alphabet, the randomart character ramp, and the public API do not change. A fingerprint already printed in a log, a CLI banner or someone's notes has to keep meaning what it meant, so altering either rendering would warrant a new crate rather than a new major version. The frozen vectors under vectors/ are what holds that line, and CI fails on drift. Downstream requirements become visual-hashing = "1". --- CHANGELOG.md | 23 ++++++++++++++++++----- CITATION.cff | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 12 ++++++++---- 5 files changed, 29 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c6748..ccbcfe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to `visual-hashing` are recorded here. ## [Unreleased] -## [0.9.0] - 2026-09-17 +## [1.0.0] - 2026-09-17 ### Added @@ -41,12 +41,25 @@ All notable changes to `visual-hashing` are recorded here. - The `serde_json` dev-dependency, replaced by a strict reader for the generated vector format. +### Stability + +`1.0` makes the wire contract binding. The 64-emoji alphabet, the randomart +character ramp and the public API will not change: a fingerprint already +printed in a log, a CLI banner or a user's notes has to keep meaning what it +meant, so a change to either rendering would be a new crate rather than a new +major version. + ### Note for existing users -`0.1.3 → 0.9.0` is a semver-incompatible jump for a `0.x` crate, so a -`visual-hashing = "0.1.3"` requirement will **not** pick this up; update the -requirement deliberately. The public API is unchanged and all renderings are -byte-identical. +`0.1.3 → 1.0.0` is a semver-incompatible jump, so a `visual-hashing = "0.1.3"` +requirement will **not** pick this up — for a `0.x` crate the minor slot is the +breaking one, giving `>=0.1.3, <0.2.0`. Update the requirement to +`visual-hashing = "1"` deliberately. + +Nothing about the output changed: the public API is identical and every +rendering is byte-for-byte the same, as the frozen vectors demonstrate. What +changed is the shape of the crate around it — no runtime dependencies, no build +script, `no_std`, and a third licence option. ## [0.1.3] - 2026-06-22 diff --git a/CITATION.cff b/CITATION.cff index 4f4dce2..de14d9f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,7 +11,7 @@ authors: - family-names: "Audley" given-names: "Patrick" orcid: "https://orcid.org/0000-0003-4382-7625" -version: "0.9.0" +version: "1.0.0" date-released: "2026-09-17" license: - Apache-2.0 diff --git a/Cargo.lock b/Cargo.lock index 0ae681b..95d0eb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "visual-hashing" -version = "0.9.0" +version = "1.0.0" dependencies = [ "blake3", ] diff --git a/Cargo.toml b/Cargo.toml index 6538d76..0ca9bd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "visual-hashing" description = "Human-friendly visual fingerprints for keys and checksums: a nameable 64-emoji BLAKE3 hash (emojihash) and OpenSSH-style drunken-bishop randomart" -version = "0.9.0" +version = "1.0.0" edition = "2021" rust-version = "1.81" license = "MIT OR Apache-2.0 OR MulanPSL-2.0" diff --git a/README.md b/README.md index 4a47303..112ee75 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,14 @@ space for unvisited cells); the start and end squares are marked `S` and `E`. ## Stability -The 64-emoji alphabet and the randomart character ramp are a **wire contract**: once -`1.0` ships they will not change, because a fingerprint that renders differently across -versions is worse than useless. Pre-`1.0` the alphabet is considered stable but reserves -the right to fix outright mistakes. +The 64-emoji alphabet and the randomart character ramp are a **wire contract**, and as +of `1.0` that contract is binding: they will not change, because a fingerprint that +renders differently across versions is worse than useless. A change to either would be +a new crate, not a new major version — anything already printed in a log, a CLI banner +or a user's notes has to keep meaning what it meant. + +The API is frozen on the same terms. Vectors under `vectors/` pin both renderings +byte-for-byte, and CI fails on any drift. **No dependencies.** Not a short list — none. The crate carries its own one-shot BLAKE3 (`src/blake3.rs`), so there is no build script, no C compiler and nothing