- 1
//! SHA-256 over installed and downloaded artifacts. - 2
//! - 3
//! Update writes an executable that arrived over the network. Without a - 4
//! digest to check it against, a truncated body or a substituted file is - 5
//! indistinguishable from a good download, and the failure surfaces later - 6
//! as a binary that will not run. - 7
- 8
use std::io::Read as _; - 9
use std::path::Path; - 10
- 11
use sha2::{Digest as _, Sha256}; - 12
- 13
const CHUNK: usize = 64 * 1024; - 14
- 15
/// Lowercase hex SHA-256 of a file, streamed so a large artifact is never - 16
/// held in memory twice. - 17
pub fn of_file(path: &Path) -> Result<String, String> { - 18
let mut file = - 19
std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?; - 20
let mut hasher = Sha256::new(); - 21
let mut buf = vec![0u8; CHUNK]; - 22
loop { - 23
let n = file - 24
.read(&mut buf) - 25
.map_err(|e| format!("read {}: {e}", path.display()))?; - 26
if n == 0 { - 27
break; - 28
} - 29
hasher.update(&buf[..n]); - 30
} - 31
Ok(hex::encode(hasher.finalize())) - 32
} - 33
- 34
/// Lowercase hex SHA-256 over a whole directory tree. - 35
/// - 36
/// The desktop frontend is a *tree* — `index.html` plus a directory of - 37
/// content-hashed assets — and digesting only `index.html` would miss the - 38
/// failure that actually happens: the shell arrives and the JS it names - 39
/// does not, so the app opens a blank window with nothing in the console - 40
/// to explain it. That has shipped from this repository twice by other - 41
/// routes, and both times the shell was fine. - 42
/// - 43
/// The digest covers each file's path as well as its bytes, so a deleted - 44
/// asset, an added one, and a renamed one all change the result. Paths are - 45
/// sorted, so the digest does not depend on directory-iteration order. - 46
#[cfg(test)] - 47
pub fn of_tree(root: &Path) -> Result<String, String> { - 48
of_tree_excluding(root, &[]) - 49
} - 50
- 51
/// [`of_tree`], skipping paths that must not be inside their own digest. - 52
/// - 53
/// In a macOS bundle the install manifest lives at - 54
/// `Contents/Resources/install.json` — the same directory as the desktop - 55
/// frontend. A manifest cannot contain a digest of a tree that contains the - 56
/// manifest: the value would change the moment it was written, and - 57
/// `verify` would report every fresh install as corrupt. (It did.) - 58
pub fn of_tree_excluding(root: &Path, exclude: &[&Path]) -> Result<String, String> { - 59
let mut files = Vec::new(); - 60
collect(root, root, &mut files)?; - 61
files.retain(|rel| !exclude.iter().any(|e| *e == root.join(rel))); - 62
files.sort(); - 63
- 64
let mut hasher = Sha256::new(); - 65
for rel in &files { - 66
hasher.update(rel.as_bytes()); - 67
hasher.update([0u8]); - 68
hasher.update(of_file(&root.join(rel))?.as_bytes()); - 69
hasher.update([0u8]); - 70
} - 71
Ok(hex::encode(hasher.finalize())) - 72
} - 73
- 74
fn collect(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), String> { - 75
let entries = std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {e}", dir.display()))?; - 76
for entry in entries { - 77
let entry = entry.map_err(|e| format!("read dir {}: {e}", dir.display()))?; - 78
let path = entry.path(); - 79
if path.is_dir() { - 80
collect(root, &path, out)?; - 81
} else { - 82
let rel = path - 83
.strip_prefix(root) - 84
.map_err(|_| format!("{} escaped {}", path.display(), root.display()))?; - 85
out.push(rel.to_string_lossy().replace('\\', "/")); - 86
} - 87
} - 88
Ok(()) - 89
} - 90
- 91
/// Lowercase hex SHA-256 of a byte slice. - 92
pub fn of_bytes(bytes: &[u8]) -> String { - 93
let mut hasher = Sha256::new(); - 94
hasher.update(bytes); - 95
hex::encode(hasher.finalize()) - 96
} - 97
- 98
/// Compare a computed digest against an expected one, case- and - 99
/// whitespace-insensitively so a digest pasted from `shasum` output works. - 100
pub fn matches(expected: &str, actual: &str) -> bool { - 101
expected.trim().eq_ignore_ascii_case(actual.trim()) - 102
} - 103
- 104
#[cfg(test)] - 105
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 106
mod tests { - 107
use super::*; - 108
- 109
#[test] - 110
fn file_and_bytes_digests_agree() { - 111
let dir = tempfile::tempdir().unwrap(); - 112
let p = dir.path().join("f"); - 113
std::fs::write(&p, b"vak").unwrap(); - 114
assert_eq!(of_file(&p).unwrap(), of_bytes(b"vak")); - 115
} - 116
- 117
#[test] - 118
fn digest_spans_content_larger_than_one_chunk() { - 119
// Guards the streaming loop: a bug that hashed only the first - 120
// chunk would still pass a small-file test. - 121
let dir = tempfile::tempdir().unwrap(); - 122
let p = dir.path().join("big"); - 123
let body = vec![7u8; CHUNK * 3 + 17]; - 124
std::fs::write(&p, &body).unwrap(); - 125
assert_eq!(of_file(&p).unwrap(), of_bytes(&body)); - 126
} - 127
- 128
#[test] - 129
fn tree_digest_notices_a_missing_asset() { - 130
// The exact shape of the failure this guards: index.html intact, - 131
// the bundle it names gone. - 132
let dir = tempfile::tempdir().unwrap(); - 133
let root = dir.path(); - 134
std::fs::create_dir_all(root.join("assets")).unwrap(); - 135
std::fs::write(root.join("index.html"), b"<script src=/assets/a.js>").unwrap(); - 136
std::fs::write(root.join("assets/a.js"), b"console.log(1)").unwrap(); - 137
- 138
let before = of_tree(root).unwrap(); - 139
std::fs::remove_file(root.join("assets/a.js")).unwrap(); - 140
assert_ne!( - 141
before, - 142
of_tree(root).unwrap(), - 143
"a deleted asset must change the tree digest" - 144
); - 145
} - 146
- 147
#[test] - 148
fn tree_digest_is_stable_and_notices_a_rename() { - 149
let dir = tempfile::tempdir().unwrap(); - 150
let root = dir.path(); - 151
std::fs::create_dir_all(root.join("assets")).unwrap(); - 152
std::fs::write(root.join("assets/one.js"), b"x").unwrap(); - 153
std::fs::write(root.join("assets/two.js"), b"y").unwrap(); - 154
- 155
let a = of_tree(root).unwrap(); - 156
assert_eq!(a, of_tree(root).unwrap(), "digest must not vary run to run"); - 157
- 158
// Same bytes, different name: content-hashed filenames are how a - 159
// frontend rebuild announces itself, so the digest must move. - 160
std::fs::rename(root.join("assets/one.js"), root.join("assets/three.js")).unwrap(); - 161
assert_ne!(a, of_tree(root).unwrap()); - 162
} - 163
- 164
#[test] - 165
fn a_tree_digest_can_exclude_the_file_that_will_hold_it() { - 166
// The bundle case: the manifest lands in the directory it - 167
// describes, so a digest that counted it would never match twice. - 168
let dir = tempfile::tempdir().unwrap(); - 169
let root = dir.path(); - 170
std::fs::write(root.join("index.html"), b"shell").unwrap(); - 171
- 172
let manifest = root.join("install.json"); - 173
let before = of_tree_excluding(root, &[manifest.as_path()]).unwrap(); - 174
std::fs::write(&manifest, b"{}").unwrap(); - 175
assert_eq!( - 176
before, - 177
of_tree_excluding(root, &[manifest.as_path()]).unwrap(), - 178
"writing the excluded file must not move the digest" - 179
); - 180
assert_ne!( - 181
before, - 182
of_tree(root).unwrap(), - 183
"and without the exclusion it must, or the test proves nothing" - 184
); - 185
} - 186
- 187
#[test] - 188
fn comparison_tolerates_case_and_surrounding_whitespace() { - 189
let d = of_bytes(b"x"); - 190
assert!(matches(&format!(" {} ", d.to_uppercase()), &d)); - 191
assert!(!matches("00", &d)); - 192
} - 193
} - 194
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.