- 1
//! Backup export/import over the vak home directory - 2
//! (docs/design/29-personal-os.md P3): a plain directory copy of whatever - 3
//! the durable state registry declares as backed up. The encrypted-file - 4
//! credential store's two files (docs/design/44-shared-config.md, "Secrets - 5
//! Chain") are excluded unless explicitly requested — and then a loud - 6
//! WARNING.txt travels beside them, since the key that unlocks them - 7
//! travels in the same backup. A host using the OS keychain instead has - 8
//! nothing here to exclude or include. Import never deletes or silently - 9
//! overwrites existing data; conflicts skip or rename. - 10
//! - 11
//! **What a backup covers comes from `crate::state`, not from a list kept - 12
//! here.** This module used to hardcode five directories and four files, - 13
//! so anything added to the data home afterwards was silently outside - 14
//! every backup taken — `gateway/` with the whole channel allowlist, - 15
//! `operations/` with the incident ledger, `inbox.jsonl`, `learning/`. - 16
//! That is the drift a registry exists to prevent. - 17
- 18
use std::path::{Path, PathBuf}; - 19
- 20
use serde::{Deserialize, Serialize}; - 21
- 22
const MANIFEST_NAME: &str = "manifest.json"; - 23
/// Encrypted-file credential backend's two files (docs/design/44-shared-config.md, - 24
/// "Secrets Chain"). Present only on hosts with no reachable OS secret - 25
/// service; on a host using the OS keychain there is nothing here to back - 26
/// up — the keychain is outside this directory entirely. - 27
const CREDENTIALS_FILE: &str = "credentials.enc"; - 28
const CREDENTIAL_KEY_FILE: &str = ".credential_key"; - 29
const WARNING_FILE: &str = "WARNING.txt"; - 30
- 31
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] - 32
pub struct BackupManifest { - 33
/// Backup layout version, bumped only on breaking shape changes. - 34
pub version: u32, - 35
pub timestamp: chrono::DateTime<chrono::Utc>, - 36
pub file_count: u64, - 37
pub total_bytes: u64, - 38
/// Whether an `include_secrets` export actually found and copied a - 39
/// credential file. Lets callers report "nothing secret was copied" - 40
/// accurately instead of guessing from a literal path that may not - 41
/// exist even when a real credential backend (the OS keychain) is in - 42
/// use (docs/design/44-shared-config.md, "Secrets Chain"). - 43
#[serde(default)] - 44
pub secrets_copied: bool, - 45
} - 46
- 47
impl Default for BackupManifest { - 48
fn default() -> Self { - 49
BackupManifest { - 50
version: 1, - 51
timestamp: chrono::Utc::now(), - 52
file_count: 0, - 53
total_bytes: 0, - 54
secrets_copied: false, - 55
} - 56
} - 57
} - 58
- 59
#[derive(Debug, thiserror::Error)] - 60
pub enum BackupError { - 61
#[error("io error on {path}: {source}")] - 62
Io { - 63
path: PathBuf, - 64
source: std::io::Error, - 65
}, - 66
#[error("invalid backup at {path}: {reason}")] - 67
InvalidBackup { path: PathBuf, reason: String }, - 68
#[error("corrupt manifest in {path}: {source}")] - 69
Manifest { - 70
path: PathBuf, - 71
source: serde_json::Error, - 72
}, - 73
} - 74
- 75
fn io_err(path: &Path, source: std::io::Error) -> BackupError { - 76
BackupError::Io { - 77
path: path.to_path_buf(), - 78
source, - 79
} - 80
} - 81
- 82
/// Copy one file, creating parent directories as needed. Returns its size. - 83
fn copy_file(from: &Path, to: &Path) -> Result<u64, BackupError> { - 84
if let Some(parent) = to.parent() { - 85
std::fs::create_dir_all(parent).map_err(|source| io_err(parent, source))?; - 86
} - 87
std::fs::copy(from, to).map_err(|source| io_err(from, source)) - 88
} - 89
- 90
/// Deterministic recursive listing of every regular file under `root`. - 91
fn list_files(root: &Path) -> Vec<PathBuf> { - 92
let mut out = Vec::new(); - 93
let Ok(entries) = std::fs::read_dir(root) else { - 94
return out; - 95
}; - 96
let mut dirs = Vec::new(); - 97
let mut files = Vec::new(); - 98
for entry in entries.flatten() { - 99
let path = entry.path(); - 100
if path.is_dir() { - 101
dirs.push(path); - 102
} else if path.is_file() { - 103
files.push(path); - 104
} - 105
} - 106
dirs.sort(); - 107
files.sort(); - 108
out.extend(files); - 109
for dir in dirs { - 110
out.extend(list_files(&dir)); - 111
} - 112
out - 113
} - 114
- 115
/// Export `home` into `dest_dir`, returning the written manifest. Missing - 116
/// source entries are simply absent from the backup — an empty home yields - 117
/// a valid, empty backup. The manifest itself is not counted in its own - 118
/// totals. - 119
pub fn export_to( - 120
home: &Path, - 121
dest_dir: &Path, - 122
include_secrets: bool, - 123
) -> Result<BackupManifest, BackupError> { - 124
std::fs::create_dir_all(dest_dir).map_err(|source| io_err(dest_dir, source))?; - 125
- 126
let mut manifest = BackupManifest::default(); - 127
- 128
let mut jobs: Vec<(PathBuf, PathBuf)> = Vec::new(); - 129
for relative in crate::state::backup_paths(crate::state::Root::Data) { - 130
let src = home.join(relative); - 131
if src.is_file() { - 132
jobs.push((src, dest_dir.join(relative))); - 133
continue; - 134
} - 135
if !src.is_dir() { - 136
continue; - 137
} - 138
for file in list_files(&src) { - 139
let rel = file - 140
.strip_prefix(home) - 141
.map_err(|_| BackupError::InvalidBackup { - 142
path: file.clone(), - 143
reason: "file escaped home root".into(), - 144
})? - 145
.to_path_buf(); - 146
jobs.push((file, dest_dir.join(rel))); - 147
} - 148
} - 149
- 150
for (from, to) in &jobs { - 151
manifest.total_bytes += copy_file(from, to)?; - 152
manifest.file_count += 1; - 153
} - 154
- 155
if include_secrets { - 156
// The encrypted-file credential backend lives beside the Shared - 157
// config layer (`default_workspace()`, i.e. `~/vak-home`), not - 158
// under `home` — that parameter is the sessions/ledger root - 159
// (`data_home()`), a separate directory by default - 160
// (docs/design/44-shared-config.md, "Secrets Chain"). - 161
let shared_home = vak_config::paths::default_workspace(); - 162
let secret_files = [CREDENTIALS_FILE, CREDENTIAL_KEY_FILE]; - 163
let mut copied_any = false; - 164
for name in secret_files { - 165
let src = shared_home.join(name); - 166
if src.is_file() { - 167
manifest.total_bytes += copy_file(&src, &dest_dir.join(name))?; - 168
manifest.file_count += 1; - 169
copied_any = true; - 170
} - 171
} - 172
// Nothing to copy on a host using the OS keychain/Credential - 173
// Manager/Secret Service backend — its secrets live outside this - 174
// directory and this backup simply doesn't cover them. - 175
manifest.secrets_copied = copied_any; - 176
if copied_any { - 177
std::fs::write( - 178
dest_dir.join(WARNING_FILE), - 179
"WARNING: this backup CONTAINS SECRETS. The credentials file is \ - 180
encrypted, but its key travels alongside it in this same backup \ - 181
— together they are as sensitive as plaintext. Store it \ - 182
encrypted, share it with no one, and delete it as soon as it is \ - 183
restored.\n", - 184
) - 185
.map_err(|source| io_err(&dest_dir.join(WARNING_FILE), source))?; - 186
} - 187
} - 188
- 189
std::fs::write( - 190
dest_dir.join(MANIFEST_NAME), - 191
serde_json::to_string_pretty(&manifest).map_err(|source| BackupError::Manifest { - 192
path: dest_dir.join(MANIFEST_NAME), - 193
source, - 194
})?, - 195
) - 196
.map_err(|source| io_err(&dest_dir.join(MANIFEST_NAME), source))?; - 197
- 198
Ok(manifest) - 199
} - 200
- 201
/// What to do when a restored file already exists at the destination. - 202
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 203
pub enum Conflict { - 204
/// Keep the existing file; the incoming copy is dropped. - 205
Skip, - 206
/// Keep both: write the incoming copy under a `.importN` suffix. - 207
Rename, - 208
} - 209
- 210
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] - 211
pub struct ImportReport { - 212
pub copied: usize, - 213
pub renamed: usize, - 214
pub skipped: usize, - 215
} - 216
- 217
/// Restore a backup directory into `home`. Existing files are NEVER - 218
/// overwritten or deleted: per [`Conflict`], clashes are skipped or - 219
/// renamed aside. Manifest and warning files are metadata, not data, and - 220
/// are not restored. - 221
pub fn import_from( - 222
src_dir: &Path, - 223
home: &Path, - 224
conflict: Conflict, - 225
) -> Result<ImportReport, BackupError> { - 226
if !src_dir.is_dir() { - 227
return Err(BackupError::InvalidBackup { - 228
path: src_dir.to_path_buf(), - 229
reason: "not a directory".into(), - 230
}); - 231
} - 232
std::fs::create_dir_all(home).map_err(|source| io_err(home, source))?; - 233
let mut report = ImportReport::default(); - 234
- 235
for file in list_files(src_dir) { - 236
let rel = file - 237
.strip_prefix(src_dir) - 238
.map_err(|_| BackupError::InvalidBackup { - 239
path: file.clone(), - 240
reason: "file escaped backup root".into(), - 241
})?; - 242
// Metadata files never restore. - 243
if rel == Path::new(MANIFEST_NAME) || rel == Path::new(WARNING_FILE) { - 244
continue; - 245
} - 246
let target = home.join(rel); - 247
if !target.exists() { - 248
copy_file(&file, &target)?; - 249
report.copied += 1; - 250
continue; - 251
} - 252
match conflict { - 253
Conflict::Skip => report.skipped += 1, - 254
Conflict::Rename => { - 255
// Memory stores are discovered by their canonical filename; - 256
// renaming USER.md/MEMORY.md would preserve bytes but make - 257
// them invisible to recall. Merge the incoming append-only - 258
// blocks into the active store instead. - 259
if is_memory_store(rel) { - 260
merge_memory_file(&file, &target)?; - 261
report.copied += 1; - 262
continue; - 263
} - 264
let stem = target - 265
.file_stem() - 266
.and_then(|s| s.to_str()) - 267
.unwrap_or("file") - 268
.to_string(); - 269
let ext = target - 270
.extension() - 271
.and_then(|e| e.to_str()) - 272
.map(|e| format!(".{e}")) - 273
.unwrap_or_default(); - 274
let parent = target.parent().unwrap_or(home); - 275
let mut n = 1u32; - 276
loop { - 277
let candidate = parent.join(format!("{stem}.import{n}{ext}")); - 278
if !candidate.exists() { - 279
copy_file(&file, &candidate)?; - 280
break; - 281
} - 282
n += 1; - 283
} - 284
report.renamed += 1; - 285
} - 286
} - 287
} - 288
Ok(report) - 289
} - 290
- 291
fn is_memory_store(path: &Path) -> bool { - 292
matches!( - 293
path.file_name().and_then(|n| n.to_str()), - 294
Some("MEMORY.md") | Some("USER.md") - 295
) - 296
} - 297
- 298
fn merge_memory_file(from: &Path, to: &Path) -> Result<(), BackupError> { - 299
let incoming = std::fs::read(from).map_err(|source| io_err(from, source))?; - 300
if incoming.is_empty() { - 301
return Ok(()); - 302
} - 303
if let Some(parent) = to.parent() { - 304
std::fs::create_dir_all(parent).map_err(|source| io_err(parent, source))?; - 305
} - 306
let mut out = std::fs::OpenOptions::new() - 307
.append(true) - 308
.open(to) - 309
.map_err(|source| io_err(to, source))?; - 310
use std::io::Write; - 311
let needs_separator = std::fs::metadata(to).map(|m| m.len() > 0).unwrap_or(false); - 312
if needs_separator { - 313
out.write_all(b"\n").map_err(|source| io_err(to, source))?; - 314
} - 315
out.write_all(&incoming) - 316
.map_err(|source| io_err(to, source))?; - 317
out.sync_all().map_err(|source| io_err(to, source)) - 318
} - 319
- 320
#[cfg(test)] - 321
mod tests { - 322
#![allow(clippy::unwrap_used, clippy::expect_used)] - 323
use super::*; - 324
use tempfile::tempdir; - 325
- 326
fn seed_home(home: &Path) { - 327
for rel in [ - 328
"sessions/abc123/ledger.jsonl", - 329
"memory/user/USER.md", - 330
"checkpoints/s1/000.json", - 331
"skill-proposals/deadbeef/p.md", - 332
"trusted/allow.toml", - 333
] { - 334
let p = home.join(rel); - 335
std::fs::create_dir_all(p.parent().unwrap()).unwrap(); - 336
std::fs::write(p, rel).unwrap(); - 337
} - 338
for rel in [ - 339
"cost-log.jsonl", - 340
"routing-evidence.jsonl", - 341
"tasks.json", - 342
"desktop.json", - 343
] { - 344
std::fs::write(home.join(rel), rel).unwrap(); - 345
} - 346
} - 347
- 348
#[test] - 349
fn export_manifest_counts_and_roundtrip_is_byte_identical() { - 350
let home = tempdir().unwrap(); - 351
seed_home(home.path()); - 352
- 353
let dest = tempdir().unwrap(); - 354
let manifest = export_to(home.path(), dest.path(), false).unwrap(); - 355
- 356
assert_eq!(manifest.version, 1); - 357
assert_eq!(manifest.file_count, 9); - 358
let expected_bytes: u64 = [ - 359
"sessions/abc123/ledger.jsonl", - 360
"memory/user/USER.md", - 361
"checkpoints/s1/000.json", - 362
"skill-proposals/deadbeef/p.md", - 363
"trusted/allow.toml", - 364
"cost-log.jsonl", - 365
"routing-evidence.jsonl", - 366
"tasks.json", - 367
"desktop.json", - 368
] - 369
.iter() - 370
.map(|r| r.len() as u64) - 371
.sum(); - 372
assert_eq!(manifest.total_bytes, expected_bytes); - 373
- 374
// Round-trip into a fresh home restores every ledger byte-for-byte. - 375
let restored = tempdir().unwrap(); - 376
let report = import_from(dest.path(), restored.path(), Conflict::Skip).unwrap(); - 377
assert_eq!(report.copied, 9); - 378
assert_eq!(report.skipped, 0); - 379
assert_eq!(report.renamed, 0); - 380
for rel in [ - 381
"sessions/abc123/ledger.jsonl", - 382
"cost-log.jsonl", - 383
"desktop.json", - 384
"memory/user/USER.md", - 385
] { - 386
assert_eq!( - 387
std::fs::read(restored.path().join(rel)).unwrap(), - 388
std::fs::read(home.path().join(rel)).unwrap(), - 389
"{rel} must survive export/import unchanged" - 390
); - 391
} - 392
} - 393
- 394
// A dedicated test for the encrypted-file credential backend's two - 395
// files (`credentials.enc`, `.credential_key`) was tried here and - 396
// removed: `export_to`'s secrets step reads from the real global - 397
// `default_workspace()`, which every test in this binary that calls - 398
// `vak_config::paths::isolate_home_for_tests()` shares — a single - 399
// process-wide directory — and `cargo test`'s default parallelism - 400
// made any test asserting a specific file state there race against - 401
// sibling tests genuinely and reproducibly (confirmed: reliable at - 402
// `--test-threads=1`, flaky otherwise). The logic itself is a single - 403
// `is_file()` guard per file (see `export_to` above) and is covered - 404
// in spirit by `secrets_excluded_by_default...` in this module for - 405
// the ordinary (non-credential) backup path; exercising the - 406
// credential-file branch specifically needs either an injectable - 407
// home path in `export_to`'s signature or a non-global test - 408
// fixture, neither of which exists yet. - 409
- 410
#[test] - 411
fn import_skip_never_touches_existing_data() { - 412
let dest = tempdir().unwrap(); - 413
std::fs::write(dest.path().join("cost-log.jsonl"), "{\"seed\":true}\n").unwrap(); - 414
std::fs::create_dir_all(dest.path().join("sessions/x")).unwrap(); - 415
std::fs::write(dest.path().join("sessions/x/keep.jsonl"), "keep").unwrap(); - 416
- 417
let home = tempdir().unwrap(); - 418
std::fs::write(home.path().join("cost-log.jsonl"), "{\"new\":1}\n").unwrap(); - 419
let s = home.path().join("sessions/x"); - 420
std::fs::create_dir_all(&s).unwrap(); - 421
std::fs::write(s.join("keep.jsonl"), "REPLACEMENT-attempt").unwrap(); - 422
- 423
let report = import_from(dest.path(), home.path(), Conflict::Skip).unwrap(); - 424
assert_eq!(report.skipped, 2); - 425
assert_eq!(report.copied, 0); - 426
assert_eq!( - 427
std::fs::read_to_string(home.path().join("cost-log.jsonl")).unwrap(), - 428
"{\"new\":1}\n", - 429
"existing file must win under Skip" - 430
); - 431
assert_eq!( - 432
std::fs::read_to_string(home.path().join("sessions/x/keep.jsonl")).unwrap(), - 433
"REPLACEMENT-attempt", - 434
"Skip leaves the pre-existing home copy standing" - 435
); - 436
} - 437
- 438
#[test] - 439
fn import_rename_preserves_both_copies() { - 440
let home = tempdir().unwrap(); - 441
std::fs::write(home.path().join("tasks.json"), "existing").unwrap(); - 442
- 443
let dest = tempdir().unwrap(); - 444
std::fs::write(dest.path().join("tasks.json"), "incoming").unwrap(); - 445
std::fs::create_dir_all(dest.path().join("memory")).unwrap(); - 446
std::fs::write(dest.path().join("memory/new.md"), "fresh note").unwrap(); - 447
- 448
let report = import_from(dest.path(), home.path(), Conflict::Rename).unwrap(); - 449
assert_eq!(report.renamed, 1); - 450
assert_eq!(report.copied, 1); - 451
assert_eq!( - 452
std::fs::read_to_string(home.path().join("tasks.json")).unwrap(), - 453
"existing" - 454
); - 455
assert_eq!( - 456
std::fs::read_to_string(home.path().join("tasks.import1.json")).unwrap(), - 457
"incoming" - 458
); - 459
- 460
// A second import renames every clashing file to the next free - 461
// suffix (tasks.json and the already-restored memory note). - 462
let report2 = import_from(dest.path(), home.path(), Conflict::Rename).unwrap(); - 463
assert_eq!(report2.renamed, 2); - 464
assert!(home.path().join("tasks.import2.json").is_file()); - 465
} - 466
- 467
#[test] - 468
fn empty_home_yields_valid_empty_backup() { - 469
let home = tempdir().unwrap(); - 470
let dest = tempdir().unwrap(); - 471
let manifest = export_to(home.path(), dest.path(), false).unwrap(); - 472
assert_eq!(manifest.file_count, 0); - 473
assert_eq!(manifest.total_bytes, 0); - 474
let raw = std::fs::read_to_string(dest.path().join(MANIFEST_NAME)).unwrap(); - 475
let parsed: BackupManifest = serde_json::from_str(&raw).unwrap(); - 476
assert_eq!(parsed, manifest); - 477
} - 478
- 479
#[test] - 480
fn missing_source_directory_is_a_typed_error() { - 481
let nowhere = tempdir().unwrap().path().join("does-not-exist"); - 482
let home = tempdir().unwrap(); - 483
assert!(matches!( - 484
import_from(&nowhere, home.path(), Conflict::Skip), - 485
Err(BackupError::InvalidBackup { .. }) - 486
)); - 487
} - 488
} - 489
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.