- 1
//! The record of what is installed, and the integrity data that lets - 2
//! `status` and `update` tell the truth about it. - 3
//! - 4
//! A v1 manifest recorded only `(name, path)` pairs and a single version - 5
//! string. That could not answer the two questions that matter after an - 6
//! update: is every component actually at the recorded version, and is - 7
//! the file on disk the file we put there? v2 records a digest per - 8
//! component, so drift is detected rather than assumed absent. - 9
- 10
use std::path::PathBuf; - 11
- 12
use super::layout::InstallRoot; - 13
use crate::install::digest; - 14
- 15
/// Current manifest schema. Bump only for a breaking shape change; new - 16
/// optional fields do not require it. - 17
pub const SCHEMA: u32 = 2; - 18
- 19
/// One installed executable or asset. - 20
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] - 21
pub struct Component { - 22
pub name: String, - 23
pub path: PathBuf, - 24
/// Lowercase hex SHA-256 of the file as installed. - 25
pub sha256: String, - 26
/// A missing required component means the install is broken, not - 27
/// merely partial — `status` and `verify` treat the two differently. - 28
#[serde(default)] - 29
pub required: bool, - 30
} - 31
- 32
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] - 33
pub struct Manifest { - 34
#[serde(default)] - 35
pub schema: u32, - 36
pub version: String, - 37
pub git_sha: String, - 38
pub installed_at: String, - 39
/// The prefix this manifest describes. Self-describing so tooling - 40
/// that finds a manifest knows the root without re-deriving it. - 41
#[serde(default)] - 42
pub prefix: PathBuf, - 43
#[serde(default)] - 44
pub components: Vec<Component>, - 45
} - 46
- 47
/// What `verify` found wrong with an install. - 48
#[derive(Debug, PartialEq, Eq)] - 49
pub enum Defect { - 50
Missing { name: String, path: PathBuf }, - 51
Corrupt { name: String, path: PathBuf }, - 52
Unreadable { name: String, detail: String }, - 53
} - 54
- 55
impl std::fmt::Display for Defect { - 56
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 57
match self { - 58
Defect::Missing { name, path } => { - 59
write!(f, "{name}: missing at {}", path.display()) - 60
} - 61
Defect::Corrupt { name, path } => write!( - 62
f, - 63
"{name}: contents differ from the manifest digest ({})", - 64
path.display() - 65
), - 66
Defect::Unreadable { name, detail } => write!(f, "{name}: {detail}"), - 67
} - 68
} - 69
} - 70
- 71
impl Manifest { - 72
/// Build a manifest describing a freshly placed install. - 73
pub fn new(version: String, prefix: PathBuf, components: Vec<Component>) -> Self { - 74
Self { - 75
schema: SCHEMA, - 76
version, - 77
git_sha: build_git_sha(), - 78
installed_at: now_rfc3339(), - 79
prefix, - 80
components, - 81
} - 82
} - 83
- 84
pub fn read(root: &InstallRoot) -> Result<Self, String> { - 85
let path = root.manifest_path(); - 86
let raw = std::fs::read(&path).map_err(|_| { - 87
format!( - 88
"no managed install at {} — run `vak self install`", - 89
root.prefix().display() - 90
) - 91
})?; - 92
let mut m: Manifest = serde_json::from_slice(&raw) - 93
.map_err(|e| format!("manifest at {} is unreadable: {e}", path.display()))?; - 94
- 95
// AGENTS.md invariant 29: an install written before the baseline is - 96
// refused whole. There is no v1 fold-forward any more -- a manifest - 97
// that old describes a tree this build cannot reason about, and - 98
// half-adopting it produced an install that `verify` and `update` - 99
// disagreed about. - 100
if vak_core::baseline::is_pre_baseline(&m.version) { - 101
return Err(vak_core::baseline::refusal( - 102
"The install manifest", - 103
&m.version, - 104
)); - 105
} - 106
if m.schema > SCHEMA { - 107
return Err(format!( - 108
"manifest at {} is schema {} but this build supports schema {SCHEMA} \ - 109
-- it was written by a newer vak; upgrade rather than downgrade", - 110
path.display(), - 111
m.schema - 112
)); - 113
} - 114
if m.prefix.as_os_str().is_empty() { - 115
m.prefix = root.prefix().to_path_buf(); - 116
} - 117
if m.schema == 0 { - 118
m.schema = SCHEMA; - 119
} - 120
Ok(m) - 121
} - 122
- 123
pub fn write(&self, root: &InstallRoot) -> Result<(), String> { - 124
let json = serde_json::to_vec_pretty(self).map_err(|e| format!("serialize: {e}"))?; - 125
super::atomic::write(&root.manifest_path(), &json) - 126
} - 127
- 128
pub fn component(&self, name: &str) -> Option<&Component> { - 129
self.components.iter().find(|c| c.name == name) - 130
} - 131
- 132
/// Path of the installed CLI, which every service unit execs. - 133
pub fn cli_path(&self) -> Result<PathBuf, String> { - 134
self.component("vak") - 135
.map(|c| c.path.clone()) - 136
.ok_or_else(|| "manifest lacks a vak entry — reinstall to repair".into()) - 137
} - 138
- 139
/// Check every recorded component against the filesystem. An empty - 140
/// result means the install is exactly what the manifest claims. - 141
pub fn verify(&self) -> Vec<Defect> { - 142
let manifest_path = vak_core::install::manifest_path_for_prefix(&self.prefix); - 143
let mut defects = Vec::new(); - 144
for c in &self.components { - 145
if !c.path.exists() { - 146
if c.required { - 147
defects.push(Defect::Missing { - 148
name: c.name.clone(), - 149
path: c.path.clone(), - 150
}); - 151
} - 152
continue; - 153
} - 154
// An entry with no digest on record predates digesting for - 155
// that component; absence of a digest is not evidence of - 156
// corruption. - 157
if c.sha256.is_empty() { - 158
continue; - 159
} - 160
// A component may be a tree — the desktop frontend is one — - 161
// and `of_file` on a directory fails with an IO error that - 162
// would read as corruption rather than as the wrong check. - 163
let computed = if c.path.is_dir() { - 164
// Same exclusion the install used: in a bundle this - 165
// manifest lives inside the tree it describes. - 166
digest::of_tree_excluding(&c.path, &[manifest_path.as_path()]) - 167
} else { - 168
digest::of_file(&c.path) - 169
}; - 170
match computed { - 171
Ok(actual) if actual == c.sha256 => {} - 172
Ok(_) => defects.push(Defect::Corrupt { - 173
name: c.name.clone(), - 174
path: c.path.clone(), - 175
}), - 176
Err(detail) => defects.push(Defect::Unreadable { - 177
name: c.name.clone(), - 178
detail, - 179
}), - 180
} - 181
} - 182
defects - 183
} - 184
} - 185
- 186
/// Compile-time version of the running binary. - 187
pub fn build_version() -> &'static str { - 188
env!("CARGO_PKG_VERSION") - 189
} - 190
- 191
/// Commit the running binary was built from, when the build recorded one. - 192
pub fn build_git_sha() -> String { - 193
option_env!("VAK_GIT_SHA") - 194
.map(str::to_string) - 195
.unwrap_or_else(|| "unknown".into()) - 196
} - 197
- 198
pub fn now_rfc3339() -> String { - 199
chrono::Utc::now().to_rfc3339() - 200
} - 201
- 202
#[cfg(test)] - 203
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 204
mod tests { - 205
use super::*; - 206
- 207
fn temp_root(tag: &str) -> (tempfile::TempDir, InstallRoot) { - 208
let dir = tempfile::Builder::new() - 209
.prefix(&format!("vak-manifest-{tag}-")) - 210
.tempdir() - 211
.unwrap(); - 212
let root = InstallRoot::at(dir.path().to_path_buf()); - 213
(dir, root) - 214
} - 215
- 216
#[test] - 217
fn a_pre_baseline_manifest_is_refused_with_the_shared_message() { - 218
// AGENTS.md invariant 29. The v1 shape used to be folded forward - 219
// here; an install that old is now refused whole, and the operator - 220
// is told the one command that resolves it rather than being left - 221
// with an install `verify` and `update` disagree about. - 222
let (_d, root) = temp_root("pre-baseline"); - 223
let bin = root.bin_dir().join("vak"); - 224
std::fs::create_dir_all(root.bin_dir()).unwrap(); - 225
std::fs::write(&bin, b"binary").unwrap(); - 226
let old = serde_json::json!({ - 227
"schema": SCHEMA, - 228
"version": "1.0.3", - 229
"git_sha": "abc", - 230
"installed_at": "2026-01-01T00:00:00Z", - 231
"components": [{"name": "vak", "path": bin, "sha256": "", "required": true}], - 232
}); - 233
std::fs::create_dir_all(root.manifest_path().parent().unwrap()).unwrap(); - 234
std::fs::write(root.manifest_path(), old.to_string()).unwrap(); - 235
- 236
let err = Manifest::read(&root).unwrap_err(); - 237
assert!(err.contains("1.0.3"), "the refusal names what it found"); - 238
assert!( - 239
err.contains(vak_core::baseline::BASELINE), - 240
"and the baseline it expected" - 241
); - 242
assert!( - 243
err.contains("vak self uninstall --purge"), - 244
"and the one command that resolves it" - 245
); - 246
} - 247
- 248
#[test] - 249
fn a_newer_schema_is_refused_rather_than_half_read() { - 250
let (_d, root) = temp_root("newer-schema"); - 251
let newer = serde_json::json!({ - 252
"schema": SCHEMA + 1, - 253
"version": "9.0.0", - 254
"git_sha": "abc", - 255
"installed_at": "2026-01-01T00:00:00Z", - 256
"components": [], - 257
}); - 258
std::fs::create_dir_all(root.manifest_path().parent().unwrap()).unwrap(); - 259
std::fs::write(root.manifest_path(), newer.to_string()).unwrap(); - 260
- 261
let err = Manifest::read(&root).unwrap_err(); - 262
assert!(err.contains("written by a newer vak")); - 263
} - 264
- 265
#[test] - 266
fn verify_reports_a_component_whose_bytes_changed() { - 267
let (_d, root) = temp_root("corrupt"); - 268
std::fs::create_dir_all(root.bin_dir()).unwrap(); - 269
let bin = root.bin_dir().join("vak"); - 270
std::fs::write(&bin, b"original").unwrap(); - 271
let m = Manifest { - 272
schema: SCHEMA, - 273
version: "2.0.0".into(), - 274
git_sha: "abc".into(), - 275
installed_at: now_rfc3339(), - 276
prefix: root.prefix().to_path_buf(), - 277
components: vec![Component { - 278
name: "vak".into(), - 279
path: bin.clone(), - 280
sha256: digest::of_file(&bin).unwrap(), - 281
required: true, - 282
}], - 283
}; - 284
assert!(m.verify().is_empty()); - 285
- 286
std::fs::write(&bin, b"tampered").unwrap(); - 287
assert_eq!( - 288
m.verify(), - 289
vec![Defect::Corrupt { - 290
name: "vak".into(), - 291
path: bin - 292
}] - 293
); - 294
} - 295
- 296
#[test] - 297
fn verify_reports_a_missing_required_component_but_tolerates_optional() { - 298
let (_d, root) = temp_root("missing"); - 299
let required = root.bin_dir().join("vak"); - 300
let optional = root.bin_dir().join("vak-delivery-worker"); - 301
let m = Manifest { - 302
schema: SCHEMA, - 303
version: "2.0.0".into(), - 304
git_sha: "abc".into(), - 305
installed_at: now_rfc3339(), - 306
prefix: root.prefix().to_path_buf(), - 307
components: vec![ - 308
Component { - 309
name: "vak".into(), - 310
path: required.clone(), - 311
sha256: "deadbeef".into(), - 312
required: true, - 313
}, - 314
Component { - 315
name: "vak-delivery-worker".into(), - 316
path: optional, - 317
sha256: "deadbeef".into(), - 318
required: false, - 319
}, - 320
], - 321
}; - 322
assert_eq!( - 323
m.verify(), - 324
vec![Defect::Missing { - 325
name: "vak".into(), - 326
path: required - 327
}], - 328
"an absent optional component is not a defect" - 329
); - 330
} - 331
} - 332
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.