Skip to content

Commit 65741c3

Browse files
authored
Merge pull request #34 from senamakel/fast-module-hash
perf(module): hash artifacts with sha2 in large chunks and remember the library digest
2 parents 273b515 + 9b360a0 commit 65741c3

10 files changed

Lines changed: 360 additions & 85 deletions

File tree

‎Cargo.lock‎

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

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
2525
async-trait = "0.1"
2626
serde = { version = "1", features = ["derive"] }
2727
serde_json = "1"
28+
sha2 = "0.10"
2829
thiserror = "2"
2930
tracing = "0.1"
3031

‎crates/tinybus/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ autoexamples = false
2424
async-trait = { workspace = true }
2525
serde = { workspace = true }
2626
serde_json = { workspace = true }
27+
sha2 = { workspace = true }
2728
thiserror = { workspace = true }
2829
tracing = { workspace = true }
2930
tokio = { version = "1", features = ["macros", "rt", "sync", "time", "io-util"] }

‎crates/tinybus/src/module/cache.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ use tracing::{debug, warn};
5252

5353
use crate::error::{Error, Result};
5454
use crate::module::hash::file_hex;
55+
use crate::module::remembered_hash::file_hex_remembered;
5556

5657
/// Suffix of the marker beside an archive holding the digest its release
5758
/// manifest published.
@@ -196,7 +197,7 @@ fn sidecar_matches(module: &Path) -> bool {
196197
let Ok(file) = std::fs::File::open(module) else {
197198
return false;
198199
};
199-
file_hex(file).is_ok_and(|actual| actual == expected)
200+
file_hex_remembered(file).is_ok_and(|actual| actual == expected)
200201
}
201202

202203
/// Where the digest marker for `asset_name` lives in `dir`.

‎crates/tinybus/src/module/hash.rs‎

Lines changed: 24 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,43 @@
1-
//! Dependency-free SHA-256 used by module artifact verification.
1+
//! SHA-256 used by module artifact verification.
22
//!
33
//! Verification at load is what an attestation later stands on: a recipient
44
//! earns the right to be handed a confidential body by having its artifact
55
//! digested here and matched against the operator's allowlist. Compiled
66
//! unconditionally rather than behind `modules`, because `module::sha256_file`
77
//! offers the same digest to callers that publish an asset without ever
88
//! loading one.
9+
//!
10+
//! Input is read in large chunks, not one 64-byte block per `read` syscall, and
11+
//! the compression function is `sha2`'s, which uses the CPU's SHA extensions
12+
//! where it has them, so hashing a 25 MB library stays off the startup path.
13+
//! Hashing the same library twice is avoided one level up, in
14+
//! `remembered_hash`.
915
1016
use std::io::{self, Read};
1117

12-
const INITIAL: [u32; 8] = [
13-
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
14-
];
18+
use sha2::{Digest, Sha256};
1519

16-
const K: [u32; 64] = [
17-
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
18-
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
19-
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
20-
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
21-
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
22-
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
23-
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
24-
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
25-
];
20+
/// Bytes requested per `read`. Large enough that a 25 MB library is ~100
21+
/// syscalls, small enough to stay out of the allocator's way.
22+
const CHUNK: usize = 256 * 1024;
2623

24+
/// Lowercase hex SHA-256 of everything `reader` yields.
2725
pub(crate) fn file_hex(mut reader: impl Read) -> io::Result<String> {
28-
let mut state = INITIAL;
29-
let mut block = [0u8; 64];
30-
let mut used = 0usize;
31-
let mut byte_len = 0u64;
26+
let mut hasher = Sha256::new();
27+
let mut chunk = vec![0u8; CHUNK];
3228
loop {
33-
let read = reader.read(&mut block[used..])?;
34-
if read == 0 {
35-
break;
36-
}
37-
used += read;
38-
byte_len = byte_len.wrapping_add(read as u64);
39-
if used == block.len() {
40-
compress(&mut state, &block);
41-
used = 0;
29+
match reader.read(&mut chunk) {
30+
Ok(0) => break,
31+
Ok(read) => hasher.update(&chunk[..read]),
32+
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
33+
Err(error) => return Err(error),
4234
}
4335
}
44-
block[used] = 0x80;
45-
used += 1;
46-
if used > 56 {
47-
block[used..].fill(0);
48-
compress(&mut state, &block);
49-
block.fill(0);
50-
} else {
51-
block[used..56].fill(0);
52-
}
53-
block[56..].copy_from_slice(&byte_len.wrapping_mul(8).to_be_bytes());
54-
compress(&mut state, &block);
55-
Ok(state.iter().map(|word| format!("{word:08x}")).collect())
56-
}
57-
58-
fn compress(state: &mut [u32; 8], block: &[u8; 64]) {
59-
let mut words = [0u32; 64];
60-
for (index, chunk) in block.as_chunks::<4>().0.iter().enumerate() {
61-
words[index] = u32::from_be_bytes(*chunk);
62-
}
63-
for index in 16..64 {
64-
let s0 = words[index - 15].rotate_right(7)
65-
^ words[index - 15].rotate_right(18)
66-
^ (words[index - 15] >> 3);
67-
let s1 = words[index - 2].rotate_right(17)
68-
^ words[index - 2].rotate_right(19)
69-
^ (words[index - 2] >> 10);
70-
words[index] = words[index - 16]
71-
.wrapping_add(s0)
72-
.wrapping_add(words[index - 7])
73-
.wrapping_add(s1);
74-
}
75-
76-
let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = *state;
77-
for index in 0..64 {
78-
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
79-
let choice = (e & f) ^ ((!e) & g);
80-
let t1 = h
81-
.wrapping_add(s1)
82-
.wrapping_add(choice)
83-
.wrapping_add(K[index])
84-
.wrapping_add(words[index]);
85-
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
86-
let majority = (a & b) ^ (a & c) ^ (b & c);
87-
let t2 = s0.wrapping_add(majority);
88-
h = g;
89-
g = f;
90-
f = e;
91-
e = d.wrapping_add(t1);
92-
d = c;
93-
c = b;
94-
b = a;
95-
a = t1.wrapping_add(t2);
96-
}
97-
for (slot, value) in state.iter_mut().zip([a, b, c, d, e, f, g, h]) {
98-
*slot = slot.wrapping_add(value);
99-
}
36+
Ok(hasher
37+
.finalize()
38+
.iter()
39+
.map(|byte| format!("{byte:02x}"))
40+
.collect())
10041
}
10142

10243
#[cfg(test)]

‎crates/tinybus/src/module/hash_tests.rs‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,83 @@ fn sha256_matches_a_published_multi_block_input_vector() {
2121
"41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3"
2222
);
2323
}
24+
25+
/// Hands out at most `step` bytes per `read`, the way a pipe or a slow disk can.
26+
struct Trickle<'a> {
27+
data: &'a [u8],
28+
step: usize,
29+
}
30+
31+
impl std::io::Read for Trickle<'_> {
32+
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
33+
let n = self.step.min(out.len()).min(self.data.len());
34+
out[..n].copy_from_slice(&self.data[..n]);
35+
self.data = &self.data[n..];
36+
Ok(n)
37+
}
38+
}
39+
40+
fn pattern(len: usize) -> Vec<u8> {
41+
(0..len).map(|i| (i * 31 % 251) as u8).collect()
42+
}
43+
44+
#[test]
45+
fn sha256_is_the_same_whatever_the_read_sizes_and_chunk_boundaries() {
46+
// Spans several chunks and ends mid-chunk and mid-block.
47+
let data = pattern(super::CHUNK * 3 + 77);
48+
let whole = super::file_hex(&data[..]).unwrap();
49+
for step in [1, 63, 64, 65, 4096, super::CHUNK - 1, super::CHUNK + 1] {
50+
let trickled = super::file_hex(Trickle { data: &data, step }).unwrap();
51+
assert_eq!(trickled, whole, "step {step}");
52+
}
53+
}
54+
55+
#[test]
56+
fn sha256_matches_the_published_million_a_vector() {
57+
assert_eq!(
58+
super::file_hex(&vec![b'a'; 1_000_000][..]).unwrap(),
59+
"cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
60+
);
61+
}
62+
63+
/// Fails with `kind` on the first `read`, then behaves like `data`.
64+
struct FailsOnce<'a> {
65+
data: &'a [u8],
66+
kind: Option<std::io::ErrorKind>,
67+
}
68+
69+
impl std::io::Read for FailsOnce<'_> {
70+
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
71+
if let Some(kind) = self.kind.take() {
72+
return Err(kind.into());
73+
}
74+
let n = out.len().min(self.data.len());
75+
out[..n].copy_from_slice(&self.data[..n]);
76+
self.data = &self.data[n..];
77+
Ok(n)
78+
}
79+
}
80+
81+
#[test]
82+
fn an_interrupted_read_is_retried_not_reported() {
83+
let data = pattern(5000);
84+
let reader = FailsOnce {
85+
data: &data,
86+
kind: Some(std::io::ErrorKind::Interrupted),
87+
};
88+
assert_eq!(
89+
super::file_hex(reader).unwrap(),
90+
super::file_hex(&data[..]).unwrap()
91+
);
92+
}
93+
94+
#[test]
95+
fn a_failing_read_is_an_error_not_a_digest_of_what_was_read() {
96+
let data = pattern(5000);
97+
let reader = FailsOnce {
98+
data: &data,
99+
kind: Some(std::io::ErrorKind::PermissionDenied),
100+
};
101+
let error = super::file_hex(reader).unwrap_err();
102+
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
103+
}

‎crates/tinybus/src/module/host.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1842,7 +1842,7 @@ fn allowlisted_hash(path: &Path, file: std::fs::File) -> Result<Option<String>>
18421842
"module allowlist contains an invalid hash",
18431843
));
18441844
}
1845-
let actual = crate::module::hash::file_hex(file)
1845+
let actual = crate::module::remembered_hash::file_hex_remembered(file)
18461846
.map_err(|_| Error::module_refused(path, "artifact hash could not be read"))?;
18471847
if actual != expected {
18481848
return Err(Error::module_refused(

‎crates/tinybus/src/module/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ pub(crate) mod host;
2222
#[cfg(feature = "modules")]
2323
mod loader;
2424
#[cfg(feature = "modules")]
25+
mod remembered_hash;
26+
#[cfg(feature = "modules")]
2527
mod resolve;
2628
#[cfg(feature = "modules")]
2729
mod transport;

0 commit comments

Comments
 (0)