- 1001
if !changed { - 1002
return Err(Error::InvalidPlan( - 1003
"revision did not change candidate files".into(), - 1004
)); - 1005
} - 1006
for file in &mut revision.files { - 1007
file.base_hash = parent - 1008
.files - 1009
.iter() - 1010
.find(|old| old.path == file.path) - 1011
.and_then(|old| old.base_hash.clone()); - 1012
} - 1013
Ok(()) - 1014
})(); - 1015
if let Err(error) = result { - 1016
let _ = remove_frozen_candidate(frozen_root); - 1017
return Err(error); - 1018
} - 1019
Ok(revision) - 1020
} - 1021
- 1022
/// Seed a fresh revision environment from the exact reviewed version. Only - 1023
/// manifest files are copied, and each byte stream is verified against the - 1024
/// saved candidate before it becomes writable task input. The destination - 1025
/// must not exist, so a prior run can never be silently reused. - 1026
pub fn prepare_revision_copy(candidate: &CandidateManifest, task_root: &Path) -> Result<(), Error> { - 1027
fs::create_dir(task_root)?; - 1028
let copy = (|| -> Result<(), Error> { - 1029
for file in &candidate.files { - 1030
if file.operation == CandidateOperation::Delete { - 1031
continue; - 1032
} - 1033
let source = confined(&candidate.source_root, &file.path)?; - 1034
let bytes = fs::read(&source).map_err(|_| Error::Missing(file.path.clone()))?; - 1035
if digest(&bytes) != file.candidate_hash { - 1036
return Err(Error::CandidateChanged(file.path.clone())); - 1037
} - 1038
let target = confined(task_root, &file.path)?; - 1039
if let Some(parent) = target.parent() { - 1040
fs::create_dir_all(parent)?; - 1041
} - 1042
let mut output = fs::OpenOptions::new() - 1043
.write(true) - 1044
.create_new(true) - 1045
.open(&target)?; - 1046
use std::io::Write; - 1047
output.write_all(&bytes)?; - 1048
output.sync_all()?; - 1049
} - 1050
Ok(()) - 1051
})(); - 1052
if let Err(error) = copy { - 1053
let _ = fs::remove_dir_all(task_root); - 1054
return Err(error); - 1055
} - 1056
Ok(()) - 1057
} - 1058
- 1059
/// An Office draft a revision turn delivered: `draft`, under the task copy's - 1060
/// `.vak/scratch/`, is the next version of the task file `path`. - 1061
#[derive(Debug, Clone, PartialEq, Eq)] - 1062
pub struct RevisionDraft { - 1063
pub path: String, - 1064
pub draft: String, - 1065
} - 1066
- 1067
/// Put each delivered draft in place of the task file it is a draft for, so - 1068
/// the revision's candidate is frozen from the task copy like any other - 1069
/// change. `office_apply` never writes the file it edits (it writes a draft - 1070
/// under `.vak/scratch/`, which a candidate never includes); in a revision the - 1071
/// task copy stands where the workspace stands in a conversation, and this - 1072
/// is its acceptance of the draft, made before the person reviews the new - 1073
/// version. - 1074
pub fn adopt_revision_drafts(task_root: &Path, drafts: &[RevisionDraft]) -> Result<(), Error> { - 1075
for draft in drafts { - 1076
if Path::new(&draft.path).starts_with(".vak") { - 1077
return Err(Error::PathEscape(draft.path.clone())); - 1078
} - 1079
if !Path::new(&draft.draft).starts_with(".vak/scratch") { - 1080
return Err(Error::PathEscape(draft.draft.clone())); - 1081
} - 1082
let source = confined(task_root, &draft.draft)?; - 1083
let bytes = fs::read(&source).map_err(|_| Error::Missing(draft.draft.clone()))?; - 1084
let target = confined(task_root, &draft.path)?; - 1085
if let Some(parent) = target.parent() { - 1086
fs::create_dir_all(parent)?; - 1087
} - 1088
let mut output = fs::OpenOptions::new() - 1089
.write(true) - 1090
.create(true) - 1091
.truncate(true) - 1092
.open(&target)?; - 1093
use std::io::Write; - 1094
output.write_all(&bytes)?; - 1095
output.sync_all()?; - 1096
} - 1097
Ok(()) - 1098
} - 1099
- 1100
fn write_transaction(path: &Path, transaction: &PromotionTransaction) -> Result<(), Error> { - 1101
let parent = path - 1102
.parent() - 1103
.ok_or_else(|| Error::InvalidPlan("promotion journal has no parent".into()))?; - 1104
fs::create_dir_all(parent)?; - 1105
let temporary = parent.join("journal.json.tmp"); - 1106
let bytes = serde_json::to_vec_pretty(transaction) - 1107
.map_err(|error| Error::InvalidPlan(format!("journal serialization failed: {error}")))?; - 1108
let mut file = fs::OpenOptions::new() - 1109
.create(true) - 1110
.truncate(true) - 1111
.write(true) - 1112
.open(&temporary)?; - 1113
use std::io::Write; - 1114
file.write_all(&bytes)?; - 1115
file.sync_all()?; - 1116
fs::rename(temporary, path)?; - 1117
Ok(()) - 1118
} - 1119
- 1120
fn load_transaction(path: &Path) -> Result<PromotionTransaction, Error> { - 1121
serde_json::from_slice(&fs::read(path)?) - 1122
.map_err(|error| Error::InvalidPlan(format!("journal parse failed: {error}"))) - 1123
} - 1124
- 1125
fn transaction_directory(root: &Path, candidate_id: &str) -> Result<PathBuf, Error> { - 1126
let mut components = Path::new(candidate_id).components(); - 1127
let valid = matches!(components.next(), Some(std::path::Component::Normal(_))) - 1128
&& components.next().is_none(); - 1129
if !valid { - 1130
return Err(Error::PathEscape(candidate_id.into())); - 1131
} - 1132
Ok(root.join(candidate_id)) - 1133
} - 1134
- 1135
fn transaction_receipt(transaction: &PromotionTransaction) -> PromotionReceipt { - 1136
let applied_state_digest = promotion_state_digest(transaction); - 1137
PromotionReceipt { - 1138
candidate_id: transaction.candidate_id.clone(), - 1139
applied: transaction - 1140
.files - 1141
.iter() - 1142
.map(|file| file.path.clone()) - 1143
.collect(), - 1144
before_hashes: transaction - 1145
.files - 1146
.iter() - 1147
.map(|file| (file.path.clone(), file.before_hash.clone())) - 1148
.collect(), - 1149
after_hashes: transaction - 1150
.files - 1151
.iter() - 1152
.filter(|file| file.operation == CandidateOperation::Upsert) - 1153
.map(|file| (file.path.clone(), file.after_hash.clone())) - 1154
.collect(), - 1155
verification: transaction - 1156
.files - 1157
.iter() - 1158
.map(|file| VerificationResult { - 1159
path: file.path.clone(), - 1160
status: "observed".into(), - 1161
evidence: if file.operation == CandidateOperation::Delete { - 1162
"destination absence verified".into() - 1163
} else { - 1164
format!("destination hash verified: {}", file.after_hash) - 1165
}, - 1166
}) - 1167
.collect(), - 1168
deleted: transaction - 1169
.files - 1170
.iter() - 1171
.filter(|file| file.operation == CandidateOperation::Delete) - 1172
.map(|file| file.path.clone()) - 1173
.collect(), - 1174
integration: IntegrationVerification { - 1175
applied_state_digest, - 1176
workspace_state_status: "observed".into(), - 1177
target_checks_status: "unavailable".into(), - 1178
evidence: "accepted files and deletions were read back from the target workspace; no registered target verifier ran".into(), - 1179
target_checks: Vec::new(), - 1180
}, - 1181
} - 1182
} - 1183
- 1184
fn promotion_state_digest(transaction: &PromotionTransaction) -> String { - 1185
let mut state = format!("candidate:{}\n", transaction.candidate_digest); - 1186
for file in &transaction.files { - 1187
let observed = if file.operation == CandidateOperation::Delete { - 1188
"absent" - 1189
} else { - 1190
file.after_hash.as_str() - 1191
}; - 1192
state.push_str(&file.path); - 1193
state.push('\t'); - 1194
state.push_str(observed); - 1195
state.push('\n'); - 1196
} - 1197
digest(state.as_bytes()) - 1198
} - 1199
- 1200
fn rollback_transaction( - 1201
transaction: &mut PromotionTransaction, - 1202
journal_path: &Path, - 1203
) -> Result<(), Error> { - 1204
transaction.state = PromotionTransactionState::RecoveryRequired; - 1205
write_transaction(journal_path, transaction)?; - 1206
for index in (0..transaction.files.len()).rev() { - 1207
if transaction.files[index].state == PromotionFileState::Prepared { - 1208
continue; - 1209
} - 1210
let file = transaction.files[index].clone(); - 1211
let target = confined(&transaction.destination_root, &file.path)?; - 1212
let current = match fs::read(&target) { - 1213
Ok(bytes) => Some(digest(&bytes)), - 1214
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - 1215
Err(error) => return Err(Error::Io(error)), - 1216
}; - 1217
if current == file.before_hash && file.state == PromotionFileState::Applying { - 1218
transaction.files[index].state = PromotionFileState::Prepared; - 1219
write_transaction(journal_path, transaction)?; - 1220
continue; - 1221
} - 1222
let expected_after = - 1223
(file.operation == CandidateOperation::Upsert).then_some(file.after_hash.as_str()); - 1224
if current.as_deref() != expected_after { - 1225
return Err(Error::Conflict(format!( - 1226
"promotion recovery blocked by a later workspace change: {}", - 1227
file.path - 1228
))); - 1229
} - 1230
if let Some(backup) = &file.backup_path { - 1231
let bytes = fs::read(backup)?; - 1232
if digest(&bytes) != file.before_hash.clone().unwrap_or_default() { - 1233
return Err(Error::CandidateChanged(format!( - 1234
"promotion backup changed: {}", - 1235
file.path - 1236
))); - 1237
} - 1238
let temporary = - 1239
target.with_extension(format!("vak-recovery-{}", transaction.candidate_id)); - 1240
fs::write(&temporary, bytes)?; - 1241
fs::rename(temporary, target)?; - 1242
} else { - 1243
fs::remove_file(target)?; - 1244
} - 1245
transaction.files[index].state = PromotionFileState::Prepared; - 1246
write_transaction(journal_path, transaction)?; - 1247
} - 1248
transaction.state = PromotionTransactionState::RolledBack; - 1249
write_transaction(journal_path, transaction) - 1250
} - 1251
- 1252
/// Import a reviewed candidate under a cross-process workspace lock. Before - 1253
/// images and per-file progress are persisted before the first destination - 1254
/// rename. An interrupted prior attempt is rolled back before a retry, while - 1255
/// a completed journal is idempotently returned for durable-record recovery. - 1256
pub fn promote_recoverable( - 1257
candidate: &CandidateManifest, - 1258
transaction_root: &Path, - 1259
) -> Result<PromotionReceipt, Error> { - 1260
fs::create_dir_all(transaction_root)?; - 1261
let lock_destination = candidate - 1262
.destination_root - 1263
.canonicalize() - 1264
.map_err(Error::Io)?; - 1265
let workspace_key = digest(lock_destination.to_string_lossy().as_bytes()).replace(':', "_"); - 1266
let lock_path = transaction_root.join(format!("{workspace_key}.lock")); - 1267
let lock_file = fs::OpenOptions::new() - 1268
.create(true) - 1269
.truncate(false) - 1270
.read(true) - 1271
.write(true) - 1272
.open(&lock_path) - 1273
.map_err(Error::Io)?; - 1274
lock_file.try_lock().map_err(|error| match error { - 1275
std::fs::TryLockError::WouldBlock => { - 1276
Error::Conflict("another workspace acceptance is in progress".into()) - 1277
} - 1278
std::fs::TryLockError::Error(error) => Error::Io(error), - 1279
})?; - 1280
let directory = transaction_directory(transaction_root, &candidate.candidate_id)?; - 1281
let journal_path = directory.join("journal.json"); - 1282
let selected_digest = candidate_digest(candidate)?; - 1283
if journal_path.exists() { - 1284
let mut previous = load_transaction(&journal_path)?; - 1285
if previous.candidate_id != candidate.candidate_id - 1286
|| previous.candidate_digest != selected_digest - 1287
|| previous.destination_root != candidate.destination_root - 1288
{ - 1289
return Err(Error::InvalidPlan( - 1290
"promotion journal identity mismatch".into(), - 1291
)); - 1292
} - 1293
if previous.state == PromotionTransactionState::Completed { - 1294
for file in &previous.files { - 1295
let target = confined(&previous.destination_root, &file.path)?; - 1296
let observed = fs::read(&target).ok().map(|bytes| digest(&bytes)); - 1297
let expected = (file.operation == CandidateOperation::Upsert) - 1298
.then_some(file.after_hash.as_str()); - 1299
if observed.as_deref() != expected { - 1300
return Err(Error::Conflict(format!( - 1301
"completed promotion no longer matches the workspace: {}", - 1302
file.path - 1303
))); - 1304
} - 1305
} - 1306
return Ok(transaction_receipt(&previous)); - 1307
} - 1308
if matches!( - 1309
previous.state, - 1310
PromotionTransactionState::Undoing | PromotionTransactionState::Undone - 1311
) { - 1312
return Err(Error::Conflict( - 1313
"candidate acceptance has already been undone".into(), - 1314
)); - 1315
} - 1316
if previous - 1317
.files - 1318
.iter() - 1319
.any(|file| file.state != PromotionFileState::Prepared) - 1320
{ - 1321
rollback_transaction(&mut previous, &journal_path)?; - 1322
} - 1323
fs::remove_dir_all(&directory)?; - 1324
} else if directory.exists() { - 1325
// A process can stop during preflight before the first journal write. - 1326
// No destination rename is possible at that point, so the orphaned - 1327
// backup staging directory is safe to discard before retry. - 1328
fs::remove_dir_all(&directory)?; - 1329
} - 1330
fs::create_dir_all(directory.join("backups"))?; - 1331
let mut staged = Vec::new(); - 1332
let mut files = Vec::new(); - 1333
for (index, file) in candidate.files.iter().enumerate() { - 1334
let bytes = if file.operation == CandidateOperation::Delete { - 1335
Vec::new() - 1336
} else { - 1337
let source = confined(&candidate.source_root, &file.path)?; - 1338
let bytes = fs::read(&source).map_err(|_| Error::Missing(file.path.clone()))?; - 1339
if digest(&bytes) != file.candidate_hash { - 1340
return Err(Error::CandidateChanged(file.path.clone())); - 1341
} - 1342
bytes - 1343
}; - 1344
let proposed_target = candidate.destination_root.join(&file.path); - 1345
let mut parent = proposed_target.parent(); - 1346
while let Some(path) = parent { - 1347
if path == candidate.destination_root { - 1348
break; - 1349
} - 1350
match fs::metadata(path) { - 1351
Ok(metadata) if !metadata.is_dir() => { - 1352
return Err(Error::Conflict(file.path.clone())); - 1353
} - 1354
Ok(_) => {} - 1355
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - 1356
Err(error) => return Err(Error::Io(error)), - 1357
} - 1358
parent = path.parent(); - 1359
} - 1360
let target = confined(&candidate.destination_root, &file.path)?; - 1361
let before_bytes = match fs::metadata(&target) { - 1362
Ok(metadata) if metadata.is_file() => Some(fs::read(&target)?), - 1363
Ok(_) => return Err(Error::Conflict(file.path.clone())), - 1364
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - 1365
Err(error) => return Err(Error::Io(error)), - 1366
}; - 1367
let before_hash = before_bytes.as_deref().map(digest); - 1368
if before_hash != file.base_hash { - 1369
return Err(Error::Conflict(file.path.clone())); - 1370
} - 1371
let backup_path = if let Some(before) = before_bytes { - 1372
let path = directory.join("backups").join(index.to_string()); - 1373
let mut backup = fs::OpenOptions::new() - 1374
.write(true) - 1375
.create_new(true) - 1376
.open(&path)?; - 1377
use std::io::Write; - 1378
backup.write_all(&before)?; - 1379
backup.sync_all()?; - 1380
Some(path) - 1381
} else { - 1382
None - 1383
}; - 1384
files.push(PromotionTransactionFile { - 1385
path: file.path.clone(), - 1386
before_hash, - 1387
after_hash: file.candidate_hash.clone(), - 1388
backup_path, - 1389
state: PromotionFileState::Prepared, - 1390
operation: file.operation.clone(), - 1391
}); - 1392
staged.push((target, bytes)); - 1393
} - 1394
let mut transaction = PromotionTransaction { - 1395
schema_version: 1, - 1396
candidate_id: candidate.candidate_id.clone(), - 1397
candidate_digest: selected_digest, - 1398
destination_root: candidate.destination_root.clone(), - 1399
state: PromotionTransactionState::Prepared, - 1400
files, - 1401
}; - 1402
write_transaction(&journal_path, &transaction)?; - 1403
transaction.state = PromotionTransactionState::Applying; - 1404
write_transaction(&journal_path, &transaction)?; - 1405
for (index, (target, bytes)) in staged.into_iter().enumerate() { - 1406
let temporary = target.with_extension(format!("vak-promotion-{}", candidate.candidate_id)); - 1407
let result = (|| -> Result<(), Error> { - 1408
if let Some(parent) = target.parent() { - 1409
fs::create_dir_all(parent)?; - 1410
} - 1411
transaction.files[index].state = PromotionFileState::Applying; - 1412
write_transaction(&journal_path, &transaction)?; - 1413
if transaction.files[index].operation == CandidateOperation::Delete { - 1414
fs::remove_file(&target)?; - 1415
} else { - 1416
let mut output = fs::OpenOptions::new() - 1417
.create(true) - 1418
.truncate(true) - 1419
.write(true) - 1420
.open(&temporary)?; - 1421
use std::io::Write; - 1422
output.write_all(&bytes)?; - 1423
output.sync_all()?; - 1424
fs::rename(&temporary, &target)?; - 1425
} - 1426
transaction.files[index].state = PromotionFileState::Applied; - 1427
write_transaction(&journal_path, &transaction)?; - 1428
let observed = fs::read(&target).ok().map(|bytes| digest(&bytes)); - 1429
let expected = (transaction.files[index].operation == CandidateOperation::Upsert) - 1430
.then_some(transaction.files[index].after_hash.as_str()); - 1431
if observed.as_deref() != expected { - 1432
return Err(Error::Conflict(format!( - 1433
"post-apply verification failed: {}", - 1434
transaction.files[index].path - 1435
))); - 1436
} - 1437
Ok(()) - 1438
})(); - 1439
if let Err(error) = result { - 1440
let _ = fs::remove_file(&temporary); - 1441
rollback_transaction(&mut transaction, &journal_path)?; - 1442
return Err(error); - 1443
} - 1444
} - 1445
transaction.state = PromotionTransactionState::Completed; - 1446
write_transaction(&journal_path, &transaction)?; - 1447
Ok(transaction_receipt(&transaction)) - 1448
} - 1449
- 1450
/// Reverse one completed promotion while it still owns the destination bytes. - 1451
/// Undo is itself journaled and resumes after a crash. Any later workspace - 1452
/// edit blocks the operation instead of being erased. - 1453
pub fn undo_promotion(candidate_id: &str, transaction_root: &Path) -> Result<UndoReceipt, Error> { - 1454
let directory = transaction_directory(transaction_root, candidate_id)?; - 1455
let journal_path = directory.join("journal.json"); - 1456
let mut transaction = load_transaction(&journal_path)?; - 1457
if transaction.candidate_id != candidate_id { - 1458
return Err(Error::InvalidPlan( - 1459
"promotion journal identity mismatch".into(), - 1460
)); - 1461
} - 1462
fs::create_dir_all(transaction_root)?; - 1463
let lock_destination = transaction - 1464
.destination_root - 1465
.canonicalize() - 1466
.map_err(Error::Io)?; - 1467
let workspace_key = digest(lock_destination.to_string_lossy().as_bytes()).replace(':', "_"); - 1468
let lock_file = fs::OpenOptions::new() - 1469
.create(true) - 1470
.truncate(false) - 1471
.read(true) - 1472
.write(true) - 1473
.open(transaction_root.join(format!("{workspace_key}.lock")))?; - 1474
lock_file.try_lock().map_err(|error| match error { - 1475
std::fs::TryLockError::WouldBlock => { - 1476
Error::Conflict("another workspace acceptance is in progress".into()) - 1477
} - 1478
std::fs::TryLockError::Error(error) => Error::Io(error), - 1479
})?; - 1480
transaction = load_transaction(&journal_path)?; - 1481
if transaction.candidate_id != candidate_id { - 1482
return Err(Error::InvalidPlan( - 1483
"promotion journal identity mismatch".into(), - 1484
)); - 1485
} - 1486
if transaction.state == PromotionTransactionState::Undone { - 1487
let verification = transaction - 1488
.files - 1489
.iter() - 1490
.map(|file| VerificationResult { - 1491
path: file.path.clone(), - 1492
status: "observed".into(), - 1493
evidence: match &file.before_hash { - 1494
Some(hash) => format!("restored destination hash verified: {hash}"), - 1495
None => "new destination file removed".into(), - 1496
}, - 1497
}) - 1498
.collect(); - 1499
return Ok(UndoReceipt { - 1500
candidate_id: candidate_id.into(), - 1501
restored: transaction - 1502
.files - 1503
.iter() - 1504
.map(|file| file.path.clone()) - 1505
.collect(), - 1506
verification, - 1507
}); - 1508
} - 1509
if !matches!( - 1510
transaction.state, - 1511
PromotionTransactionState::Completed | PromotionTransactionState::Undoing - 1512
) { - 1513
return Err(Error::Conflict( - 1514
"candidate acceptance is not complete and cannot be undone".into(), - 1515
)); - 1516
} - 1517
transaction.state = PromotionTransactionState::Undoing; - 1518
write_transaction(&journal_path, &transaction)?; - 1519
for index in (0..transaction.files.len()).rev() { - 1520
let file = transaction.files[index].clone(); - 1521
let target = confined(&transaction.destination_root, &file.path)?; - 1522
let current = match fs::read(&target) { - 1523
Ok(bytes) => Some(digest(&bytes)), - 1524
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - 1525
Err(error) => return Err(Error::Io(error)), - 1526
}; - 1527
if file.state == PromotionFileState::Undone { - 1528
if current != file.before_hash { - 1529
return Err(Error::Conflict(format!( - 1530
"undo recovery blocked by a later workspace change: {}", - 1531
file.path - 1532
))); - 1533
} - 1534
continue; - 1535
} - 1536
if file.state == PromotionFileState::Undoing && current == file.before_hash { - 1537
transaction.files[index].state = PromotionFileState::Undone; - 1538
write_transaction(&journal_path, &transaction)?; - 1539
continue; - 1540
} - 1541
let expected_after = - 1542
(file.operation == CandidateOperation::Upsert).then_some(file.after_hash.as_str()); - 1543
if current.as_deref() != expected_after { - 1544
return Err(Error::Conflict(format!( - 1545
"undo blocked by a later workspace change: {}", - 1546
file.path - 1547
))); - 1548
} - 1549
transaction.files[index].state = PromotionFileState::Undoing; - 1550
write_transaction(&journal_path, &transaction)?; - 1551
if let Some(backup) = &file.backup_path { - 1552
let bytes = fs::read(backup)?; - 1553
if Some(digest(&bytes)) != file.before_hash { - 1554
return Err(Error::CandidateChanged(format!( - 1555
"promotion backup changed: {}", - 1556
file.path - 1557
))); - 1558
} - 1559
let temporary = target.with_extension(format!("vak-undo-{candidate_id}")); - 1560
fs::write(&temporary, bytes)?; - 1561
fs::rename(temporary, &target)?; - 1562
} else { - 1563
fs::remove_file(&target)?; - 1564
} - 1565
let observed = fs::read(&target).ok().map(|bytes| digest(&bytes)); - 1566
if observed != file.before_hash { - 1567
return Err(Error::Conflict(format!( - 1568
"post-undo verification failed: {}", - 1569
file.path - 1570
))); - 1571
} - 1572
transaction.files[index].state = PromotionFileState::Undone; - 1573
write_transaction(&journal_path, &transaction)?; - 1574
} - 1575
transaction.state = PromotionTransactionState::Undone; - 1576
write_transaction(&journal_path, &transaction)?; - 1577
Ok(UndoReceipt { - 1578
candidate_id: candidate_id.into(), - 1579
restored: transaction - 1580
.files - 1581
.iter() - 1582
.map(|file| file.path.clone()) - 1583
.collect(), - 1584
verification: transaction - 1585
.files - 1586
.iter() - 1587
.map(|file| VerificationResult { - 1588
path: file.path.clone(), - 1589
status: "observed".into(), - 1590
evidence: match &file.before_hash { - 1591
Some(hash) => format!("restored destination hash verified: {hash}"), - 1592
None => "new destination file removed".into(), - 1593
}, - 1594
}) - 1595
.collect(), - 1596
}) - 1597
} - 1598
- 1599
#[cfg(test)] - 1600
mod tests { - 1601
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 1602
- 1603
use super::*; - 1604
use lopdf::dictionary; - 1605
- 1606
fn promote(candidate: &CandidateManifest) -> Result<PromotionReceipt, Error> { - 1607
let control = tempfile::tempdir()?; - 1608
promote_recoverable(candidate, control.path()) - 1609
} - 1610
- 1611
#[test] - 1612
fn promotion_is_compare_before_write() { - 1613
let source = tempfile::tempdir().unwrap(); - 1614
let target = tempfile::tempdir().unwrap(); - 1615
fs::write(source.path().join("result.txt"), "draft").unwrap(); - 1616
let candidate = candidate_manifest("c1", source.path(), target.path()).unwrap(); - 1617
fs::write(target.path().join("result.txt"), "user-edit").unwrap(); - 1618
assert!(matches!(promote(&candidate), Err(Error::Conflict(path)) if path == "result.txt")); - 1619
assert_eq!( - 1620
fs::read_to_string(target.path().join("result.txt")).unwrap(), - 1621
"user-edit" - 1622
); - 1623
} - 1624
- 1625
#[test] - 1626
fn registered_target_verifiers_plan_and_check_applied_files() { - 1627
let target = tempfile::tempdir().unwrap(); - 1628
fs::write(target.path().join("result.json"), br#"{"ready":true}"#).unwrap(); - 1629
fs::write(target.path().join("preview.png"), b"not a png").unwrap(); - 1630
let candidate = CandidateManifest { - 1631
candidate_id: "formats".into(), - 1632
source_root: target.path().into(), - 1633
destination_root: target.path().into(), - 1634
files: vec![ - 1635
CandidateFile { - 1636
path: "preview.png".into(), - 1637
candidate_hash: digest(b"not a png"), - 1638
base_hash: None, - 1639
bytes: 9, - 1640
operation: CandidateOperation::Upsert, - 1641
}, - 1642
CandidateFile { - 1643
path: "result.json".into(), - 1644
candidate_hash: digest(br#"{"ready":true}"#), - 1645
base_hash: None, - 1646
bytes: 14, - 1647
operation: CandidateOperation::Upsert, - 1648
}, - 1649
], - 1650
target_checks: Vec::new(), - 1651
workspace_checks: Vec::new(), - 1652
}; - 1653
let registry = default_target_verifiers(); - 1654
let plan = registry.plan(&candidate); - 1655
assert_eq!(plan.len(), 2); - 1656
let results = registry.verify(target.path(), &plan); - 1657
assert!( - 1658
results - 1659
.iter() - 1660
.any(|result| { result.path == "result.json" && result.status == "passed" }) - 1661
); - 1662
assert!( - 1663
results - 1664
.iter() - 1665
.any(|result| { result.path == "preview.png" && result.status == "failed" }) - 1666
); - 1667
} - 1668
- 1669
#[test] - 1670
fn openxml_verifier_covers_the_family_and_refuses_disguises() { - 1671
use vak_ooxml::fixtures; - 1672
- 1673
let target = tempfile::tempdir().unwrap(); - 1674
let verifier = OpenXmlPackageVerifier; - 1675
for (name, bytes, expected) in [ - 1676
("report.docx", fixtures::docx(), "Word document (.docx"), - 1677
("book.xlsx", fixtures::xlsx(), "2 sheets"), - 1678
("deck.pptx", fixtures::pptx(), "2 slides"), - 1679
("flow.vsdx", fixtures::vsdx(), "1 pages"), - 1680
] { - 1681
let path = target.path().join(name); - 1682
fs::write(&path, bytes).unwrap(); - 1683
assert!(verifier.supports(name)); - 1684
let evidence = verifier.verify(&path).unwrap(); - 1685
assert!(evidence.contains(expected), "{evidence}"); - 1686
assert!( - 1687
evidence.contains("rendering were not checked"), - 1688
"{evidence}" - 1689
); - 1690
} - 1691
let flagged = verifier.verify(&target.path().join("report.docx")).unwrap(); - 1692
assert!(flagged.contains("1 risky fields"), "{flagged}"); - 1693
- 1694
let renamed = target.path().join("invoice.docx"); - 1695
fs::write( - 1696
&renamed, - 1697
fixtures::word_with( - 1698
"application/vnd.ms-word.document.macroEnabled.main+xml", - 1699
fixtures::MINIMAL_WORD_BODY, - 1700
&[], - 1701
&[], - 1702
&[], - 1703
&[], - 1704
), - 1705
) - 1706
.unwrap(); - 1707
let error = verifier.verify(&renamed).unwrap_err(); - 1708
assert!(error.contains("(.docm) but is named .docx"), "{error}"); - 1709
- 1710
let wrong_root = target.path().join("wrong-root.docx"); - 1711
fs::write( - 1712
&wrong_root, - 1713
fixtures::word_with(fixtures::WORD_MAIN, "<workbook/>", &[], &[], &[], &[]), - 1714
) - 1715
.unwrap(); - 1716
assert!(verifier.verify(&wrong_root).is_err()); - 1717
- 1718
let broken = target.path().join("broken.docx"); - 1719
fs::write(&broken, b"not a package").unwrap(); - 1720
assert!(verifier.verify(&broken).is_err()); - 1721
assert!(verifier.supports("macro.xlsm") && verifier.supports("stencil.VSSX")); - 1722
assert!(!verifier.supports("legacy.doc") && !verifier.supports("binary.xlsb")); - 1723
} - 1724
- 1725
#[test] - 1726
fn image_verifier_decodes_pixels_instead_of_trusting_the_header() { - 1727
let target = tempfile::tempdir().unwrap(); - 1728
let valid_path = target.path().join("preview.png"); - 1729
let corrupt_path = target.path().join("corrupt.png"); - 1730
let image = image::RgbImage::from_pixel(3, 2, image::Rgb([12, 34, 56])); - 1731
image.save(&valid_path).unwrap(); - 1732
fs::write(&corrupt_path, b"\x89PNG\r\n\x1a\ncorrupt body").unwrap(); - 1733
- 1734
let evidence = ImageDecodeVerifier.verify(&valid_path).unwrap(); - 1735
assert!(evidence.contains("3×2")); - 1736
assert!(ImageDecodeVerifier.verify(&corrupt_path).is_err()); - 1737
} - 1738
- 1739
#[test] - 1740
fn pdf_structure_verifier_requires_a_parseable_page_tree() { - 1741
let target = tempfile::tempdir().unwrap(); - 1742
let complete = target.path().join("complete.pdf"); - 1743
let truncated = target.path().join("truncated.pdf"); - 1744
let mut document = lopdf::Document::with_version("1.7"); - 1745
let pages_id = document.new_object_id(); - 1746
let page_id = document.add_object(lopdf::dictionary! { - 1747
"Type" => "Page", - 1748
"Parent" => pages_id, - 1749
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], - 1750
}); - 1751
document.objects.insert( - 1752
pages_id, - 1753
lopdf::Object::Dictionary(lopdf::dictionary! { - 1754
"Type" => "Pages", - 1755
"Kids" => vec![page_id.into()], - 1756
"Count" => 1, - 1757
}), - 1758
); - 1759
let catalog_id = document.add_object(lopdf::dictionary! { - 1760
"Type" => "Catalog", - 1761
"Pages" => pages_id, - 1762
}); - 1763
document.trailer.set("Root", catalog_id); - 1764
document.save(&complete).unwrap(); - 1765
fs::write(&truncated, b"%PDF-1.7\nnot a document\n%%EOF\n").unwrap(); - 1766
- 1767
let evidence = PdfStructureVerifier.verify(&complete).unwrap(); - 1768
assert!(evidence.contains("1 page(s)")); - 1769
assert!(PdfStructureVerifier.verify(&truncated).is_err()); - 1770
} - 1771
- 1772
#[test] - 1773
fn data_and_svg_verifiers_reject_structural_errors() { - 1774
let target = tempfile::tempdir().unwrap(); - 1775
let csv = target.path().join("table.csv"); - 1776
let broken_csv = target.path().join("broken.csv"); - 1777
let svg = target.path().join("figure.svg"); - 1778
let broken_svg = target.path().join("broken.svg"); - 1779
fs::write(&csv, "name,value\nalpha,1\nbeta,2\n").unwrap(); - 1780
fs::write(&broken_csv, "name,value\nalpha\n").unwrap(); - 1781
fs::write( - 1782
&svg, - 1783
r#"<svg xmlns="http://www.w3.org/2000/svg"><circle r="2"/></svg>"#, - 1784
) - 1785
.unwrap(); - 1786
fs::write(&broken_svg, "<html></html>").unwrap(); - 1787
- 1788
assert!(DelimitedDataVerifier.verify(&csv).is_ok()); - 1789
assert!(DelimitedDataVerifier.verify(&broken_csv).is_err()); - 1790
assert!(SvgStructureVerifier.verify(&svg).is_ok()); - 1791
assert!(SvgStructureVerifier.verify(&broken_svg).is_err()); - 1792
} - 1793
- 1794
#[test] - 1795
fn html_target_check_catches_a_page_swallowed_by_unclosed_title() { - 1796
let target = tempfile::tempdir().unwrap(); - 1797
let valid = target.path().join("working.html"); - 1798
let script_root = target.path().join("script-root.htm"); - 1799
let swallowed = target.path().join("blank.html"); - 1800
fs::write(&valid, "<!doctype html><html><head><title>Draft</title></head><body><main>Ready</main></body></html>").unwrap(); - 1801
fs::write(&script_root, "<!doctype html><html><body><script>document.body.textContent = 'Ready'</script></body></html>").unwrap(); - 1802
fs::write(&swallowed, "<!doctype html><html><head><title>Draft</head><body><main>Missing</main></body></html>").unwrap(); - 1803
- 1804
let candidate = CandidateManifest { - 1805
candidate_id: "html-check".into(), - 1806
source_root: target.path().into(), - 1807
destination_root: target.path().into(), - 1808
files: ["working.html", "script-root.htm", "blank.html"] - 1809
.into_iter() - 1810
.map(|path| CandidateFile { - 1811
path: path.into(), - 1812
candidate_hash: String::new(), - 1813
base_hash: None, - 1814
bytes: 0, - 1815
operation: CandidateOperation::Upsert, - 1816
}) - 1817
.collect(), - 1818
target_checks: Vec::new(), - 1819
workspace_checks: Vec::new(), - 1820
}; - 1821
let registry = default_target_verifiers(); - 1822
let checks = registry.plan(&candidate); - 1823
assert_eq!(checks.len(), 3); - 1824
let results = registry.verify(target.path(), &checks); - 1825
assert_eq!( - 1826
results - 1827
.iter() - 1828
.map(|result| result.status.as_str()) - 1829
.collect::<Vec<_>>(), - 1830
vec!["passed", "passed", "failed"] - 1831
); - 1832
assert!(results[2].evidence.contains("unclosed <title>")); - 1833
} - 1834
#[test] - 1835
fn nested_artifacts_are_first_class() { - 1836
let source = tempfile::tempdir().unwrap(); - 1837
let target = tempfile::tempdir().unwrap(); - 1838
fs::create_dir_all(source.path().join("assets")).unwrap(); - 1839
fs::write(source.path().join("assets/chart.csv"), "x,y\n1,2\n").unwrap(); - 1840
let candidate = candidate_manifest("c2", source.path(), target.path()).unwrap(); - 1841
let receipt = promote(&candidate).unwrap(); - 1842
assert_eq!(receipt.applied, vec!["assets/chart.csv"]); - 1843
assert_eq!(receipt.verification[0].status, "observed"); - 1844
assert_eq!(receipt.integration.workspace_state_status, "observed"); - 1845
assert_eq!(receipt.integration.target_checks_status, "unavailable"); - 1846
assert!( - 1847
receipt - 1848
.integration - 1849
.applied_state_digest - 1850
.starts_with("sha256:") - 1851
); - 1852
} - 1853
- 1854
#[test] - 1855
fn promotion_preflights_later_destination_types_before_writing() { - 1856
let source = tempfile::tempdir().unwrap(); - 1857
let target = tempfile::tempdir().unwrap(); - 1858
fs::write(source.path().join("a.txt"), "ready").unwrap(); - 1859
fs::write(source.path().join("z.txt"), "blocked").unwrap(); - 1860
let candidate = candidate_manifest("c3", source.path(), target.path()).unwrap(); - 1861
fs::create_dir(target.path().join("z.txt")).unwrap(); - 1862
- 1863
assert!(matches!(promote(&candidate), Err(Error::Conflict(path)) if path == "z.txt")); - 1864
assert!(!target.path().join("a.txt").exists()); - 1865
} - 1866
- 1867
#[test] - 1868
fn promotion_preflights_later_parent_collisions_before_writing() { - 1869
let source = tempfile::tempdir().unwrap(); - 1870
let target = tempfile::tempdir().unwrap(); - 1871
fs::write(source.path().join("a.txt"), "ready").unwrap(); - 1872
fs::create_dir(source.path().join("nested")).unwrap(); - 1873
fs::write(source.path().join("nested/z.txt"), "blocked").unwrap(); - 1874
let candidate = candidate_manifest("c4", source.path(), target.path()).unwrap(); - 1875
fs::write(target.path().join("nested"), "user file").unwrap(); - 1876
- 1877
assert!( - 1878
matches!(promote(&candidate), Err(Error::Conflict(path)) if path == "nested/z.txt") - 1879
); - 1880
assert!(!target.path().join("a.txt").exists()); - 1881
} - 1882
- 1883
#[test] - 1884
fn frozen_candidate_keeps_reviewed_bytes_after_scratch_changes() { - 1885
let source = tempfile::tempdir().unwrap(); - 1886
let target = tempfile::tempdir().unwrap(); - 1887
let store = tempfile::tempdir().unwrap(); - 1888
fs::write(source.path().join("result.txt"), "reviewed").unwrap(); - 1889
let frozen = store.path().join("candidate-1"); - 1890
let candidate = - 1891
freeze_candidate("candidate-1", source.path(), target.path(), &frozen).unwrap(); - 1892
fs::write(source.path().join("result.txt"), "later agent work").unwrap(); - 1893
- 1894
assert_eq!(candidate.source_root, frozen); - 1895
assert_eq!( - 1896
fs::read_to_string(frozen.join("result.txt")).unwrap(), - 1897
"reviewed" - 1898
); - 1899
let receipt = promote(&candidate).unwrap(); - 1900
assert_eq!(receipt.applied, vec!["result.txt"]); - 1901
assert_eq!( - 1902
fs::read_to_string(target.path().join("result.txt")).unwrap(), - 1903
"reviewed" - 1904
); - 1905
} - 1906
- 1907
#[test] - 1908
fn server_owned_cleanup_removes_a_protected_candidate_tree() { - 1909
let source = tempfile::tempdir().unwrap(); - 1910
let target = tempfile::tempdir().unwrap(); - 1911
let store = tempfile::tempdir().unwrap(); - 1912
fs::create_dir(source.path().join("nested")).unwrap(); - 1913
fs::write(source.path().join("nested/result.txt"), "reviewed").unwrap(); - 1914
let frozen = store.path().join("candidate-cleanup"); - 1915
freeze_candidate("candidate-cleanup", source.path(), target.path(), &frozen).unwrap(); - 1916
- 1917
assert!(fs::write(frozen.join("nested/extra.txt"), "unreviewed").is_err()); - 1918
remove_frozen_candidate(&frozen).unwrap(); - 1919
assert!(!frozen.exists()); - 1920
remove_frozen_candidate(&frozen).unwrap(); - 1921
} - 1922
- 1923
#[test] - 1924
fn revision_copy_uses_only_verified_saved_version() { - 1925
let source = tempfile::tempdir().unwrap(); - 1926
let target = tempfile::tempdir().unwrap(); - 1927
let store = tempfile::tempdir().unwrap(); - 1928
fs::create_dir(source.path().join("pages")).unwrap(); - 1929
fs::write(source.path().join("pages/index.html"), "version one").unwrap(); - 1930
let saved = freeze_candidate( - 1931
"version-one", - 1932
source.path(), - 1933
target.path(), - 1934
&store.path().join("saved"), - 1935
) - 1936
.unwrap(); - 1937
fs::write(source.path().join("pages/index.html"), "unreviewed change").unwrap(); - 1938
assert!( - 1939
fs::write( - 1940
saved.source_root.join("unlisted.txt"), - 1941
"must not enter copy", - 1942
) - 1943
.is_err() - 1944
); - 1945
let copy = store.path().join("revision-copy"); - 1946
prepare_revision_copy(&saved, ©).unwrap(); - 1947
assert_eq!( - 1948
fs::read_to_string(copy.join("pages/index.html")).unwrap(), - 1949
"version one" - 1950
); - 1951
assert!(!copy.join("unlisted.txt").exists()); - 1952
- 1953
let saved_file = saved.source_root.join("pages/index.html"); - 1954
let mut permissions = fs::metadata(&saved_file).unwrap().permissions(); - 1955
#[cfg(unix)] - 1956
{ - 1957
use std::os::unix::fs::PermissionsExt; - 1958
permissions.set_mode(permissions.mode() | 0o200); - 1959
} - 1960
#[cfg(not(unix))] - 1961
permissions.set_readonly(false); - 1962
fs::set_permissions(&saved_file, permissions).unwrap(); - 1963
fs::write(&saved_file, "tampered").unwrap(); - 1964
let failed_copy = store.path().join("failed-copy"); - 1965
assert!(matches!( - 1966
prepare_revision_copy(&saved, &failed_copy), - 1967
Err(Error::CandidateChanged(path)) if path == "pages/index.html" - 1968
)); - 1969
assert!(!failed_copy.exists()); - 1970
} - 1971
- 1972
#[test] - 1973
fn adopted_office_draft_becomes_the_next_version() { - 1974
let source = tempfile::tempdir().unwrap(); - 1975
let workspace = tempfile::tempdir().unwrap(); - 1976
let store = tempfile::tempdir().unwrap(); - 1977
fs::write(workspace.path().join("letter.docx"), "original").unwrap(); - 1978
fs::write(source.path().join("letter.docx"), "version one").unwrap(); - 1979
let first = freeze_candidate( - 1980
"v1", - 1981
source.path(), - 1982
workspace.path(), - 1983
&store.path().join("v1"), - 1984
) - 1985
.unwrap(); - 1986
let task = store.path().join("task"); - 1987
prepare_revision_copy(&first, &task).unwrap(); - 1988
let drafts = task.join(".vak/scratch/vak/call-2"); - 1989
fs::create_dir_all(&drafts).unwrap(); - 1990
fs::write(drafts.join("letter.docx"), "version two").unwrap(); - 1991
- 1992
// Left in scratch, the draft is not a change: this is the failure. - 1993
assert!(matches!( - 1994
freeze_revision_candidate("unchanged", &task, &first, &store.path().join("x")), - 1995
Err(Error::InvalidPlan(reason)) if reason == "revision did not change candidate files" - 1996
)); - 1997
- 1998
let adopted = RevisionDraft { - 1999
path: "letter.docx".into(), - 2000
draft: ".vak/scratch/vak/call-2/letter.docx".into(),
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.