dto_roots.rs (35940B)
1 use std::fs; 2 use std::io::{self, ErrorKind, Write}; 3 use std::path::{Component, Path, PathBuf}; 4 5 use dto_bindgen_core::{ 6 Config, RootDiscoveryConfig, RootDiscoveryMode, generate_root_module, scan_rust_source, 7 }; 8 use tempfile::NamedTempFile; 9 10 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 11 enum Mode { 12 Check, 13 Write, 14 } 15 16 #[derive(Debug)] 17 struct GeneratedRootFile { 18 path: PathBuf, 19 display_path: String, 20 contents: String, 21 } 22 23 struct StagedRootFile { 24 path: PathBuf, 25 display_path: String, 26 temporary: NamedTempFile, 27 original: Option<OriginalFileState>, 28 } 29 30 struct OriginalRootFile { 31 path: PathBuf, 32 display_path: String, 33 contents: Option<Vec<u8>>, 34 permissions: Option<fs::Permissions>, 35 } 36 37 struct OriginalFileState { 38 contents: Vec<u8>, 39 permissions: fs::Permissions, 40 } 41 42 type PersistFailure = (NamedTempFile, io::Error); 43 44 pub(crate) fn run(args: &[String], workspace_root: &Path) -> Result<(), String> { 45 let mode = match args { 46 [arg] if arg == "--check" => Mode::Check, 47 [arg] if arg == "--write" => Mode::Write, 48 _ => return Err("usage: cargo xtask dto-roots --check|--write".to_owned()), 49 }; 50 execute(workspace_root, mode) 51 } 52 53 pub(crate) fn check(workspace_root: &Path) -> Result<(), String> { 54 execute(workspace_root, Mode::Check) 55 } 56 57 fn execute(workspace_root: &Path, mode: Mode) -> Result<(), String> { 58 let config_relative_path = "dto_bindgen.toml"; 59 let config_path = workspace_path(workspace_root, config_relative_path)?; 60 validate_existing_regular_file(workspace_root, config_relative_path, "DTO root authority")?; 61 let config = Config::from_toml_path(&config_path).map_err(|error| { 62 format!( 63 "failed to load DTO root authority `{}`: {error}", 64 config_path.display() 65 ) 66 })?; 67 let generated = generate_configured_roots(workspace_root, &config)?; 68 validate_output_paths(workspace_root, &generated)?; 69 70 match mode { 71 Mode::Check => check_generated_roots(&generated), 72 Mode::Write => write_generated_roots(&generated), 73 } 74 } 75 76 fn generate_configured_roots( 77 workspace_root: &Path, 78 config: &Config, 79 ) -> Result<Vec<GeneratedRootFile>, String> { 80 let mut generated = Vec::new(); 81 if config.root_discovery.mode == RootDiscoveryMode::SourceManifest { 82 generated.push(generate_discovery_roots( 83 workspace_root, 84 config, 85 "top-level root discovery", 86 &config.root_discovery, 87 )?); 88 } 89 for package in &config.packages { 90 if package.root_discovery.mode != RootDiscoveryMode::SourceManifest { 91 continue; 92 } 93 let discovery = RootDiscoveryConfig { 94 mode: package.root_discovery.mode, 95 source_files: package.root_discovery.source_files.clone(), 96 root_module_file: package.root_discovery.root_module_file.clone(), 97 }; 98 generated.push(generate_discovery_roots( 99 workspace_root, 100 config, 101 &format!("package `{}`", package.key), 102 &discovery, 103 )?); 104 } 105 106 if generated.is_empty() { 107 return Err("dto_bindgen.toml defines no source-manifest DTO roots".to_owned()); 108 } 109 110 generated.sort_by(|left, right| left.display_path.cmp(&right.display_path)); 111 Ok(generated) 112 } 113 114 fn generate_discovery_roots( 115 workspace_root: &Path, 116 config: &Config, 117 authority_label: &str, 118 discovery: &RootDiscoveryConfig, 119 ) -> Result<GeneratedRootFile, String> { 120 let mut inventories = Vec::with_capacity(discovery.source_files.len()); 121 for source_file in &discovery.source_files { 122 let path = workspace_path(workspace_root, source_file)?; 123 validate_existing_regular_file(workspace_root, source_file, "DTO source")?; 124 let input = fs::read_to_string(&path).map_err(|error| { 125 format!("failed to read DTO source `{source_file}` for {authority_label}: {error}") 126 })?; 127 let mut inventory = 128 scan_rust_source(source_file.clone(), &input).map_err(|error| error.to_string())?; 129 inventory.source_file = canonical_dto_source_path(source_file).to_owned(); 130 inventories.push(inventory); 131 } 132 133 let mut package_config = config.clone(); 134 package_config.root_discovery = discovery.clone(); 135 let module = generate_root_module(&package_config, &inventories) 136 .map_err(|error| format!("failed to generate DTO roots for {authority_label}: {error}"))?; 137 let path = workspace_path(workspace_root, &module.path)?; 138 139 Ok(GeneratedRootFile { 140 path, 141 display_path: module.path, 142 contents: module.contents, 143 }) 144 } 145 146 /// Returns the crate-visible source route used by generated root descriptors. 147 /// 148 /// `radroots_event` intentionally keeps several implementation files at the 149 /// crate source root while exposing them through singular domain facades with 150 /// `#[path = "..."]`. `dto_bindgen_core` derives a descriptor's Rust module 151 /// path from its source filename, so these routed files need the same logical 152 /// path that rustc assigns through their public facade. The physical source 153 /// path remains the authority used for reading and diagnostics. 154 fn canonical_dto_source_path(source_file: &str) -> &str { 155 match source_file { 156 "crates/event/src/account.rs" => "crates/event/src/profile/account.rs", 157 "crates/event/src/app_data.rs" => "crates/event/src/social/app_data.rs", 158 "crates/event/src/comment.rs" => "crates/event/src/post/comment.rs", 159 "crates/event/src/coop.rs" => "crates/event/src/farm/coop.rs", 160 "crates/event/src/document.rs" => "crates/event/src/post/document.rs", 161 "crates/event/src/follow.rs" => "crates/event/src/social/follow.rs", 162 "crates/event/src/gcs.rs" => "crates/event/src/farm/change_set.rs", 163 "crates/event/src/geochat.rs" => "crates/event/src/social/geochat.rs", 164 "crates/event/src/gift_wrap.rs" => "crates/event/src/social/gift_wrap.rs", 165 "crates/event/src/job.rs" => "crates/event/src/social/job.rs", 166 "crates/event/src/job_feedback.rs" => "crates/event/src/social/job_feedback.rs", 167 "crates/event/src/job_request.rs" => "crates/event/src/social/job_request.rs", 168 "crates/event/src/job_result.rs" => "crates/event/src/social/job_result.rs", 169 "crates/event/src/list.rs" => "crates/event/src/social/list.rs", 170 "crates/event/src/list_set.rs" => "crates/event/src/social/list_set.rs", 171 "crates/event/src/message.rs" => "crates/event/src/social/message.rs", 172 "crates/event/src/message_file.rs" => "crates/event/src/social/message_file.rs", 173 "crates/event/src/operational_listing.rs" => "crates/event/src/listing/operational.rs", 174 "crates/event/src/order.rs" => "crates/event/src/trade/order.rs", 175 "crates/event/src/order_economics.rs" => "crates/event/src/trade/order_economics.rs", 176 "crates/event/src/plot.rs" => "crates/event/src/farm/plot.rs", 177 "crates/event/src/reaction.rs" => "crates/event/src/post/reaction.rs", 178 "crates/event/src/relay_document.rs" => "crates/event/src/social/relay_document.rs", 179 "crates/event/src/resource_area.rs" => "crates/event/src/farm/resource_area.rs", 180 "crates/event/src/resource_cap.rs" => "crates/event/src/farm/resource_cap.rs", 181 "crates/event/src/seal.rs" => "crates/event/src/social/seal.rs", 182 "crates/event/src/trade_validation.rs" => "crates/event/src/trade/validation.rs", 183 _ => source_file, 184 } 185 } 186 187 fn workspace_path(workspace_root: &Path, configured_path: &str) -> Result<PathBuf, String> { 188 let relative = Path::new(configured_path); 189 if configured_path.is_empty() 190 || configured_path.contains('\\') 191 || configured_path 192 .split('/') 193 .any(|segment| matches!(segment, "" | "." | "..")) 194 || has_windows_drive_prefix(configured_path) 195 || relative.is_absolute() 196 || !relative 197 .components() 198 .all(|component| matches!(component, Component::Normal(_))) 199 { 200 return Err(format!( 201 "DTO root authority path must be a normalized workspace-relative path: `{configured_path}`" 202 )); 203 } 204 Ok(workspace_root.join(relative)) 205 } 206 207 fn has_windows_drive_prefix(path: &str) -> bool { 208 let bytes = path.as_bytes(); 209 bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' 210 } 211 212 fn validate_no_symlink_components( 213 workspace_root: &Path, 214 configured_path: &str, 215 allow_missing_final: bool, 216 role: &str, 217 ) -> Result<(), String> { 218 let relative = Path::new(configured_path); 219 let component_count = relative.components().count(); 220 let mut current = workspace_root.to_path_buf(); 221 for (index, component) in relative.components().enumerate() { 222 let Component::Normal(segment) = component else { 223 return Err(format!( 224 "{role} path is not normalized: `{configured_path}`" 225 )); 226 }; 227 current.push(segment); 228 match current.symlink_metadata() { 229 Ok(metadata) if metadata.file_type().is_symlink() => { 230 return Err(format!( 231 "{role} path contains a symlink component: `{}`", 232 current.display() 233 )); 234 } 235 Ok(_) => {} 236 Err(error) 237 if error.kind() == ErrorKind::NotFound 238 && allow_missing_final 239 && index + 1 == component_count => 240 { 241 return Ok(()); 242 } 243 Err(error) => { 244 return Err(format!( 245 "failed to inspect {role} path component `{}`: {error}", 246 current.display() 247 )); 248 } 249 } 250 } 251 Ok(()) 252 } 253 254 fn validate_existing_regular_file( 255 workspace_root: &Path, 256 configured_path: &str, 257 role: &str, 258 ) -> Result<(), String> { 259 validate_no_symlink_components(workspace_root, configured_path, false, role)?; 260 let path = workspace_path(workspace_root, configured_path)?; 261 let metadata = path 262 .metadata() 263 .map_err(|error| format!("failed to inspect {role} `{configured_path}`: {error}"))?; 264 if !metadata.is_file() { 265 return Err(format!( 266 "{role} must be a regular file: `{configured_path}`" 267 )); 268 } 269 Ok(()) 270 } 271 272 fn check_generated_roots(generated: &[GeneratedRootFile]) -> Result<(), String> { 273 let mut stale = Vec::new(); 274 for output in generated { 275 match fs::read(&output.path) { 276 Ok(current) if current == output.contents.as_bytes() => {} 277 Ok(_) => stale.push(format!("stale `{}`", output.display_path)), 278 Err(error) if error.kind() == ErrorKind::NotFound => { 279 stale.push(format!("missing `{}`", output.display_path)); 280 } 281 Err(error) => { 282 return Err(format!( 283 "failed to read generated DTO roots `{}`: {error}", 284 output.display_path 285 )); 286 } 287 } 288 } 289 290 if stale.is_empty() { 291 Ok(()) 292 } else { 293 Err(format!( 294 "generated DTO roots are not fresh:\n- {}\nrun `cargo xtask dto-roots --write`", 295 stale.join("\n- ") 296 )) 297 } 298 } 299 300 fn validate_output_paths( 301 workspace_root: &Path, 302 generated: &[GeneratedRootFile], 303 ) -> Result<(), String> { 304 let canonical_root = workspace_root.canonicalize().map_err(|error| { 305 format!( 306 "failed to resolve DTO workspace root `{}`: {error}", 307 workspace_root.display() 308 ) 309 })?; 310 for output in generated { 311 validate_no_symlink_components( 312 workspace_root, 313 &output.display_path, 314 true, 315 "generated DTO root", 316 )?; 317 let parent = output.path.parent().ok_or_else(|| { 318 format!( 319 "generated DTO root path has no parent: `{}`", 320 output.display_path 321 ) 322 })?; 323 if !parent.is_dir() { 324 return Err(format!( 325 "generated DTO root parent does not exist: `{}`", 326 parent.display() 327 )); 328 } 329 let canonical_parent = parent.canonicalize().map_err(|error| { 330 format!( 331 "failed to resolve generated DTO root parent `{}`: {error}", 332 parent.display() 333 ) 334 })?; 335 if !canonical_parent.starts_with(&canonical_root) { 336 return Err(format!( 337 "generated DTO root parent escapes the workspace: `{}`", 338 parent.display() 339 )); 340 } 341 let metadata = match output.path.symlink_metadata() { 342 Ok(metadata) => Some(metadata), 343 Err(error) if error.kind() == ErrorKind::NotFound => None, 344 Err(error) => { 345 return Err(format!( 346 "failed to inspect generated DTO roots `{}`: {error}", 347 output.display_path 348 )); 349 } 350 }; 351 if metadata 352 .as_ref() 353 .is_some_and(|metadata| metadata.file_type().is_symlink()) 354 { 355 return Err(format!( 356 "generated DTO root cannot be a symlink: `{}`", 357 output.display_path 358 )); 359 } 360 if metadata 361 .as_ref() 362 .is_some_and(|metadata| !metadata.is_file()) 363 { 364 return Err(format!( 365 "generated DTO root is not a regular file: `{}`", 366 output.display_path 367 )); 368 } 369 } 370 Ok(()) 371 } 372 373 fn write_generated_roots(generated: &[GeneratedRootFile]) -> Result<(), String> { 374 write_generated_roots_with(generated, persist_temporary_file) 375 } 376 377 fn write_generated_roots_with<F>( 378 generated: &[GeneratedRootFile], 379 mut persist: F, 380 ) -> Result<(), String> 381 where 382 F: FnMut(NamedTempFile, &Path) -> Result<(), PersistFailure>, 383 { 384 let staged = stage_generated_roots(generated)?; 385 let mut committed = Vec::with_capacity(staged.len()); 386 for output in staged { 387 let original = OriginalRootFile { 388 path: output.path.clone(), 389 display_path: output.display_path.clone(), 390 permissions: output 391 .original 392 .as_ref() 393 .map(|original| original.permissions.clone()), 394 contents: output.original.map(|original| original.contents), 395 }; 396 if let Err((_temporary, error)) = persist(output.temporary, &output.path) { 397 let rollback = rollback_original_roots(&committed); 398 return Err(write_failure_with_rollback( 399 &output.display_path, 400 &error, 401 rollback, 402 )); 403 } 404 committed.push(original); 405 } 406 407 if let Err(error) = check_generated_roots(generated) { 408 let rollback = rollback_original_roots(&committed); 409 return Err(match rollback { 410 Ok(()) => format!("{error}; restored all generated DTO roots"), 411 Err(rollback_error) => format!("{error}; rollback also failed: {rollback_error}"), 412 }); 413 } 414 Ok(()) 415 } 416 417 fn stage_generated_roots(generated: &[GeneratedRootFile]) -> Result<Vec<StagedRootFile>, String> { 418 let mut staged = Vec::new(); 419 for output in generated { 420 let original = match fs::read(&output.path) { 421 Ok(current) if current == output.contents.as_bytes() => continue, 422 Ok(current) => { 423 let permissions = fs::metadata(&output.path) 424 .map_err(|error| { 425 format!( 426 "failed to inspect generated DTO roots `{}` before staging: {error}", 427 output.display_path 428 ) 429 })? 430 .permissions(); 431 Some(OriginalFileState { 432 contents: current, 433 permissions, 434 }) 435 } 436 Err(error) if error.kind() == ErrorKind::NotFound => None, 437 Err(error) => { 438 return Err(format!( 439 "failed to read generated DTO roots `{}` before staging: {error}", 440 output.display_path 441 )); 442 } 443 }; 444 let parent = output.path.parent().ok_or_else(|| { 445 format!( 446 "generated DTO root path has no parent: `{}`", 447 output.display_path 448 ) 449 })?; 450 let desired_permissions = original 451 .as_ref() 452 .map(|original| original.permissions.clone()) 453 .or_else(default_generated_file_permissions); 454 let temporary = stage_bytes( 455 parent, 456 output.contents.as_bytes(), 457 desired_permissions.as_ref(), 458 ) 459 .map_err(|error| { 460 format!( 461 "failed to stage generated DTO roots `{}`: {error}", 462 output.display_path 463 ) 464 })?; 465 staged.push(StagedRootFile { 466 path: output.path.clone(), 467 display_path: output.display_path.clone(), 468 temporary, 469 original, 470 }); 471 } 472 Ok(staged) 473 } 474 475 fn stage_bytes( 476 parent: &Path, 477 contents: &[u8], 478 permissions: Option<&fs::Permissions>, 479 ) -> io::Result<NamedTempFile> { 480 let mut temporary = NamedTempFile::new_in(parent)?; 481 temporary.write_all(contents)?; 482 temporary.flush()?; 483 if let Some(permissions) = permissions { 484 fs::set_permissions(temporary.path(), permissions.clone())?; 485 } 486 temporary.as_file().sync_all()?; 487 let staged = fs::read(temporary.path())?; 488 if staged != contents { 489 return Err(io::Error::other("staged DTO root bytes do not match input")); 490 } 491 Ok(temporary) 492 } 493 494 #[cfg(unix)] 495 fn default_generated_file_permissions() -> Option<fs::Permissions> { 496 use std::os::unix::fs::PermissionsExt; 497 498 Some(fs::Permissions::from_mode(0o644)) 499 } 500 501 #[cfg(not(unix))] 502 fn default_generated_file_permissions() -> Option<fs::Permissions> { 503 None 504 } 505 506 fn persist_temporary_file( 507 temporary: NamedTempFile, 508 destination: &Path, 509 ) -> Result<(), PersistFailure> { 510 temporary 511 .persist(destination) 512 .map(|_| ()) 513 .map_err(|error| (error.file, error.error)) 514 } 515 516 fn rollback_original_roots(committed: &[OriginalRootFile]) -> Result<(), String> { 517 let mut failures = Vec::new(); 518 for original in committed.iter().rev() { 519 let result = if let Some(contents) = &original.contents { 520 let parent = original.path.parent().ok_or_else(|| { 521 io::Error::other(format!( 522 "generated DTO root path has no parent: `{}`", 523 original.display_path 524 )) 525 }); 526 parent 527 .and_then(|parent| stage_bytes(parent, contents, original.permissions.as_ref())) 528 .and_then(|temporary| { 529 persist_temporary_file(temporary, &original.path) 530 .map_err(|(_temporary, error)| error) 531 }) 532 } else { 533 match fs::remove_file(&original.path) { 534 Ok(()) => Ok(()), 535 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), 536 Err(error) => Err(error), 537 } 538 }; 539 if let Err(error) = result { 540 failures.push(format!("`{}`: {error}", original.display_path)); 541 } 542 } 543 if failures.is_empty() { 544 Ok(()) 545 } else { 546 Err(failures.join("; ")) 547 } 548 } 549 550 fn write_failure_with_rollback( 551 display_path: &str, 552 error: &io::Error, 553 rollback: Result<(), String>, 554 ) -> String { 555 match rollback { 556 Ok(()) => format!( 557 "failed to commit generated DTO roots `{display_path}`: {error}; restored all previously committed outputs" 558 ), 559 Err(rollback_error) => format!( 560 "failed to commit generated DTO roots `{display_path}`: {error}; rollback also failed: {rollback_error}" 561 ), 562 } 563 } 564 565 #[cfg(test)] 566 mod tests { 567 use super::*; 568 use std::cell::Cell; 569 use tempfile::TempDir; 570 571 fn fixture_workspace() -> TempDir { 572 let workspace = TempDir::new().expect("create fixture workspace"); 573 let root = workspace.path(); 574 fs::create_dir_all(root.join("crates/demo/src/generated")) 575 .expect("create fixture workspace"); 576 fs::write( 577 root.join("dto_bindgen.toml"), 578 r#"schema_version = 1 579 580 [[package]] 581 key = "demo" 582 rust_package = "demo" 583 rust_crate = "demo" 584 npm = "@radroots/demo" 585 out_dir = "target/demo" 586 587 [package.root_discovery] 588 mode = "source_manifest" 589 source_files = ["crates/demo/src/model.rs"] 590 root_module_file = "crates/demo/src/generated/dto_roots.rs" 591 "#, 592 ) 593 .expect("write fixture config"); 594 fs::write( 595 root.join("crates/demo/src/model.rs"), 596 r#"#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] 597 #[cfg_attr(feature = "dto-bindgen", dto(export))] 598 pub struct DemoDto { 599 pub value: String, 600 } 601 "#, 602 ) 603 .expect("write fixture source"); 604 workspace 605 } 606 607 fn add_second_package(root: &Path) { 608 fs::create_dir_all(root.join("crates/second/src/generated")) 609 .expect("create second package"); 610 let config_path = root.join("dto_bindgen.toml"); 611 let mut config = fs::read_to_string(&config_path).expect("read fixture config"); 612 config.push_str( 613 r#" 614 [[package]] 615 key = "second" 616 rust_package = "second" 617 rust_crate = "second" 618 npm = "@radroots/second" 619 out_dir = "target/second" 620 621 [package.root_discovery] 622 mode = "source_manifest" 623 source_files = ["crates/second/src/model.rs"] 624 root_module_file = "crates/second/src/generated/dto_roots.rs" 625 "#, 626 ); 627 fs::write(config_path, config).expect("write two-package config"); 628 fs::write( 629 root.join("crates/second/src/model.rs"), 630 r#"#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] 631 #[cfg_attr(feature = "dto-bindgen", dto(export))] 632 pub struct SecondDto { 633 pub value: String, 634 } 635 "#, 636 ) 637 .expect("write second fixture source"); 638 } 639 640 #[test] 641 fn write_then_check_is_deterministic() { 642 let workspace = fixture_workspace(); 643 let root = workspace.path(); 644 run(&["--write".to_owned()], root).expect("write roots"); 645 let output = root.join("crates/demo/src/generated/dto_roots.rs"); 646 let first = fs::read_to_string(&output).expect("read generated roots"); 647 assert!(first.contains("crate::model::DemoDto")); 648 649 run(&["--write".to_owned()], root).expect("repeat write roots"); 650 assert_eq!( 651 fs::read_to_string(&output).expect("read repeated roots"), 652 first 653 ); 654 check(root).expect("fresh roots"); 655 } 656 657 #[test] 658 fn check_aggregates_two_package_drift_without_mutation() { 659 let workspace = fixture_workspace(); 660 let root = workspace.path(); 661 add_second_package(root); 662 run(&["--write".to_owned()], root).expect("write roots"); 663 let demo = root.join("crates/demo/src/generated/dto_roots.rs"); 664 let second = root.join("crates/second/src/generated/dto_roots.rs"); 665 fs::write(&demo, "stale\n").expect("write drift"); 666 fs::remove_file(&second).expect("remove generated roots"); 667 668 let stale = check(root).expect_err("reject stale roots"); 669 assert!(stale.contains("stale `crates/demo/src/generated/dto_roots.rs`")); 670 assert!(stale.contains("missing `crates/second/src/generated/dto_roots.rs`")); 671 assert!(stale.contains("cargo xtask dto-roots --write")); 672 assert_eq!(fs::read_to_string(&demo).expect("read drift"), "stale\n"); 673 assert!(!second.exists()); 674 675 run(&["--write".to_owned()], root).expect("repair both roots"); 676 check(root).expect("both roots fresh"); 677 assert!( 678 fs::read_to_string(demo) 679 .expect("read demo roots") 680 .contains("DemoDto") 681 ); 682 assert!( 683 fs::read_to_string(second) 684 .expect("read second roots") 685 .contains("SecondDto") 686 ); 687 } 688 689 #[test] 690 fn rejects_invalid_modes_paths_and_missing_authority() { 691 let workspace = fixture_workspace(); 692 let root = workspace.path(); 693 for args in [ 694 Vec::<String>::new(), 695 vec!["--unknown".to_owned()], 696 vec!["--check".to_owned(), "extra".to_owned()], 697 vec!["--check".to_owned(), "--write".to_owned()], 698 ] { 699 let mode = run(&args, root).expect_err("require one explicit valid mode"); 700 assert!(mode.contains("--check|--write")); 701 } 702 703 for invalid in ["", "/tmp/output.rs", "../output.rs", "a\\b.rs", "a/./b.rs"] { 704 assert!(workspace_path(root, invalid).is_err(), "accepted {invalid}"); 705 } 706 707 fs::remove_file(root.join("dto_bindgen.toml")).expect("remove authority"); 708 let missing = check(root).expect_err("reject missing authority"); 709 assert!(missing.contains("failed to inspect DTO root authority path component")); 710 } 711 712 #[test] 713 fn supports_top_level_source_manifest_authority() { 714 let workspace = fixture_workspace(); 715 let root = workspace.path(); 716 fs::write( 717 root.join("dto_bindgen.toml"), 718 r#"schema_version = 1 719 720 [root_discovery] 721 mode = "source_manifest" 722 source_files = ["crates/demo/src/model.rs"] 723 root_module_file = "crates/demo/src/generated/dto_roots.rs" 724 "#, 725 ) 726 .expect("write top-level root authority"); 727 728 run(&["--write".to_owned()], root).expect("write top-level roots"); 729 check(root).expect("top-level roots fresh"); 730 assert!( 731 fs::read_to_string(root.join("crates/demo/src/generated/dto_roots.rs")) 732 .expect("read top-level roots") 733 .contains("DemoDto") 734 ); 735 } 736 737 #[test] 738 fn event_routed_sources_generate_crate_visible_module_paths() { 739 for (physical, routed) in [ 740 ("account.rs", "profile/account.rs"), 741 ("app_data.rs", "social/app_data.rs"), 742 ("comment.rs", "post/comment.rs"), 743 ("coop.rs", "farm/coop.rs"), 744 ("document.rs", "post/document.rs"), 745 ("follow.rs", "social/follow.rs"), 746 ("gcs.rs", "farm/change_set.rs"), 747 ("geochat.rs", "social/geochat.rs"), 748 ("gift_wrap.rs", "social/gift_wrap.rs"), 749 ("job.rs", "social/job.rs"), 750 ("job_feedback.rs", "social/job_feedback.rs"), 751 ("job_request.rs", "social/job_request.rs"), 752 ("job_result.rs", "social/job_result.rs"), 753 ("list.rs", "social/list.rs"), 754 ("list_set.rs", "social/list_set.rs"), 755 ("message.rs", "social/message.rs"), 756 ("message_file.rs", "social/message_file.rs"), 757 ("operational_listing.rs", "listing/operational.rs"), 758 ("order.rs", "trade/order.rs"), 759 ("order_economics.rs", "trade/order_economics.rs"), 760 ("plot.rs", "farm/plot.rs"), 761 ("reaction.rs", "post/reaction.rs"), 762 ("relay_document.rs", "social/relay_document.rs"), 763 ("resource_area.rs", "farm/resource_area.rs"), 764 ("resource_cap.rs", "farm/resource_cap.rs"), 765 ("seal.rs", "social/seal.rs"), 766 ("trade_validation.rs", "trade/validation.rs"), 767 ] { 768 let physical = format!("crates/event/src/{physical}"); 769 let routed = format!("crates/event/src/{routed}"); 770 assert_eq!(canonical_dto_source_path(&physical), routed); 771 } 772 773 assert_eq!( 774 canonical_dto_source_path("crates/event/src/farm.rs"), 775 "crates/event/src/farm.rs" 776 ); 777 assert_eq!( 778 canonical_dto_source_path("crates/core/src/money.rs"), 779 "crates/core/src/money.rs" 780 ); 781 } 782 783 #[test] 784 fn persist_failure_rolls_back_every_committed_output() { 785 let workspace = fixture_workspace(); 786 let root = workspace.path(); 787 add_second_package(root); 788 let config = Config::from_toml_path(root.join("dto_bindgen.toml")) 789 .expect("load two-package authority"); 790 let generated = generate_configured_roots(root, &config).expect("generate roots"); 791 validate_output_paths(root, &generated).expect("validate root paths"); 792 let demo = root.join("crates/demo/src/generated/dto_roots.rs"); 793 let second = root.join("crates/second/src/generated/dto_roots.rs"); 794 fs::write(&demo, "original demo\n").expect("write original demo"); 795 fs::write(&second, "original second\n").expect("write original second"); 796 #[cfg(unix)] 797 { 798 use std::os::unix::fs::PermissionsExt; 799 800 fs::set_permissions(&demo, fs::Permissions::from_mode(0o640)) 801 .expect("set demo permissions"); 802 fs::set_permissions(&second, fs::Permissions::from_mode(0o604)) 803 .expect("set second permissions"); 804 } 805 806 let persist_count = Cell::new(0_u8); 807 let error = write_generated_roots_with(&generated, |temporary, destination| { 808 persist_count.set(persist_count.get() + 1); 809 if persist_count.get() == 2 { 810 Err((temporary, io::Error::other("injected persist failure"))) 811 } else { 812 persist_temporary_file(temporary, destination) 813 } 814 }) 815 .expect_err("second persist must fail"); 816 817 assert!(error.contains("injected persist failure")); 818 assert!(error.contains("restored all previously committed outputs")); 819 assert_eq!( 820 fs::read_to_string(&demo).expect("read demo"), 821 "original demo\n" 822 ); 823 assert_eq!( 824 fs::read_to_string(&second).expect("read second"), 825 "original second\n" 826 ); 827 #[cfg(unix)] 828 { 829 assert_eq!(unix_mode(&demo), 0o640); 830 assert_eq!(unix_mode(&second), 0o604); 831 } 832 } 833 834 #[test] 835 fn persist_failure_removes_a_newly_committed_output() { 836 let workspace = fixture_workspace(); 837 let root = workspace.path(); 838 add_second_package(root); 839 let config = Config::from_toml_path(root.join("dto_bindgen.toml")) 840 .expect("load two-package authority"); 841 let generated = generate_configured_roots(root, &config).expect("generate roots"); 842 validate_output_paths(root, &generated).expect("validate root paths"); 843 let demo = root.join("crates/demo/src/generated/dto_roots.rs"); 844 let second = root.join("crates/second/src/generated/dto_roots.rs"); 845 fs::write(&second, "original second\n").expect("write original second"); 846 847 let persist_count = Cell::new(0_u8); 848 write_generated_roots_with(&generated, |temporary, destination| { 849 persist_count.set(persist_count.get() + 1); 850 if persist_count.get() == 2 { 851 Err((temporary, io::Error::other("injected persist failure"))) 852 } else { 853 persist_temporary_file(temporary, destination) 854 } 855 }) 856 .expect_err("second persist must fail"); 857 858 assert!(!demo.exists()); 859 assert_eq!( 860 fs::read_to_string(second).expect("read second"), 861 "original second\n" 862 ); 863 } 864 865 #[cfg(unix)] 866 fn unix_mode(path: &Path) -> u32 { 867 use std::os::unix::fs::PermissionsExt; 868 869 fs::metadata(path) 870 .expect("read permissions") 871 .permissions() 872 .mode() 873 & 0o777 874 } 875 876 #[cfg(unix)] 877 #[test] 878 fn write_creates_and_preserves_governed_source_permissions() { 879 use std::os::unix::fs::PermissionsExt; 880 881 let workspace = fixture_workspace(); 882 let root = workspace.path(); 883 let output = root.join("crates/demo/src/generated/dto_roots.rs"); 884 run(&["--write".to_owned()], root).expect("create generated roots"); 885 assert_eq!(unix_mode(&output), 0o644); 886 887 fs::write(&output, "stale\n").expect("make roots stale"); 888 fs::set_permissions(&output, fs::Permissions::from_mode(0o640)) 889 .expect("set custom permissions"); 890 run(&["--write".to_owned()], root).expect("replace generated roots"); 891 assert_eq!(unix_mode(&output), 0o640); 892 } 893 894 #[cfg(unix)] 895 #[test] 896 fn check_rejects_generated_output_symlinks() { 897 use std::os::unix::fs::symlink; 898 899 let workspace = fixture_workspace(); 900 let root = workspace.path(); 901 let output = root.join("crates/demo/src/generated/dto_roots.rs"); 902 let target = root.join("target.rs"); 903 fs::write(&target, "outside authority\n").expect("write symlink target"); 904 symlink(&target, &output).expect("create generated root symlink"); 905 906 let error = check(root).expect_err("reject generated root symlink"); 907 assert!(error.contains("generated DTO root path contains a symlink component")); 908 } 909 910 #[cfg(unix)] 911 #[test] 912 fn check_rejects_source_and_output_parent_symlinks() { 913 use std::os::unix::fs::symlink; 914 915 let source_workspace = fixture_workspace(); 916 let source_root = source_workspace.path(); 917 let source = source_root.join("crates/demo/src/model.rs"); 918 let outside_workspace = TempDir::new().expect("create outside workspace"); 919 let outside_source = outside_workspace.path().join("outside_source.rs"); 920 fs::write( 921 &outside_source, 922 fs::read_to_string(&source).expect("read source"), 923 ) 924 .expect("write outside source"); 925 fs::remove_file(&source).expect("remove source"); 926 symlink(&outside_source, &source).expect("create source symlink"); 927 928 let source_error = check(source_root).expect_err("reject source symlink"); 929 assert!(source_error.contains("DTO source path contains a symlink component")); 930 931 let output_workspace = fixture_workspace(); 932 let output_root = output_workspace.path(); 933 let output_parent = output_root.join("crates/demo/src/generated"); 934 let outside_parent_workspace = TempDir::new().expect("create outside output workspace"); 935 let outside_parent = outside_parent_workspace.path().join("generated"); 936 fs::create_dir_all(&outside_parent).expect("create outside output parent"); 937 fs::remove_dir(&output_parent).expect("remove output parent"); 938 symlink(&outside_parent, &output_parent).expect("create output parent symlink"); 939 940 let output_error = check(output_root).expect_err("reject output parent symlink"); 941 assert!(output_error.contains("generated DTO root path contains a symlink component")); 942 } 943 944 #[test] 945 fn authority_and_sources_must_be_regular_files() { 946 let source_workspace = fixture_workspace(); 947 let source_root = source_workspace.path(); 948 let source = source_root.join("crates/demo/src/model.rs"); 949 fs::remove_file(&source).expect("remove source file"); 950 fs::create_dir(&source).expect("create source directory"); 951 let source_error = check(source_root).expect_err("reject source directory"); 952 assert!(source_error.contains("DTO source must be a regular file")); 953 954 let authority_workspace = fixture_workspace(); 955 let authority_root = authority_workspace.path(); 956 let authority = authority_root.join("dto_bindgen.toml"); 957 fs::remove_file(&authority).expect("remove authority file"); 958 fs::create_dir(&authority).expect("create authority directory"); 959 let authority_error = check(authority_root).expect_err("reject authority directory"); 960 assert!(authority_error.contains("DTO root authority must be a regular file")); 961 } 962 }