- 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(), - 2001
}; - 2002
adopt_revision_drafts(&task, std::slice::from_ref(&adopted)).unwrap(); - 2003
let second = - 2004
freeze_revision_candidate("v2", &task, &first, &store.path().join("v2")).unwrap(); - 2005
assert_eq!(second.files.len(), 1); - 2006
assert_eq!(second.files[0].path, "letter.docx"); - 2007
assert_eq!(second.files[0].base_hash, first.files[0].base_hash); - 2008
assert_eq!( - 2009
fs::read_to_string(second.source_root.join("letter.docx")).unwrap(), - 2010
"version two" - 2011
); - 2012
- 2013
for refused in [ - 2014
RevisionDraft { - 2015
path: ".vak/config.toml".into(), - 2016
..adopted.clone() - 2017
}, - 2018
RevisionDraft { - 2019
draft: "letter.docx".into(), - 2020
..adopted.clone() - 2021
}, - 2022
RevisionDraft { - 2023
draft: ".vak/scratch/../../letter.docx".into(), - 2024
..adopted.clone() - 2025
}, - 2026
] { - 2027
assert!(matches!( - 2028
adopt_revision_drafts(&task, &[refused]), - 2029
Err(Error::PathEscape(_)) - 2030
)); - 2031
} - 2032
} - 2033
- 2034
#[test] - 2035
fn revised_candidate_keeps_original_baseline_and_excludes_control_files() { - 2036
let source = tempfile::tempdir().unwrap(); - 2037
let workspace = tempfile::tempdir().unwrap(); - 2038
let store = tempfile::tempdir().unwrap(); - 2039
fs::write( - 2040
workspace.path().join("page.html"), - 2041
"workspace before review", - 2042
) - 2043
.unwrap(); - 2044
fs::write(source.path().join("page.html"), "version one").unwrap(); - 2045
let first = freeze_candidate( - 2046
"v1", - 2047
source.path(), - 2048
workspace.path(), - 2049
&store.path().join("v1"), - 2050
) - 2051
.unwrap(); - 2052
let original_base = first.files[0].base_hash.clone(); - 2053
fs::write( - 2054
workspace.path().join("page.html"), - 2055
"human changed workspace", - 2056
) - 2057
.unwrap(); - 2058
let task = store.path().join("task"); - 2059
prepare_revision_copy(&first, &task).unwrap(); - 2060
fs::write(task.join("page.html"), "version two").unwrap(); - 2061
fs::write(task.join("added.txt"), "new draft file").unwrap(); - 2062
fs::create_dir(task.join(".vak")).unwrap(); - 2063
fs::write(task.join(".vak/config.toml"), "private control state").unwrap(); - 2064
let second = - 2065
freeze_revision_candidate("v2", &task, &first, &store.path().join("v2")).unwrap(); - 2066
assert_eq!(second.files.len(), 2); - 2067
assert_eq!( - 2068
second - 2069
.files - 2070
.iter() - 2071
.find(|file| file.path == "page.html") - 2072
.unwrap() - 2073
.base_hash, - 2074
original_base - 2075
); - 2076
assert_eq!( - 2077
second - 2078
.files - 2079
.iter() - 2080
.find(|file| file.path == "added.txt") - 2081
.unwrap() - 2082
.base_hash, - 2083
None - 2084
); - 2085
assert!(!second.source_root.join(".vak").exists()); - 2086
assert!(matches!(promote(&second), Err(Error::Conflict(path)) if path == "page.html")); - 2087
} - 2088
- 2089
#[test] - 2090
fn unchanged_revision_does_not_create_another_version() { - 2091
let source = tempfile::tempdir().unwrap(); - 2092
let workspace = tempfile::tempdir().unwrap(); - 2093
let store = tempfile::tempdir().unwrap(); - 2094
fs::write(source.path().join("draft.txt"), "same").unwrap(); - 2095
let first = freeze_candidate( - 2096
"v1", - 2097
source.path(), - 2098
workspace.path(), - 2099
&store.path().join("v1"), - 2100
) - 2101
.unwrap(); - 2102
let task = store.path().join("task"); - 2103
prepare_revision_copy(&first, &task).unwrap(); - 2104
let next = store.path().join("v2"); - 2105
assert!(matches!( - 2106
freeze_revision_candidate("v2", &task, &first, &next), - 2107
Err(Error::InvalidPlan(_)) - 2108
)); - 2109
assert!(!next.exists()); - 2110
} - 2111
- 2112
#[cfg(unix)] - 2113
#[test] - 2114
fn promotion_rejects_symlinked_candidate_paths() { - 2115
let source = tempfile::tempdir().unwrap(); - 2116
let target = tempfile::tempdir().unwrap(); - 2117
let outside = tempfile::tempdir().unwrap(); - 2118
fs::write(outside.path().join("secret.txt"), "secret").unwrap(); - 2119
std::os::unix::fs::symlink( - 2120
outside.path().join("secret.txt"), - 2121
source.path().join("candidate.txt"), - 2122
) - 2123
.unwrap(); - 2124
- 2125
let candidate = CandidateManifest { - 2126
candidate_id: "symlink".into(), - 2127
source_root: source.path().into(), - 2128
destination_root: target.path().into(), - 2129
target_checks: Vec::new(), - 2130
workspace_checks: Vec::new(), - 2131
files: vec![CandidateFile { - 2132
path: "candidate.txt".into(), - 2133
candidate_hash: digest(b"secret"), - 2134
base_hash: None, - 2135
bytes: 6, - 2136
operation: CandidateOperation::Upsert, - 2137
}], - 2138
}; - 2139
assert!(matches!(promote(&candidate), Err(Error::PathEscape(_)))); - 2140
assert!(!target.path().join("candidate.txt").exists()); - 2141
} - 2142
- 2143
#[test] - 2144
fn recoverable_promotion_rolls_back_interrupted_apply_then_retries() { - 2145
let source = tempfile::tempdir().unwrap(); - 2146
let target = tempfile::tempdir().unwrap(); - 2147
let control = tempfile::tempdir().unwrap(); - 2148
fs::write(source.path().join("a.txt"), "new a").unwrap(); - 2149
fs::write(source.path().join("b.txt"), "new b").unwrap(); - 2150
fs::write(target.path().join("a.txt"), "old a").unwrap(); - 2151
let candidate = CandidateManifest { - 2152
candidate_id: "recoverable".into(), - 2153
source_root: source.path().into(), - 2154
destination_root: target.path().into(), - 2155
target_checks: Vec::new(), - 2156
workspace_checks: Vec::new(), - 2157
files: vec![ - 2158
CandidateFile { - 2159
path: "a.txt".into(), - 2160
candidate_hash: digest(b"new a"), - 2161
base_hash: Some(digest(b"old a")), - 2162
bytes: 5, - 2163
operation: CandidateOperation::Upsert, - 2164
}, - 2165
CandidateFile { - 2166
path: "b.txt".into(), - 2167
candidate_hash: digest(b"new b"), - 2168
base_hash: None, - 2169
bytes: 5, - 2170
operation: CandidateOperation::Upsert, - 2171
}, - 2172
], - 2173
}; - 2174
let directory = control.path().join("recoverable"); - 2175
fs::create_dir_all(directory.join("backups")).unwrap(); - 2176
let backup = directory.join("backups/0"); - 2177
fs::write(&backup, "old a").unwrap(); - 2178
fs::write(target.path().join("a.txt"), "new a").unwrap(); - 2179
let interrupted = PromotionTransaction { - 2180
schema_version: 1, - 2181
candidate_id: "recoverable".into(), - 2182
candidate_digest: candidate_digest(&candidate).unwrap(), - 2183
destination_root: target.path().into(), - 2184
state: PromotionTransactionState::Applying, - 2185
files: vec![ - 2186
PromotionTransactionFile { - 2187
path: "a.txt".into(), - 2188
before_hash: Some(digest(b"old a")), - 2189
after_hash: digest(b"new a"), - 2190
backup_path: Some(backup), - 2191
state: PromotionFileState::Applied, - 2192
operation: CandidateOperation::Upsert, - 2193
}, - 2194
PromotionTransactionFile { - 2195
path: "b.txt".into(), - 2196
before_hash: None, - 2197
after_hash: digest(b"new b"), - 2198
backup_path: None, - 2199
state: PromotionFileState::Applying, - 2200
operation: CandidateOperation::Upsert, - 2201
}, - 2202
], - 2203
}; - 2204
write_transaction(&directory.join("journal.json"), &interrupted).unwrap(); - 2205
- 2206
let receipt = promote_recoverable(&candidate, control.path()).unwrap(); - 2207
assert_eq!(receipt.applied, vec!["a.txt", "b.txt"]); - 2208
assert_eq!( - 2209
fs::read_to_string(target.path().join("a.txt")).unwrap(), - 2210
"new a" - 2211
); - 2212
assert_eq!( - 2213
fs::read_to_string(target.path().join("b.txt")).unwrap(), - 2214
"new b" - 2215
); - 2216
let journal = load_transaction(&directory.join("journal.json")).unwrap(); - 2217
assert_eq!(journal.state, PromotionTransactionState::Completed); - 2218
} - 2219
- 2220
#[test] - 2221
fn recovery_refuses_to_erase_a_later_workspace_edit() { - 2222
let source = tempfile::tempdir().unwrap(); - 2223
let target = tempfile::tempdir().unwrap(); - 2224
let control = tempfile::tempdir().unwrap(); - 2225
fs::write(source.path().join("a.txt"), "candidate").unwrap(); - 2226
fs::write(target.path().join("a.txt"), "human edit").unwrap(); - 2227
let candidate = CandidateManifest { - 2228
candidate_id: "conflicted-recovery".into(), - 2229
source_root: source.path().into(), - 2230
destination_root: target.path().into(), - 2231
target_checks: Vec::new(), - 2232
workspace_checks: Vec::new(), - 2233
files: vec![CandidateFile { - 2234
path: "a.txt".into(), - 2235
candidate_hash: digest(b"candidate"), - 2236
base_hash: Some(digest(b"original")), - 2237
bytes: 9, - 2238
operation: CandidateOperation::Upsert, - 2239
}], - 2240
}; - 2241
let directory = control.path().join("conflicted-recovery"); - 2242
fs::create_dir_all(directory.join("backups")).unwrap(); - 2243
let backup = directory.join("backups/0"); - 2244
fs::write(&backup, "original").unwrap(); - 2245
write_transaction( - 2246
&directory.join("journal.json"), - 2247
&PromotionTransaction { - 2248
schema_version: 1, - 2249
candidate_id: candidate.candidate_id.clone(), - 2250
candidate_digest: candidate_digest(&candidate).unwrap(), - 2251
destination_root: target.path().into(), - 2252
state: PromotionTransactionState::Applying, - 2253
files: vec![PromotionTransactionFile { - 2254
path: "a.txt".into(), - 2255
before_hash: Some(digest(b"original")), - 2256
after_hash: digest(b"candidate"), - 2257
backup_path: Some(backup), - 2258
state: PromotionFileState::Applied, - 2259
operation: CandidateOperation::Upsert, - 2260
}], - 2261
}, - 2262
) - 2263
.unwrap(); - 2264
- 2265
assert!(matches!( - 2266
promote_recoverable(&candidate, control.path()), - 2267
Err(Error::Conflict(message)) if message.contains("later workspace change") - 2268
)); - 2269
assert_eq!( - 2270
fs::read_to_string(target.path().join("a.txt")).unwrap(), - 2271
"human edit" - 2272
); - 2273
} - 2274
- 2275
#[test] - 2276
fn completed_transaction_is_bound_to_the_selected_file_set() { - 2277
let source = tempfile::tempdir().unwrap(); - 2278
let target = tempfile::tempdir().unwrap(); - 2279
let control = tempfile::tempdir().unwrap(); - 2280
fs::write(source.path().join("a.txt"), "a").unwrap(); - 2281
fs::write(source.path().join("b.txt"), "b").unwrap(); - 2282
let file = |path: &str, bytes: &[u8]| CandidateFile { - 2283
path: path.into(), - 2284
candidate_hash: digest(bytes), - 2285
base_hash: None, - 2286
bytes: bytes.len() as u64, - 2287
operation: CandidateOperation::Upsert, - 2288
}; - 2289
let first = CandidateManifest { - 2290
candidate_id: "selection".into(), - 2291
source_root: source.path().into(), - 2292
destination_root: target.path().into(), - 2293
target_checks: Vec::new(), - 2294
workspace_checks: Vec::new(), - 2295
files: vec![file("a.txt", b"a")], - 2296
}; - 2297
promote_recoverable(&first, control.path()).unwrap(); - 2298
let different_selection = CandidateManifest { - 2299
files: vec![file("b.txt", b"b")], - 2300
..first - 2301
}; - 2302
assert!(matches!( - 2303
promote_recoverable(&different_selection, control.path()), - 2304
Err(Error::InvalidPlan(message)) if message.contains("identity mismatch") - 2305
)); - 2306
assert!(!target.path().join("b.txt").exists()); - 2307
} - 2308
- 2309
#[test] - 2310
fn scoped_undo_restores_changed_files_and_removes_new_files() { - 2311
let source = tempfile::tempdir().unwrap(); - 2312
let target = tempfile::tempdir().unwrap(); - 2313
let control = tempfile::tempdir().unwrap(); - 2314
fs::write(source.path().join("changed.txt"), "after").unwrap(); - 2315
fs::write(source.path().join("new.txt"), "new").unwrap(); - 2316
fs::write(target.path().join("changed.txt"), "before").unwrap(); - 2317
let candidate = CandidateManifest { - 2318
candidate_id: "undoable".into(), - 2319
source_root: source.path().into(), - 2320
destination_root: target.path().into(), - 2321
target_checks: Vec::new(),
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.