- 1
//! All-or-nothing placement of a set of files. - 2
//! - 3
//! Install and update both replace several executables at once. Doing that - 4
//! file by file leaves a window in which the CLI is new and the tray is - 5
//! old, and a failure halfway through leaves no way back. A transaction - 6
//! stages every file first, verifies the whole set, and only then moves - 7
//! them into place — restoring what it displaced if any move fails. - 8
- 9
use std::path::{Path, PathBuf}; - 10
- 11
use super::atomic; - 12
use super::layout::InstallRoot; - 13
- 14
/// A file waiting in staging for its destination. - 15
struct Staged { - 16
name: String, - 17
staged_at: PathBuf, - 18
destination: PathBuf, - 19
executable: bool, - 20
} - 21
- 22
/// A file already moved into place, with whatever it displaced. - 23
struct Committed { - 24
destination: PathBuf, - 25
displaced: Option<PathBuf>, - 26
} - 27
- 28
pub struct Transaction { - 29
staging: PathBuf, - 30
backup: PathBuf, - 31
staged: Vec<Staged>, - 32
committed: Vec<Committed>, - 33
finished: bool, - 34
} - 35
- 36
impl Transaction { - 37
/// Open a transaction against an install root. Staging and backup - 38
/// both live inside the prefix so every move is a same-filesystem - 39
/// rename rather than a copy that could itself fail halfway. - 40
pub fn begin(root: &InstallRoot) -> Result<Self, String> { - 41
let staging = root.staging_dir(); - 42
let backup = root.backup_dir(); - 43
// Debris from a process that died mid-transaction is not state to - 44
// preserve; the manifest is the record of truth. - 45
let _ = std::fs::remove_dir_all(&staging); - 46
let _ = std::fs::remove_dir_all(&backup); - 47
std::fs::create_dir_all(&staging) - 48
.map_err(|e| format!("mkdir {}: {e}", staging.display()))?; - 49
std::fs::create_dir_all(&backup).map_err(|e| format!("mkdir {}: {e}", backup.display()))?; - 50
Ok(Self { - 51
staging, - 52
backup, - 53
staged: Vec::new(), - 54
committed: Vec::new(), - 55
finished: false, - 56
}) - 57
} - 58
- 59
fn staging_path(&self, name: &str) -> PathBuf { - 60
// Flatten into staging: two components may share a basename in - 61
// different destination directories. - 62
self.staging - 63
.join(name.replace(std::path::MAIN_SEPARATOR, "_")) - 64
} - 65
- 66
/// Stage bytes destined for `destination`. - 67
pub fn stage_bytes( - 68
&mut self, - 69
name: &str, - 70
bytes: &[u8], - 71
destination: PathBuf, - 72
executable: bool, - 73
) -> Result<(), String> { - 74
let staged_at = self.staging_path(name); - 75
if executable { - 76
atomic::write_executable(&staged_at, bytes)?; - 77
} else { - 78
atomic::write(&staged_at, bytes)?; - 79
} - 80
self.staged.push(Staged { - 81
name: name.to_string(), - 82
staged_at, - 83
destination, - 84
executable, - 85
}); - 86
Ok(()) - 87
} - 88
- 89
/// Stage an existing file destined for `destination`. - 90
pub fn stage_file( - 91
&mut self, - 92
name: &str, - 93
source: &Path, - 94
destination: PathBuf, - 95
executable: bool, - 96
) -> Result<(), String> { - 97
let bytes = std::fs::read(source).map_err(|e| format!("read {}: {e}", source.display()))?; - 98
self.stage_bytes(name, &bytes, destination, executable) - 99
} - 100
- 101
pub fn is_empty(&self) -> bool { - 102
self.staged.is_empty() - 103
} - 104
- 105
/// Move every staged file into place. On the first failure, every - 106
/// file already moved is restored and the error is returned, so the - 107
/// install is left exactly as it was found. - 108
pub fn commit(mut self) -> Result<(), String> { - 109
let staged = std::mem::take(&mut self.staged); - 110
for item in staged { - 111
if let Err(e) = self.place(&item) { - 112
let restore = self.rollback_committed(); - 113
self.finished = true; - 114
self.cleanup(); - 115
return Err(match restore { - 116
Ok(()) => format!("{e} (no changes were kept)"), - 117
Err(r) => format!("{e}; rollback also failed: {r}"), - 118
}); - 119
} - 120
} - 121
self.finished = true; - 122
self.cleanup(); - 123
Ok(()) - 124
} - 125
- 126
fn place(&mut self, item: &Staged) -> Result<(), String> { - 127
// Every failure in this function names the component, so an - 128
// operator reading one line knows which piece of the release - 129
// could not be placed and where. - 130
if let Some(parent) = item.destination.parent() { - 131
std::fs::create_dir_all(parent).map_err(|e| { - 132
format!( - 133
"install {}: cannot create {}: {e}", - 134
item.name, - 135
parent.display() - 136
) - 137
})?; - 138
} - 139
// Preserve whatever is there so a later failure can put it back. - 140
let displaced = if item.destination.exists() { - 141
let keep = self.backup.join(format!( - 142
"{}.{}", - 143
item.name.replace(std::path::MAIN_SEPARATOR, "_"), - 144
std::process::id() - 145
)); - 146
std::fs::rename(&item.destination, &keep).map_err(|e| { - 147
format!( - 148
"install {}: cannot set aside existing {}: {e}", - 149
item.name, - 150
item.destination.display() - 151
) - 152
})?; - 153
Some(keep) - 154
} else { - 155
None - 156
}; - 157
match std::fs::rename(&item.staged_at, &item.destination) { - 158
Ok(()) => { - 159
if item.executable { - 160
atomic::set_executable(&item.destination)?; - 161
} - 162
self.committed.push(Committed { - 163
destination: item.destination.clone(), - 164
displaced, - 165
}); - 166
Ok(()) - 167
} - 168
Err(e) => { - 169
// Put back what we displaced before reporting, so this - 170
// one destination is already whole. - 171
if let Some(keep) = displaced { - 172
let _ = std::fs::rename(&keep, &item.destination); - 173
} - 174
Err(format!( - 175
"install {} to {}: {e}", - 176
item.name, - 177
item.destination.display() - 178
)) - 179
} - 180
} - 181
} - 182
- 183
fn rollback_committed(&mut self) -> Result<(), String> { - 184
let mut failures = Vec::new(); - 185
// Reverse order so a destination touched twice ends on its - 186
// original contents. - 187
for done in self.committed.drain(..).rev() { - 188
let _ = std::fs::remove_file(&done.destination); - 189
if let Some(keep) = done.displaced - 190
&& let Err(e) = std::fs::rename(&keep, &done.destination) - 191
{ - 192
failures.push(format!("restore {}: {e}", done.destination.display())); - 193
} - 194
} - 195
if failures.is_empty() { - 196
Ok(()) - 197
} else { - 198
Err(failures.join("; ")) - 199
} - 200
} - 201
- 202
fn cleanup(&self) { - 203
let _ = std::fs::remove_dir_all(&self.staging); - 204
let _ = std::fs::remove_dir_all(&self.backup); - 205
} - 206
} - 207
- 208
impl Drop for Transaction { - 209
fn drop(&mut self) { - 210
// Dropped without commit: the caller bailed out. Undo anything - 211
// already placed and leave no scratch directories behind. - 212
if !self.finished { - 213
let _ = self.rollback_committed(); - 214
self.cleanup(); - 215
} - 216
} - 217
} - 218
- 219
#[cfg(test)] - 220
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 221
mod tests { - 222
use super::*; - 223
- 224
fn root() -> (tempfile::TempDir, InstallRoot) { - 225
let d = tempfile::tempdir().unwrap(); - 226
let r = InstallRoot::at(d.path().to_path_buf()); - 227
(d, r) - 228
} - 229
- 230
#[test] - 231
fn commit_places_every_staged_file_and_clears_scratch() { - 232
let (_d, root) = root(); - 233
let a = root.bin_dir().join("vak"); - 234
let b = root.bin_dir().join("vak-delivery-worker"); - 235
let mut tx = Transaction::begin(&root).unwrap(); - 236
tx.stage_bytes("vak", b"new-cli", a.clone(), true).unwrap(); - 237
tx.stage_bytes("vak-delivery-worker", b"new-tray", b.clone(), true) - 238
.unwrap(); - 239
tx.commit().unwrap(); - 240
- 241
assert_eq!(std::fs::read(&a).unwrap(), b"new-cli"); - 242
assert_eq!(std::fs::read(&b).unwrap(), b"new-tray"); - 243
assert!(!root.staging_dir().exists()); - 244
assert!(!root.backup_dir().exists()); - 245
} - 246
- 247
#[test] - 248
fn a_failed_placement_restores_every_earlier_file() { - 249
let (_d, root) = root(); - 250
let good = root.bin_dir().join("vak"); - 251
std::fs::create_dir_all(root.bin_dir()).unwrap(); - 252
std::fs::write(&good, b"old-cli").unwrap(); - 253
- 254
// Second destination sits under a path whose parent is a regular - 255
// file, so creating its directory fails and the transaction must - 256
// put the first file back. (A directory at the destination is not - 257
// enough: it would simply be moved aside into backup and the - 258
// placement would succeed.) - 259
let wall = root.bin_dir().join("not-a-dir"); - 260
std::fs::write(&wall, b"regular file").unwrap(); - 261
let blocked = wall.join("vak-delivery-worker"); - 262
- 263
let mut tx = Transaction::begin(&root).unwrap(); - 264
tx.stage_bytes("vak", b"new-cli", good.clone(), true) - 265
.unwrap(); - 266
tx.stage_bytes("vak-delivery-worker", b"new-tray", blocked, true) - 267
.unwrap(); - 268
let err = tx.commit().unwrap_err(); - 269
- 270
assert!( - 271
err.contains("vak-delivery-worker"), - 272
"error names the failure: {err}" - 273
); - 274
assert_eq!( - 275
std::fs::read(&good).unwrap(), - 276
b"old-cli", - 277
"the earlier file must be back to its original contents" - 278
); - 279
assert!(!root.staging_dir().exists()); - 280
assert!(!root.backup_dir().exists()); - 281
} - 282
- 283
#[test] - 284
fn dropping_without_commit_leaves_the_install_untouched() { - 285
let (_d, root) = root(); - 286
let target = root.bin_dir().join("vak"); - 287
std::fs::create_dir_all(root.bin_dir()).unwrap(); - 288
std::fs::write(&target, b"old").unwrap(); - 289
{ - 290
let mut tx = Transaction::begin(&root).unwrap(); - 291
tx.stage_bytes("vak", b"new", target.clone(), true).unwrap(); - 292
// No commit: the guard must undo staging on the way out. - 293
} - 294
assert_eq!(std::fs::read(&target).unwrap(), b"old"); - 295
assert!(!root.staging_dir().exists()); - 296
} - 297
- 298
#[test] - 299
fn stale_scratch_from_a_killed_process_is_discarded_on_begin() { - 300
let (_d, root) = root(); - 301
std::fs::create_dir_all(root.staging_dir()).unwrap(); - 302
std::fs::write(root.staging_dir().join("debris"), b"x").unwrap(); - 303
let tx = Transaction::begin(&root).unwrap(); - 304
assert!(tx.is_empty()); - 305
assert!(!root.staging_dir().join("debris").exists()); - 306
} - 307
} - 308
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.