- 239
fn verify(&self, path: &Path) -> Result<String, String> { - 240
let bytes = fs::read(path).map_err(|error| error.to_string())?; - 241
serde_json::from_slice::<serde_json::Value>(&bytes) - 242
.map(|_| "JSON parsed".into()) - 243
.map_err(|error| format!("JSON parse failed: {error}")) - 244
} - 245
} - 246
- 247
/// Checks the browser-parsed document shape, not visual quality or script output. - 248
/// In particular, an unclosed raw-text element such as <title> can swallow the - 249
/// intended page while leaving the source file nonempty. - 250
pub struct HtmlBodyVerifier; - 251
- 252
impl TargetVerifier for HtmlBodyVerifier { - 253
fn id(&self) -> &'static str { - 254
"format.html.body" - 255
} - 256
- 257
fn supports(&self, path: &str) -> bool { - 258
let path = path.to_ascii_lowercase(); - 259
path.ends_with(".html") || path.ends_with(".htm") - 260
} - 261
- 262
fn verify(&self, path: &Path) -> Result<String, String> { - 263
const MAX_HTML_BYTES: u64 = 16 * 1024 * 1024; - 264
let size = fs::metadata(path).map_err(|error| error.to_string())?.len(); - 265
if size > MAX_HTML_BYTES { - 266
return Err("HTML exceeds the 16 MiB structural check limit".into()); - 267
} - 268
let bytes = fs::read(path).map_err(|error| error.to_string())?; - 269
let source = String::from_utf8_lossy(&bytes); - 270
let document = dom_query::Document::from(source.as_ref()); - 271
let body = document.select("body"); - 272
if body.is_empty() { - 273
return Err("HTML parser found no body".into()); - 274
} - 275
let has_body_text = !body.text().trim().is_empty(); - 276
let has_body_element = !body.select("*").is_empty(); - 277
let has_script = !document.select("script").is_empty(); - 278
if !has_body_text && !has_body_element && !has_script { - 279
return Err("HTML parser found no visible body content or script; check for an unclosed <title> or other raw-text element".into()); - 280
} - 281
Ok("HTML parsed with body content or a script; visual output was not inspected".into()) - 282
} - 283
} - 284
- 285
pub struct ImageDecodeVerifier; - 286
- 287
impl TargetVerifier for ImageDecodeVerifier { - 288
fn id(&self) -> &'static str { - 289
"format.image-decode" - 290
} - 291
- 292
fn supports(&self, path: &str) -> bool { - 293
let path = path.to_ascii_lowercase(); - 294
[".png", ".jpg", ".jpeg", ".gif", ".webp"] - 295
.iter() - 296
.any(|extension| path.ends_with(extension)) - 297
} - 298
- 299
fn verify(&self, path: &Path) -> Result<String, String> { - 300
let format = image::ImageFormat::from_path(path) - 301
.map_err(|error| format!("image extension is unsupported: {error}"))?; - 302
let mut reader = image::ImageReader::open(path) - 303
.map_err(|error| format!("image could not be opened: {error}"))?; - 304
reader.set_format(format); - 305
let decoded = reader - 306
.decode() - 307
.map_err(|error| format!("image decode failed: {error}"))?; - 308
let width = decoded.width(); - 309
let height = decoded.height(); - 310
if width == 0 || height == 0 { - 311
return Err("decoded image has zero width or height".into()); - 312
} - 313
Ok(format!("decoded {width}×{height} image")) - 314
} - 315
} - 316
- 317
pub struct PdfStructureVerifier; - 318
- 319
impl TargetVerifier for PdfStructureVerifier { - 320
fn id(&self) -> &'static str { - 321
"format.pdf-structure" - 322
} - 323
- 324
fn supports(&self, path: &str) -> bool { - 325
path.to_ascii_lowercase().ends_with(".pdf") - 326
} - 327
- 328
fn verify(&self, path: &Path) -> Result<String, String> { - 329
const MAX_DECOMPRESSED_STREAM_BYTES: usize = 64 * 1024 * 1024; - 330
let document = lopdf::Document::load_with_options( - 331
path, - 332
lopdf::LoadOptions::with_max_decompressed_size(MAX_DECOMPRESSED_STREAM_BYTES), - 333
) - 334
.map_err(|error| format!("PDF structure could not be parsed: {error}"))?; - 335
document - 336
.catalog() - 337
.map_err(|error| format!("PDF catalog is invalid: {error}"))?; - 338
let pages = document.get_pages(); - 339
if pages.is_empty() { - 340
return Err("PDF has no pages".into()); - 341
} - 342
Ok(format!( - 343
"parsed PDF {} with {} page(s)", - 344
document.version, - 345
pages.len() - 346
)) - 347
} - 348
} - 349
- 350
/// Opens a candidate of the Open XML family through `vak-ooxml`: bounded - 351
/// package reading, detection by main-part content type, the main-part - 352
/// root, and a full read projection. A package whose content type names a - 353
/// different format than its extension (a macro package renamed `.docx`) - 354
/// fails, because the name is what a person trusts before opening it. - 355
pub struct OpenXmlPackageVerifier; - 356
- 357
impl TargetVerifier for OpenXmlPackageVerifier { - 358
fn id(&self) -> &'static str { - 359
"format.openxml" - 360
} - 361
- 362
fn supports(&self, path: &str) -> bool { - 363
vak_ooxml::is_openxml_path(path) - 364
} - 365
- 366
fn verify(&self, path: &Path) -> Result<String, String> { - 367
let file = fs::File::open(path).map_err(|error| error.to_string())?; - 368
let mut package = vak_ooxml::Package::open(file, vak_ooxml::Limits::default()) - 369
.map_err(|error| error.to_string())?; - 370
let format = package.format(); - 371
let named = path - 372
.extension() - 373
.and_then(|extension| extension.to_str()) - 374
.unwrap_or_default() - 375
.to_ascii_lowercase(); - 376
if named != format.extension() { - 377
return Err(format!( - 378
"package is a {} (.{}) but is named .{named}", - 379
format.vocabulary.label(), - 380
format.extension() - 381
)); - 382
} - 383
let document = vak_ooxml::read::project(&mut package).map_err(|error| error.to_string())?; - 384
if !document.inspection.untyped_parts.is_empty() { - 385
return Err(format!( - 386
"package has part(s) without a content type: {}", - 387
document.inspection.untyped_parts.join(", ") - 388
)); - 389
} - 390
let stats = document - 391
.stats - 392
.iter() - 393
.map(|(name, count)| format!("{count} {name}")) - 394
.collect::<Vec<_>>() - 395
.join(", "); - 396
let mut evidence = format!( - 397
"Open XML {} (.{}, {:?}) opened with {} parts; main part {} parsed; {stats}", - 398
format.vocabulary.label(), - 399
format.extension(), - 400
document.inspection.conformance, - 401
document.inspection.part_count, - 402
document.inspection.main_part, - 403
); - 404
let flags = document.inspection.flags(); - 405
if !flags.is_empty() { - 406
evidence.push_str("; flags: "); - 407
evidence.push_str(&flags.join("; ")); - 408
} - 409
evidence.push_str("; schema conformance and rendering were not checked"); - 410
Ok(evidence) - 411
} - 412
} - 413
- 414
pub struct DelimitedDataVerifier; - 415
- 416
impl TargetVerifier for DelimitedDataVerifier { - 417
fn id(&self) -> &'static str { - 418
"data.delimited" - 419
} - 420
- 421
fn supports(&self, path: &str) -> bool { - 422
let path = path.to_ascii_lowercase(); - 423
path.ends_with(".csv") || path.ends_with(".tsv") - 424
} - 425
- 426
fn verify(&self, path: &Path) -> Result<String, String> { - 427
let delimiter = if path - 428
.to_string_lossy() - 429
.to_ascii_lowercase() - 430
.ends_with(".tsv") - 431
{ - 432
b'\t' - 433
} else { - 434
b',' - 435
}; - 436
let mut reader = csv::ReaderBuilder::new() - 437
.delimiter(delimiter) - 438
.flexible(false) - 439
.from_path(path) - 440
.map_err(|error| format!("delimited data could not be opened: {error}"))?; - 441
let columns = reader - 442
.headers() - 443
.map_err(|error| format!("header parse failed: {error}"))? - 444
.len(); - 445
if columns == 0 { - 446
return Err("delimited data has no columns".into()); - 447
} - 448
let mut rows = 0_u64; - 449
for record in reader.records() { - 450
record.map_err(|error| format!("record parse failed: {error}"))?; - 451
rows += 1; - 452
} - 453
Ok(format!( - 454
"parsed {rows} data row(s) with {columns} consistent column(s)" - 455
)) - 456
} - 457
} - 458
- 459
pub struct SvgStructureVerifier; - 460
- 461
impl TargetVerifier for SvgStructureVerifier { - 462
fn id(&self) -> &'static str { - 463
"format.svg" - 464
} - 465
- 466
fn supports(&self, path: &str) -> bool { - 467
path.to_ascii_lowercase().ends_with(".svg") - 468
} - 469
- 470
fn verify(&self, path: &Path) -> Result<String, String> { - 471
let mut reader = quick_xml::Reader::from_file(path) - 472
.map_err(|error| format!("SVG could not be opened: {error}"))?; - 473
reader.config_mut().trim_text(true); - 474
let mut buffer = Vec::new(); - 475
loop { - 476
match reader.read_event_into(&mut buffer) { - 477
Ok(quick_xml::events::Event::Start(element)) - 478
| Ok(quick_xml::events::Event::Empty(element)) => { - 479
let name = element.local_name(); - 480
return if name.as_ref() == b"svg" { - 481
Ok("parsed SVG root".into()) - 482
} else { - 483
Err(format!( - 484
"XML root is {}, expected svg", - 485
String::from_utf8_lossy(name.as_ref()) - 486
)) - 487
}; - 488
} - 489
Ok(quick_xml::events::Event::Eof) => return Err("SVG has no root element".into()), - 490
Ok(_) => {} - 491
Err(error) => return Err(format!("SVG parse failed: {error}")), - 492
} - 493
buffer.clear(); - 494
} - 495
} - 496
} - 497
- 498
pub fn default_target_verifiers() -> TargetVerifierRegistry { - 499
let mut registry = TargetVerifierRegistry::default(); - 500
registry.register(JsonSyntaxVerifier); - 501
registry.register(HtmlBodyVerifier); - 502
registry.register(ImageDecodeVerifier); - 503
registry.register(PdfStructureVerifier); - 504
registry.register(OpenXmlPackageVerifier); - 505
registry.register(DelimitedDataVerifier); - 506
registry.register(SvgStructureVerifier); - 507
registry - 508
} - 509
- 510
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 511
pub enum PromotionTransactionState { - 512
Prepared, - 513
Applying, - 514
Completed, - 515
RolledBack, - 516
RecoveryRequired, - 517
Undoing, - 518
Undone, - 519
} - 520
- 521
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 522
pub enum PromotionFileState { - 523
Prepared, - 524
Applying, - 525
Applied, - 526
Undoing, - 527
Undone, - 528
} - 529
- 530
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 531
pub struct PromotionTransactionFile { - 532
pub path: String, - 533
pub before_hash: Option<String>, - 534
pub after_hash: String, - 535
pub backup_path: Option<PathBuf>, - 536
pub state: PromotionFileState, - 537
#[serde(default)] - 538
pub operation: CandidateOperation, - 539
} - 540
- 541
/// Crash-recovery journal for one exact candidate import. It is stored outside - 542
/// the destination workspace, so the Agent cannot edit its own transaction - 543
/// state and a partially-applied import remains recoverable after restart. - 544
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 545
pub struct PromotionTransaction { - 546
pub schema_version: u32, - 547
pub candidate_id: String, - 548
pub candidate_digest: String, - 549
pub destination_root: PathBuf, - 550
pub state: PromotionTransactionState, - 551
pub files: Vec<PromotionTransactionFile>, - 552
} - 553
- 554
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 555
pub struct VerificationResult { - 556
pub path: String, - 557
pub status: String, - 558
pub evidence: String, - 559
} - 560
- 561
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 562
pub struct UndoReceipt { - 563
pub candidate_id: String, - 564
pub restored: Vec<String>, - 565
pub verification: Vec<VerificationResult>, - 566
} - 567
- 568
/// Durable control-plane fact for an environment lifecycle. The session ledger - 569
/// remains the conversational source of truth; this JSONL record is the - 570
/// addressable projection used by Workbench and server operations. - 571
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 572
pub struct EnvironmentRecord { - 573
pub record_id: String, - 574
pub environment_id: String, - 575
pub state: EnvironmentState, - 576
pub plan: EnvironmentPlan, - 577
pub updated_at: String, - 578
#[serde(default)] - 579
pub detail: Option<String>, - 580
} - 581
- 582
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 583
pub struct PreviewPreparationRecord { - 584
pub record_id: String, - 585
pub session_id: String, - 586
pub result_id: String, - 587
pub candidate_id: String, - 588
pub candidate_digest: String, - 589
pub environment_id: String, - 590
pub state: EnvironmentState, - 591
pub command: String, - 592
pub evidence: String, - 593
pub updated_at: String, - 594
} - 595
- 596
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 597
pub struct CandidateRecord { - 598
pub record_id: String, - 599
pub session_id: String, - 600
pub turn_id: String, - 601
pub result_id: String, - 602
pub execution_id: String, - 603
pub environment_id: String, - 604
pub candidate_digest: String, - 605
pub candidate: CandidateManifest, - 606
pub verified: bool, - 607
/// Format evidence observed from the frozen draft bytes. Acceptance runs - 608
/// the same planned checks again against the applied workspace state. - 609
#[serde(default)] - 610
pub draft_checks: Vec<TargetCheckResult>, - 611
pub updated_at: String, - 612
/// The saved version used as input for a human-requested revision. - 613
#[serde(default)] - 614
pub parent_candidate_id: Option<String>, - 615
/// Durable child session whose tool receipts produced this version. - 616
#[serde(default)] - 617
pub revision_session_id: Option<String>, - 618
/// Set when a person kept only some of an Office draft's changes: this - 619
/// version replays those, and `parent_candidate_id` is the full draft. - 620
#[serde(default, skip_serializing_if = "Option::is_none")] - 621
pub narrowed: Option<NarrowedDraft>, - 622
} - 623
- 624
/// Which of an Office draft's changes a narrowed version keeps - 625
/// (docs/design/72-openxml-documents.md, P3). - 626
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 627
pub struct NarrowedDraft { - 628
pub path: String, - 629
pub keep: Vec<String>, - 630
} - 631
- 632
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 633
pub enum CandidateRevisionStatus { - 634
Running, - 635
Completed, - 636
Failed, - 637
} - 638
- 639
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 640
pub struct CandidateRevisionRecord { - 641
pub record_id: String, - 642
pub revision_id: String, - 643
pub session_id: String, - 644
pub parent_candidate_id: String, - 645
pub comment_id: String, - 646
pub child_session_id: String, - 647
pub task_root: PathBuf, - 648
pub status: CandidateRevisionStatus, - 649
pub candidate_id: Option<String>, - 650
pub detail: Option<String>, - 651
pub updated_at: String, - 652
} - 653
- 654
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 655
pub struct PromotionRecord { - 656
pub record_id: String, - 657
pub session_id: String, - 658
pub result_id: String, - 659
pub candidate_digest: String, - 660
pub candidate_id: String, - 661
pub receipt: PromotionReceipt, - 662
#[serde(default)] - 663
pub workspace_checks: Vec<WorkspaceCheckPlan>, - 664
pub updated_at: String, - 665
} - 666
- 667
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 668
pub struct PromotionUndoRecord { - 669
pub record_id: String, - 670
pub session_id: String, - 671
pub candidate_id: String, - 672
pub receipt: UndoReceipt, - 673
pub updated_at: String, - 674
} - 675
- 676
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 677
pub struct WorkspaceCheckRecord { - 678
pub record_id: String, - 679
pub session_id: String, - 680
pub candidate_id: String, - 681
pub applied_state_digest: String, - 682
pub check: WorkspaceCheckPlan, - 683
pub status: String, - 684
pub evidence: String, - 685
pub updated_at: String, - 686
} - 687
- 688
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] - 689
#[serde(tag = "kind", content = "record")] - 690
pub enum DurableRecord { - 691
Environment(EnvironmentRecord), - 692
PreviewPreparation(PreviewPreparationRecord), - 693
Candidate(CandidateRecord), - 694
Promotion(PromotionRecord), - 695
PromotionUndo(PromotionUndoRecord), - 696
WorkspaceCheck(WorkspaceCheckRecord), - 697
CandidateRevision(CandidateRevisionRecord), - 698
} - 699
- 700
pub fn append_record(path: &Path, record: &DurableRecord) -> Result<(), Error> { - 701
if let Some(parent) = path.parent() { - 702
fs::create_dir_all(parent)?; - 703
} - 704
let mut line = serde_json::to_vec(record) - 705
.map_err(|e| Error::InvalidPlan(format!("record serialization failed: {e}")))?; - 706
line.push(b'\n'); - 707
let mut file = fs::OpenOptions::new() - 708
.create(true) - 709
.append(true) - 710
.open(path)?; - 711
use std::io::Write; - 712
file.write_all(&line)?; - 713
file.sync_data()?; - 714
Ok(()) - 715
} - 716
- 717
pub fn load_records(path: &Path) -> Result<Vec<DurableRecord>, Error> { - 718
if !path.exists() { - 719
return Ok(Vec::new()); - 720
} - 721
let text = fs::read_to_string(path)?; - 722
text.lines() - 723
.filter(|line| !line.trim().is_empty()) - 724
.map(|line| { - 725
serde_json::from_str(line) - 726
.map_err(|e| Error::InvalidPlan(format!("record parse failed: {e}"))) - 727
}) - 728
.collect() - 729
} - 730
- 731
#[derive(Debug, thiserror::Error)] - 732
pub enum Error { - 733
#[error("path escapes its root: {0}")] - 734
PathEscape(String), - 735
#[error("candidate file is missing: {0}")] - 736
Missing(String), - 737
#[error("candidate changed after review: {0}")] - 738
CandidateChanged(String), - 739
#[error("workspace changed since review: {0}")] - 740
Conflict(String), - 741
#[error("filesystem error: {0}")] - 742
Io(#[from] std::io::Error), - 743
#[error("invalid environment plan: {0}")] - 744
InvalidPlan(String), - 745
} - 746
- 747
pub fn digest(bytes: &[u8]) -> String { - 748
format!("sha256:{:x}", Sha256::digest(bytes)) - 749
} - 750
- 751
pub fn candidate_digest(candidate: &CandidateManifest) -> Result<String, Error> { - 752
let bytes = serde_json::to_vec(candidate) - 753
.map_err(|error| Error::InvalidPlan(format!("candidate serialization failed: {error}")))?; - 754
Ok(digest(&bytes)) - 755
} - 756
- 757
fn confined(root: &Path, relative: &str) -> Result<PathBuf, Error> { - 758
let rel = Path::new(relative); - 759
if rel.is_absolute() - 760
|| rel - 761
.components() - 762
.any(|c| matches!(c, std::path::Component::ParentDir)) - 763
{ - 764
return Err(Error::PathEscape(relative.to_string())); - 765
} - 766
let root = root - 767
.canonicalize() - 768
.map_err(|error| Error::InvalidPlan(format!("root is unavailable: {error}")))?; - 769
let mut current = root; - 770
for component in rel.components() { - 771
let std::path::Component::Normal(name) = component else { - 772
continue; - 773
}; - 774
current.push(name); - 775
match fs::symlink_metadata(¤t) { - 776
Ok(metadata) => { - 777
if metadata.file_type().is_symlink() { - 778
return Err(Error::PathEscape(relative.to_string())); - 779
} - 780
} - 781
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - 782
Err(error) => return Err(Error::Io(error)), - 783
} - 784
} - 785
Ok(current) - 786
} - 787
- 788
pub fn candidate_manifest( - 789
id: &str, - 790
source_root: &Path, - 791
destination_root: &Path, - 792
) -> Result<CandidateManifest, Error> { - 793
let mut files = Vec::new(); - 794
for item in WalkDir::new(source_root).follow_links(false) { - 795
let item = item.map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?; - 796
// A task Core may maintain local control state. It is never part of - 797
// the deliverable and must not enter a reviewed candidate. - 798
if item - 799
.path() - 800
.strip_prefix(source_root) - 801
.ok() - 802
.is_some_and(|relative| { - 803
relative - 804
.components() - 805
.next() - 806
.is_some_and(|component| component.as_os_str() == ".vak") - 807
}) - 808
{ - 809
continue; - 810
} - 811
if !item.file_type().is_file() { - 812
continue; - 813
} - 814
let relative = item - 815
.path() - 816
.strip_prefix(source_root) - 817
.map_err(|_| Error::PathEscape(item.path().display().to_string()))? - 818
.to_string_lossy() - 819
.replace('\\', "/"); - 820
let bytes = fs::read(item.path())?; - 821
let target = destination_root.join(&relative); - 822
let base_hash = target - 823
.is_file() - 824
.then(|| fs::read(&target).ok()) - 825
.flatten() - 826
.map(|b| digest(&b)); - 827
files.push(CandidateFile { - 828
path: relative, - 829
candidate_hash: digest(&bytes), - 830
base_hash, - 831
bytes: bytes.len() as u64, - 832
operation: CandidateOperation::Upsert, - 833
}); - 834
} - 835
files.sort_by(|a, b| a.path.cmp(&b.path)); - 836
Ok(CandidateManifest { - 837
candidate_id: id.to_string(), - 838
source_root: source_root.to_path_buf(), - 839
destination_root: destination_root.to_path_buf(), - 840
files, - 841
target_checks: Vec::new(), - 842
workspace_checks: Vec::new(), - 843
}) - 844
} - 845
- 846
fn protect_frozen_tree(root: &Path) -> Result<(), Error> { - 847
let mut directories = Vec::new(); - 848
for item in WalkDir::new(root).follow_links(false) { - 849
let item = item.map_err(|error| Error::Io(std::io::Error::other(error.to_string())))?; - 850
let path = item.path(); - 851
let metadata = fs::symlink_metadata(path)?; - 852
if metadata.file_type().is_symlink() { - 853
return Err(Error::PathEscape(path.display().to_string())); - 854
} - 855
if metadata.is_dir() { - 856
directories.push(path.to_path_buf()); - 857
continue; - 858
} - 859
let mut permissions = metadata.permissions(); - 860
#[cfg(unix)] - 861
{ - 862
use std::os::unix::fs::PermissionsExt; - 863
permissions.set_mode(permissions.mode() & 0o555); - 864
} - 865
#[cfg(not(unix))] - 866
permissions.set_readonly(true); - 867
fs::set_permissions(path, permissions)?; - 868
} - 869
directories.sort_by_key(|path| std::cmp::Reverse(path.components().count())); - 870
for path in directories { - 871
let mut permissions = fs::symlink_metadata(&path)?.permissions(); - 872
#[cfg(unix)] - 873
{ - 874
use std::os::unix::fs::PermissionsExt; - 875
permissions.set_mode(permissions.mode() & 0o555); - 876
} - 877
#[cfg(not(unix))] - 878
permissions.set_readonly(true); - 879
fs::set_permissions(path, permissions)?; - 880
} - 881
Ok(()) - 882
} - 883
- 884
pub fn remove_frozen_candidate(root: &Path) -> Result<(), Error> { - 885
if !root.exists() { - 886
return Ok(()); - 887
} - 888
let mut entries: Vec<PathBuf> = WalkDir::new(root) - 889
.follow_links(false) - 890
.into_iter() - 891
.map(|entry| { - 892
entry - 893
.map(|entry| entry.path().to_path_buf()) - 894
.map_err(|error| Error::Io(std::io::Error::other(error.to_string()))) - 895
}) - 896
.collect::<Result<_, _>>()?; - 897
entries.sort_by_key(|path| path.components().count()); - 898
for path in entries { - 899
let Ok(metadata) = fs::symlink_metadata(&path) else { - 900
continue; - 901
}; - 902
if metadata.file_type().is_symlink() { - 903
continue; - 904
} - 905
let mut permissions = metadata.permissions(); - 906
#[cfg(unix)] - 907
{ - 908
use std::os::unix::fs::PermissionsExt; - 909
permissions.set_mode(permissions.mode() | 0o700); - 910
} - 911
#[cfg(not(unix))] - 912
permissions.set_readonly(false); - 913
fs::set_permissions(path, permissions)?; - 914
} - 915
fs::remove_dir_all(root)?; - 916
Ok(()) - 917
} - 918
- 919
/// Capture the reviewed bytes under a new, server-owned directory. The - 920
/// manifest still records the destination baseline observed at export time. - 921
pub fn freeze_candidate( - 922
id: &str, - 923
source_root: &Path, - 924
destination_root: &Path, - 925
frozen_root: &Path, - 926
) -> Result<CandidateManifest, Error> { - 927
let mut manifest = candidate_manifest(id, source_root, destination_root)?; - 928
fs::create_dir(frozen_root)?; - 929
let copy = (|| -> Result<(), Error> { - 930
for file in &manifest.files { - 931
if file.operation == CandidateOperation::Delete { - 932
continue; - 933
} - 934
let source = confined(source_root, &file.path)?; - 935
let bytes = fs::read(&source).map_err(|_| Error::Missing(file.path.clone()))?; - 936
if digest(&bytes) != file.candidate_hash { - 937
return Err(Error::CandidateChanged(file.path.clone())); - 938
} - 939
let target = confined(frozen_root, &file.path)?; - 940
if let Some(parent) = target.parent() { - 941
fs::create_dir_all(parent)?; - 942
} - 943
let mut output = fs::OpenOptions::new() - 944
.write(true) - 945
.create_new(true) - 946
.open(&target)?; - 947
use std::io::Write; - 948
output.write_all(&bytes)?; - 949
output.sync_all()?; - 950
} - 951
protect_frozen_tree(frozen_root) - 952
})(); - 953
if let Err(error) = copy { - 954
let _ = remove_frozen_candidate(frozen_root); - 955
return Err(error); - 956
} - 957
manifest.source_root = frozen_root.to_path_buf(); - 958
Ok(manifest) - 959
} - 960
- 961
/// Freeze a later version while retaining the baseline the person originally - 962
/// reviewed. Re-reading the destination here would let intervening workspace - 963
/// edits become an implicitly accepted baseline. - 964
pub fn freeze_revision_candidate( - 965
id: &str, - 966
task_root: &Path, - 967
parent: &CandidateManifest, - 968
frozen_root: &Path, - 969
) -> Result<CandidateManifest, Error> { - 970
let mut revision = freeze_candidate(id, task_root, &parent.destination_root, frozen_root)?; - 971
let result = (|| -> Result<(), Error> { - 972
for original in &parent.files { - 973
if !revision.files.iter().any(|file| file.path == original.path) - 974
&& original.base_hash.is_some() - 975
{ - 976
revision.files.push(CandidateFile { - 977
path: original.path.clone(), - 978
candidate_hash: original.candidate_hash.clone(), - 979
base_hash: original.base_hash.clone(), - 980
bytes: 0, - 981
operation: CandidateOperation::Delete, - 982
}); - 983
} - 984
} - 985
revision.files.sort_by(|a, b| a.path.cmp(&b.path)); - 986
if revision.files.is_empty() { - 987
return Err(Error::InvalidPlan( - 988
"revision has no workspace changes to review".into(), - 989
)); - 990
} - 991
let changed = revision.files.len() != parent.files.len() - 992
|| revision.files.iter().any(|file| { - 993
parent - 994
.files - 995
.iter() - 996
.find(|old| old.path == file.path) - 997
.is_some_and(|old| { - 998
old.candidate_hash != file.candidate_hash || old.operation != file.operation - 999
}) - 1000
}); - 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 =
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.