1use std::collections::BTreeSet;
15use std::ffi::OsString;
16use std::fs;
17use std::path::{Component, Path, PathBuf};
18
19use clap::{Parser, Subcommand};
20#[cfg(test)]
21use type_bridge_schema::SystemSchemaSourceService;
22use type_bridge_schema_migration::MigrationGenerationOutcome;
23use type_bridge_schema_migration_typedb::execution_capability_vocabulary;
24use type_bridge_workspace::{
25 ConfigOrigin, ExtensionRegistryService, ExtensionRequirement, SecretReference,
26 SecretReferenceService, TypeBridgeConfigSpec, TypeBridgeWorkspace, TypeBridgeWorkspaceServices,
27 WorkspaceDirectoryAuthority, WorkspaceEnvironment, WorkspaceRoot, WorkspaceServiceError,
28 WorkspaceTransportPolicy,
29};
30
31#[derive(Parser)]
32#[command(
33 name = "type-bridge",
34 version,
35 about = "TypeBridge V2 workspace commands"
36)]
37struct Cli {
38 #[arg(long, global = true, default_value = "typebridge.yaml")]
40 manifest: PathBuf,
41 #[command(subcommand)]
42 command: Command,
43}
44
45#[derive(Subcommand)]
46enum Command {
47 Schema {
49 #[command(subcommand)]
50 command: SchemaCommand,
51 },
52 Migration {
54 #[command(subcommand)]
55 command: MigrationCommand,
56 },
57}
58
59#[derive(Subcommand)]
60enum SchemaCommand {
61 Check,
63 Generate,
65 ExportDeclared {
67 #[arg(long, default_value = "declared-schema.json")]
69 output: PathBuf,
70 },
71}
72
73#[derive(Subcommand)]
74enum MigrationCommand {
75 Make {
77 #[arg(long)]
79 name: String,
80 },
81 Plan,
83 Apply {
85 #[arg(long)]
87 environment: String,
88 #[arg(long = "approve")]
90 approvals: Vec<String>,
91 },
92 Verify {
94 #[arg(long)]
96 environment: String,
97 },
98 Adopt {
100 #[arg(long)]
102 environment: String,
103 #[arg(long)]
105 legacy_directory: PathBuf,
106 #[arg(long, default_value = "0000_legacy_frontier")]
108 name: String,
109 },
110}
111
112struct DeferSecrets;
114
115impl SecretReferenceService for DeferSecrets {
116 fn validate_reference(
117 &self,
118 _reference: &SecretReference,
119 ) -> Result<(), WorkspaceServiceError> {
120 Ok(())
121 }
122}
123
124struct NoExtensions;
126
127impl ExtensionRegistryService for NoExtensions {
128 fn validate_requirement(
129 &self,
130 _requirement: &ExtensionRequirement,
131 ) -> Result<(), WorkspaceServiceError> {
132 Err(WorkspaceServiceError::new(
133 "extension_handlers_unavailable_in_cli",
134 ))
135 }
136}
137
138pub fn run_cli<I, T>(arguments: I) -> i32
145where
146 I: IntoIterator<Item = T>,
147 T: Into<OsString> + Clone,
148{
149 let cli = match Cli::try_parse_from(arguments) {
150 Ok(cli) => cli,
151 Err(error) => {
152 let _ = error.print();
153 return if error.use_stderr() { 2 } else { 0 };
154 }
155 };
156 match run(&cli) {
157 Ok(()) => 0,
158 Err(message) => {
159 eprintln!("error: {message}");
160 1
161 }
162 }
163}
164
165fn run(cli: &Cli) -> Result<(), String> {
166 let workspace = load_workspace(&cli.manifest)?;
167 match &cli.command {
168 Command::Schema {
169 command: SchemaCommand::Check,
170 } => {
171 println!(
172 "schema sources are valid\n declared identity: {}\n managed semantics: {}",
173 workspace
174 .declared_schema()
175 .declared_identity_fingerprint()
176 .as_fingerprint()
177 .digest()
178 .to_hex(),
179 workspace
180 .managed_state()
181 .managed_semantic_schema()
182 .as_fingerprint()
183 .digest()
184 .to_hex(),
185 );
186 Ok(())
187 }
188 Command::Schema {
189 command: SchemaCommand::Generate,
190 } => run_schema_generate(&workspace),
191 Command::Schema {
192 command: SchemaCommand::ExportDeclared { output },
193 } => run_schema_export_declared(&workspace, output),
194 Command::Migration { command } => match command {
195 MigrationCommand::Make { name } => {
196 let directory = workspace.open_migration_directory().map_err(display)?;
197 match workspace
198 .migration_make_in(&directory, name)
199 .map_err(display)?
200 {
201 MigrationGenerationOutcome::UpToDate => {
202 println!("history already reaches the desired schema");
203 }
204 MigrationGenerationOutcome::Generated(generated) => {
205 workspace
206 .write_generated_migration_in(&directory, &generated)
207 .map_err(display)?;
208 let path = directory.display_path().join(generated.file_name());
209 println!(
210 "wrote {}\n safety: {:?}\n preview: {}",
211 path.display(),
212 generated.manifest().safety(),
213 path.with_file_name(generated.preview_file_name()).display(),
214 );
215 }
216 }
217 Ok(())
218 }
219 MigrationCommand::Apply {
220 environment,
221 approvals,
222 } => run_connected(
223 &workspace,
224 environment,
225 ConnectedAction::Apply {
226 approvals: approvals.clone(),
227 },
228 ),
229 MigrationCommand::Verify { environment } => {
230 run_connected(&workspace, environment, ConnectedAction::Verify)
231 }
232 MigrationCommand::Adopt {
233 environment,
234 legacy_directory,
235 name,
236 } => run_connected(
237 &workspace,
238 environment,
239 ConnectedAction::Adopt {
240 legacy_directory: legacy_directory.clone(),
241 name: name.clone(),
242 },
243 ),
244 MigrationCommand::Plan => {
245 let directory = workspace.open_migration_directory().map_err(display)?;
246 let plan = workspace
247 .migration_plan_in(&directory, &BTreeSet::new())
248 .map_err(display)?;
249 if plan.is_empty() {
250 println!("no committed migrations");
251 return Ok(());
252 }
253 for entry in plan {
254 println!(
255 "{}/{} safety={:?} reversible={}",
256 entry.id().app_label().as_str(),
257 entry.id().name().as_str(),
258 entry.safety(),
259 entry.reversible(),
260 );
261 }
262 Ok(())
263 }
264 },
265 }
266}
267
268fn load_workspace(manifest: &PathBuf) -> Result<TypeBridgeWorkspace, String> {
269 let manifest = fs::canonicalize(manifest)
270 .map_err(|error| format!("cannot resolve {}: {error}", manifest.display()))?;
271 let root = manifest
272 .parent()
273 .ok_or_else(|| "workspace manifest has no parent directory".to_owned())?;
274 let file_name = manifest
275 .file_name()
276 .and_then(|name| name.to_str())
277 .ok_or_else(|| "workspace manifest has no UTF-8 file name".to_owned())?;
278 let root = WorkspaceRoot::new(root).map_err(display)?;
279 let source = WorkspaceDirectoryAuthority::open(root.clone()).map_err(display)?;
280 let origin = ConfigOrigin::new(root, file_name, "type-bridge cli").map_err(display)?;
281 let limit = type_bridge_contract::limits::MAX_CANONICAL_BYTES;
285 let captured = source
286 .capture_relative_file(Path::new(file_name), limit)
287 .map_err(|error| format!("cannot read {}: {error}", manifest.display()))?;
288 let bytes = captured.bytes();
289 if bytes.len() > limit {
290 return Err(format!(
291 "{} exceeds the 16 MiB manifest ceiling",
292 manifest.display()
293 ));
294 }
295 let located = TypeBridgeConfigSpec::from_yaml_bytes(bytes, origin).map_err(display)?;
296
297 let available = execution_capability_vocabulary().map_err(display)?;
298 let secrets = DeferSecrets;
299 let extensions = NoExtensions;
300 let services = TypeBridgeWorkspaceServices::new(&source, &secrets, &extensions, &available);
301 TypeBridgeWorkspace::from_located_config(located, &services).map_err(display)
303}
304
305fn display(error: impl std::fmt::Display) -> String {
306 error.to_string()
307}
308
309fn sanitize_connected_error(
316 context: String,
317 code: &'static str,
318 error: type_bridge_orm::SecureConnectError,
319) -> String {
320 match error.credential_safe_diagnostic() {
321 Some(diagnostic) => format!("{context}: {diagnostic}"),
322 None => format!("{context} [{code}]; inspect provider logs"),
323 }
324}
325
326fn sanitize_schema_export_error(_error: type_bridge_orm::OrmError) -> String {
329 "cannot export the managed schema [typedb_schema_export_failed]; inspect provider logs"
330 .to_owned()
331}
332
333fn sanitize_migration_execution_outcome(
340 context: &str,
341 outcome: type_bridge_schema_migration::MigrationExecutionOutcome,
342) -> String {
343 use type_bridge_schema_migration::{
344 MigrationExecutionOutcome as Outcome, MigrationExecutionPosition as Position,
345 };
346
347 let render_position = |position| match position {
348 Position::TransactionGroup(ordinal) => format!("transaction group {ordinal}"),
349 Position::ManifestCheckpoint => "manifest checkpoint".to_owned(),
350 };
351 match outcome {
352 Outcome::Applied => format!("{context}: applied"),
353 Outcome::RetrySafe {
354 migration_id,
355 position,
356 diagnostic,
357 } => format!(
358 "{context}: retry-safe at {}/{} ({position}): {diagnostic}",
359 migration_id.app_label().as_str(),
360 migration_id.name().as_str(),
361 position = render_position(position),
362 ),
363 Outcome::RequiresExplicitRecovery {
364 migration_id,
365 position,
366 diagnostic,
367 } => format!(
368 "{context}: explicit recovery required at {}/{} ({position}): {diagnostic}",
369 migration_id.app_label().as_str(),
370 migration_id.name().as_str(),
371 position = render_position(position),
372 ),
373 }
374}
375
376fn run_schema_generate(workspace: &TypeBridgeWorkspace) -> Result<(), String> {
385 use type_bridge_contract::projection::{BindingTarget, ProjectionConfig};
386 use type_bridge_schema::project;
387 use type_bridge_schema_codegen::{PythonEmitter, RustEmitter, TypeScriptEmitter};
388
389 let outputs = workspace.config().outputs();
390 if outputs.is_empty() {
391 return Err(
392 "no binding outputs configured; add bindings.<target>.output to the manifest".into(),
393 );
394 }
395
396 let resolved = workspace.resolved_schema();
397
398 for (target, directory) in outputs {
399 let package = match target {
400 BindingTarget::Python => {
401 let emitter = PythonEmitter::new();
402 let projection = project(
403 resolved,
404 BindingTarget::Python,
405 &ProjectionConfig::python(),
406 &emitter.generator_handlers(),
407 &emitter.code_resources().map_err(display)?,
408 )
409 .map_err(display)?;
410 emitter.emit(&projection)
411 }
412 BindingTarget::TypeScript => {
413 let emitter = TypeScriptEmitter::new();
414 let projection = project(
415 resolved,
416 BindingTarget::TypeScript,
417 &ProjectionConfig::typescript(),
418 &emitter.generator_handlers(),
419 &emitter.code_resources().map_err(display)?,
420 )
421 .map_err(display)?;
422 emitter.emit(&projection)
423 }
424 BindingTarget::Rust => {
425 let emitter = RustEmitter::new();
426 let projection = project(
427 resolved,
428 BindingTarget::Rust,
429 &ProjectionConfig::rust(),
430 &emitter.generator_handlers(),
431 &emitter.code_resources().map_err(display)?,
432 )
433 .map_err(display)?;
434 emitter.emit_with_declared_schema(&projection, workspace.declared_schema())
435 }
436 }
437 .map_err(display)?;
438
439 let workspace_root = workspace.output_root()?;
440 let root = workspace_root.open_beneath(directory.as_path())?;
441 for (path, bytes) in package.files() {
442 let relative = std::path::Path::new(path);
443 validate_generated_relative_path(relative)?;
444 let parent = root.open_beneath(
445 relative
446 .parent()
447 .unwrap_or_else(|| std::path::Path::new("")),
448 )?;
449 let file_name = relative
450 .file_name()
451 .ok_or_else(|| format!("generated path {path:?} has no file name"))?;
452 parent.write_atomic(file_name, bytes)?;
453 }
454 println!(
455 "generated {} file(s) for {} into {}",
456 package.files().len(),
457 match target {
458 BindingTarget::Python => "python",
459 BindingTarget::TypeScript => "typescript",
460 BindingTarget::Rust => "rust",
461 },
462 root.display_path().display(),
463 );
464 }
465 Ok(())
466}
467
468fn run_schema_export_declared(
470 workspace: &TypeBridgeWorkspace,
471 output: &Path,
472) -> Result<(), String> {
473 use type_bridge_contract::schema::encode_declared_schema;
474
475 validate_declared_output_path(output)?;
476 let root = workspace.output_root()?;
477 let parent = root.open_beneath(output.parent().unwrap_or_else(|| std::path::Path::new("")))?;
478 let file_name = output
479 .file_name()
480 .ok_or_else(|| "declared-schema output has no file name".to_owned())?;
481 let destination = parent.display_path().join(file_name);
482 let bytes = encode_declared_schema(workspace.declared_schema()).map_err(display)?;
483 parent.write_atomic(file_name, &bytes)?;
484 println!(
485 "wrote canonical declared schema to {}\n declared identity: {}",
486 destination.display(),
487 workspace
488 .declared_schema()
489 .declared_identity_fingerprint()
490 .as_fingerprint()
491 .digest()
492 .to_hex(),
493 );
494 Ok(())
495}
496
497fn validate_declared_output_path(output: &Path) -> Result<(), String> {
498 let Some(portable) = output.to_str() else {
499 return Err("declared-schema output must be valid UTF-8".into());
500 };
501 let invalid_spelling = portable.is_empty()
502 || portable.contains(['\\', ':', '\0'])
503 || portable.bytes().any(|byte| byte.is_ascii_control())
504 || portable
505 .split('/')
506 .any(|segment| segment.is_empty() || matches!(segment, "." | ".."));
507 let invalid_components = output.is_absolute()
508 || output
509 .components()
510 .any(|component| !matches!(component, Component::Normal(_)));
511 if invalid_spelling || invalid_components {
512 return Err("declared-schema output must be a confined portable workspace path".into());
513 }
514 if output.extension().and_then(|extension| extension.to_str()) != Some("json") {
515 return Err("declared-schema output must end in lowercase .json".into());
516 }
517 Ok(())
518}
519
520fn validate_generated_relative_path(path: &std::path::Path) -> Result<(), String> {
521 if path.as_os_str().is_empty()
522 || path
523 .components()
524 .any(|component| !matches!(component, std::path::Component::Normal(_)))
525 {
526 return Err(format!(
527 "generated output path {:?} is not a confined relative file",
528 path
529 ));
530 }
531 Ok(())
532}
533
534enum ConnectedAction {
535 Apply {
536 approvals: Vec<String>,
537 },
538 Verify,
539 Adopt {
540 legacy_directory: PathBuf,
541 name: String,
542 },
543}
544
545fn secure_connect_options(
546 environment: &WorkspaceEnvironment,
547) -> type_bridge_orm::SecureConnectOptions {
548 let tls_mode = match environment.transport_policy() {
549 WorkspaceTransportPolicy::Disabled => type_bridge_orm::TlsMode::Disabled,
550 WorkspaceTransportPolicy::NativeRoots => type_bridge_orm::TlsMode::NativeRoots,
551 WorkspaceTransportPolicy::CustomRootCa(root_ca) => {
552 type_bridge_orm::TlsMode::CustomRootCa(root_ca.as_path().to_path_buf())
553 }
554 };
555 let mut options = type_bridge_orm::SecureConnectOptions {
556 tls_mode,
557 ..type_bridge_orm::SecureConnectOptions::default()
558 };
559 if let Some(port) = environment.http_port() {
560 options.http_port = port;
561 }
562 options
563}
564
565fn preflight_secure_connect_options(
566 workspace: &TypeBridgeWorkspace,
567 environment_name: &str,
568) -> Result<type_bridge_orm::PreparedSecureConnectOptions, String> {
569 let environment = workspace
570 .config()
571 .environment(environment_name)
572 .ok_or_else(|| {
573 format!("environment {environment_name:?} is not owned by this workspace")
574 })?;
575 let options = secure_connect_options(environment);
576 match workspace
577 .capture_environment_custom_root_ca(environment_name)
578 .map_err(display)?
579 {
580 Some(bytes) => options
581 .prepare_transport_from_captured_custom_root(bytes)
582 .map_err(display),
583 None => options.prepare_transport().map_err(display),
584 }
585}
586
587fn run_connected(
588 workspace: &TypeBridgeWorkspace,
589 environment: &str,
590 action: ConnectedAction,
591) -> Result<(), String> {
592 let runtime = tokio::runtime::Runtime::new()
593 .map_err(|error| format!("cannot start the async runtime: {error}"))?;
594 runtime.block_on(run_connected_async(workspace, environment, action))
595}
596
597async fn run_connected_async(
598 workspace: &TypeBridgeWorkspace,
599 environment_name: &str,
600 action: ConnectedAction,
601) -> Result<(), String> {
602 let config = workspace.config();
603 let Some(environment) = config.environment(environment_name) else {
604 let known = config
605 .environments()
606 .keys()
607 .cloned()
608 .collect::<Vec<_>>()
609 .join(", ");
610 return Err(format!(
611 "unknown environment {environment_name:?}; the manifest declares: [{known}]"
612 ));
613 };
614 if matches!(
615 &action,
616 ConnectedAction::Apply { .. } | ConnectedAction::Adopt { .. }
617 ) && !environment.migrate()
618 {
619 return Err(format!(
620 "environment {environment_name:?} is not opted into migration \
621 application; set `migrate: true` in the manifest to allow it"
622 ));
623 }
624 environment
625 .requirements()
626 .ensure_supported_by(&execution_capability_vocabulary().map_err(display)?)
627 .map_err(display)?;
628
629 let prepared_adoption = match &action {
632 ConnectedAction::Adopt {
633 legacy_directory,
634 name,
635 } => Some(prepare_legacy_adoption(workspace, legacy_directory, name)?),
636 ConnectedAction::Apply { .. } | ConnectedAction::Verify => None,
637 };
638
639 let migration_directory = if matches!(&action, ConnectedAction::Adopt { .. }) {
643 workspace.ensure_migration_directory().map_err(display)?
644 } else {
645 workspace.open_migration_directory().map_err(display)?
646 };
647 let ordinary_graph = if prepared_adoption.is_none() {
651 Some(
652 workspace
653 .discover_migrations_in(&migration_directory)
654 .map_err(display)?,
655 )
656 } else {
657 None
658 };
659 let prepared_approvals = match &action {
664 ConnectedAction::Apply { approvals } => Some(bind_approvals(
665 ordinary_graph
666 .as_ref()
667 .ok_or_else(|| "internal apply history was not retained".to_owned())?,
668 approvals,
669 )?),
670 ConnectedAction::Verify | ConnectedAction::Adopt { .. } => None,
671 };
672
673 let options = preflight_secure_connect_options(workspace, environment_name)?;
677 let username = resolve_credential(environment.username())?;
678 let password = resolve_credential(environment.password())?;
679 let journal_name =
680 type_bridge_schema_migration_typedb::derived_journal_database_name(environment.database());
681 let managed_requires_existing = match &action {
688 ConnectedAction::Verify => Some(
689 "`migration verify` is read-only and never creates databases \
690 — apply migrations to this environment first",
691 ),
692 ConnectedAction::Adopt { .. } => {
693 Some("`migration adopt` cutover requires the migrated v1 database to already exist")
694 }
695 ConnectedAction::Apply { .. } => None,
696 };
697 if let Some(reason) = managed_requires_existing {
698 let exists = type_bridge_orm::database_exists_prepared_secure(
699 environment.uri(),
700 environment.database(),
701 &username,
702 &password,
703 options.clone(),
704 )
705 .await
706 .map_err(|error| {
707 sanitize_connected_error(
708 format!("cannot check database {:?}", environment.database()),
709 "typedb_database_exists_failed",
710 error,
711 )
712 })?;
713 if !exists {
714 return Err(format!(
715 "database {:?} does not exist; {reason}",
716 environment.database()
717 ));
718 }
719 } else {
720 type_bridge_orm::ensure_database_exists_prepared_secure(
721 environment.uri(),
722 environment.database(),
723 &username,
724 &password,
725 options.clone(),
726 )
727 .await
728 .map_err(|error| {
729 sanitize_connected_error(
730 format!("cannot ensure database {:?}", environment.database()),
731 "typedb_database_ensure_failed",
732 error,
733 )
734 })?;
735 }
736 let managed = std::sync::Arc::new(
737 type_bridge_orm::Database::connect_prepared_secure_with_options(
738 environment.uri(),
739 environment.database(),
740 &username,
741 &password,
742 options.clone(),
743 )
744 .await
745 .map_err(|error| {
746 sanitize_connected_error(
747 "cannot connect the managed database".to_owned(),
748 "typedb_database_connect_failed",
749 error,
750 )
751 })?,
752 );
753
754 let adoption_files = if let Some(prepared) = prepared_adoption.as_ref() {
759 verify_prepared_adoption_live(&managed, prepared).await?;
760 Some(publish_prepared_adoption(
761 workspace,
762 &migration_directory,
763 prepared,
764 )?)
765 } else {
766 None
767 };
768
769 if matches!(&action, ConnectedAction::Verify) {
770 let exists = type_bridge_orm::database_exists_prepared_secure(
771 environment.uri(),
772 &journal_name,
773 &username,
774 &password,
775 options.clone(),
776 )
777 .await
778 .map_err(|error| {
779 sanitize_connected_error(
780 format!("cannot check database {journal_name:?}"),
781 "typedb_database_exists_failed",
782 error,
783 )
784 })?;
785 if !exists {
786 return Err(format!(
787 "database {journal_name:?} does not exist; `migration verify` is read-only and never creates databases"
788 ));
789 }
790 } else {
791 type_bridge_orm::ensure_database_exists_prepared_secure(
792 environment.uri(),
793 &journal_name,
794 &username,
795 &password,
796 options.clone(),
797 )
798 .await
799 .map_err(|error| {
800 sanitize_connected_error(
801 format!("cannot ensure database {journal_name:?}"),
802 "typedb_database_ensure_failed",
803 error,
804 )
805 })?;
806 }
807 let journal = std::sync::Arc::new(
808 type_bridge_orm::Database::connect_prepared_secure_with_options(
809 environment.uri(),
810 &journal_name,
811 &username,
812 &password,
813 options,
814 )
815 .await
816 .map_err(|error| {
817 sanitize_connected_error(
818 "cannot connect the journal database".to_owned(),
819 "typedb_database_connect_failed",
820 error,
821 )
822 })?,
823 );
824
825 let genesis = workspace
826 .migration_genesis_in(&migration_directory)
827 .map_err(display)?;
828 let lowering = type_bridge_schema_migration::SchemaLoweringBinding::current(
829 workspace.delta_context().available_capabilities().clone(),
830 )
831 .map_err(display)?;
832 let runner = type_bridge_schema_migration_typedb::TypeDbMigrationRunner::new(
833 managed,
834 journal,
835 genesis.clone(),
836 workspace.delta_context().clone(),
837 lowering,
838 config.migration_policy().clone(),
839 );
840 let holder =
841 type_bridge_schema_migration::LeaseHolderId::new("type-bridge-cli").map_err(display)?;
842 let directory = migration_directory.directory();
843
844 match action {
845 ConnectedAction::Apply { .. } => {
846 let approvals = prepared_approvals
847 .as_deref()
848 .ok_or_else(|| "internal apply approvals were not retained".to_owned())?;
849 let outcome = runner
850 .apply_in(
851 directory,
852 &type_bridge_schema_migration::MigrationApplyTarget::DefaultHead,
853 &holder,
854 approvals,
855 )
856 .await
857 .map_err(display)?;
858 match outcome {
859 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::UpToDate => {
860 println!("applied ledger already reaches the committed head");
861 Ok(())
862 }
863 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
864 type_bridge_schema_migration::MigrationExecutionOutcome::Applied,
865 ) => {
866 println!("applied the committed chain");
867 Ok(())
868 }
869 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
870 outcome,
871 ) => Err(sanitize_migration_execution_outcome(
872 "apply did not complete",
873 outcome,
874 )),
875 }
876 }
877 ConnectedAction::Verify => {
878 let report = runner
879 .verify_in(directory, Some(workspace.declared_schema()))
880 .await
881 .map_err(display)?;
882 if report.is_clean() {
883 println!(
884 "migration state is coherent\n applied frontier: {}",
885 report
886 .applied_frontier()
887 .iter()
888 .map(|id| format!("{}/{}", id.app_label().as_str(), id.name().as_str()))
889 .collect::<Vec<_>>()
890 .join(", "),
891 );
892 Ok(())
893 } else {
894 for finding in report.findings() {
895 eprintln!("drift: {finding:?}");
896 }
897 Err(format!("{} drift finding(s)", report.findings().len()))
898 }
899 }
900 ConnectedAction::Adopt { .. } => {
901 let bridge_display_path = adoption_files
902 .ok_or_else(|| "internal adoption preflight state was not retained".to_owned())?;
903 let prepared = prepared_adoption
904 .as_ref()
905 .ok_or_else(|| "internal adoption authority was not retained".to_owned())?;
906 let outcome = runner
907 .import_verified_legacy_frontier_in(
908 &prepared.history,
909 &prepared.reconstructed,
910 directory,
911 &holder,
912 )
913 .await;
914 match outcome {
915 Ok(
916 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::UpToDate,
917 ) => {
918 println!("legacy history is already adopted; the bridged ledger is current");
919 Ok(())
920 }
921 Ok(
922 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
923 type_bridge_schema_migration::MigrationExecutionOutcome::Applied,
924 ),
925 ) => {
926 println!(
927 "adopted the legacy history\n genesis: {}\n bridge: {}",
928 migration_directory
929 .display_path()
930 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
931 .display(),
932 bridge_display_path.display(),
933 );
934 Ok(())
935 }
936 Ok(
937 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
938 outcome,
939 ),
940 ) => Err(sanitize_migration_execution_outcome(
941 "adoption checkpoint did not complete",
942 outcome,
943 )),
944 Err(error) => Err(display(error)),
945 }
946 }
947 }
948}
949
950struct PreparedLegacyAdoption {
951 history: type_bridge_migration::LegacyAdoptionHistory,
952 reconstructed: type_bridge_migration::VerifiedLegacyHead,
953 authority: type_bridge_schema_compat::AdoptedGenesisAuthority,
954 bridge: type_bridge_schema_migration::VerifiedSchemaMigrationManifest,
955 bridge_name: String,
956 bridge_bytes: Vec<u8>,
957}
958
959fn prepare_legacy_adoption(
962 workspace: &TypeBridgeWorkspace,
963 legacy_directory: &std::path::Path,
964 name: &str,
965) -> Result<PreparedLegacyAdoption, String> {
966 let migration_name =
969 type_bridge_contract::migration::MigrationName::new(name.to_owned()).map_err(display)?;
970 let bridge_name = format!("{}.tbmigration.json", migration_name.as_str());
971 let history =
972 type_bridge_migration::load_adoption_history(legacy_directory).map_err(|error| {
973 format!("legacy migration directory failed the checked adoption loader: {error}")
974 })?;
975 let reconstructed = type_bridge_migration::reconstruct_legacy_head(&history)
976 .map_err(|error| format!("legacy head reconstruction failed: {error}"))?;
977 let authority = type_bridge_schema_compat::parse_adopted_genesis_authority(
978 type_bridge_contract::schema::DocumentId::new("legacy-head-snapshot.typeql")
979 .map_err(display)?,
980 reconstructed.schema_typeql(),
981 )
982 .map_err(display)?;
983 let frontier = type_bridge_schema_migration_typedb::extract_legacy_frontier(history.graph())
984 .map_err(display)?;
985 let applied_set =
986 type_bridge_schema_migration_typedb::extract_legacy_applied_set_digest(history.graph())
987 .map_err(display)?;
988 let id = type_bridge_contract::migration::MigrationId::from_components(
989 type_bridge_contract::migration::MigrationAppLabel::new(
990 workspace.config().app_label().as_str().to_owned(),
991 )
992 .map_err(display)?,
993 migration_name,
994 );
995 let bridge = type_bridge_schema_migration::build_legacy_frontier_bridge(
996 id,
997 frontier,
998 applied_set,
999 authority.declared(),
1000 workspace.delta_context(),
1001 )
1002 .map_err(display)?;
1003 let bridge_bytes =
1004 type_bridge_schema_migration::encode_verified_manifest(&bridge).map_err(display)?;
1005 history
1006 .require_unchanged_head(&reconstructed)
1007 .map_err(|error| {
1008 format!("legacy migration directory changed during adoption preparation: {error}")
1009 })?;
1010 Ok(PreparedLegacyAdoption {
1011 history,
1012 reconstructed,
1013 authority,
1014 bridge,
1015 bridge_name,
1016 bridge_bytes,
1017 })
1018}
1019
1020async fn verify_prepared_adoption_live(
1023 managed: &type_bridge_orm::Database,
1024 prepared: &PreparedLegacyAdoption,
1025) -> Result<(), String> {
1026 let export = managed
1027 .schema_text()
1028 .await
1029 .map_err(sanitize_schema_export_error)?;
1030 prepared
1031 .history
1032 .require_unchanged_head(&prepared.reconstructed)
1033 .map_err(|error| format!("legacy adoption history changed during live export: {error}"))?;
1034 let expected_internal = type_bridge_schema_compat::released_typeql_to_declared_projection(
1035 type_bridge_contract::schema::DocumentId::new("managed-fence-schema.typeql")
1036 .map_err(display)?,
1037 type_bridge_schema_migration_typedb::MANAGED_FENCE_SCHEMA_TYPEQL,
1038 )
1039 .map_err(display)?;
1040 let live = type_bridge_schema_compat::parse_adopted_genesis_authority_with_internal(
1041 type_bridge_contract::schema::DocumentId::new("legacy-live-head.typeql")
1042 .map_err(display)?,
1043 &export,
1044 Some(&expected_internal),
1045 )
1046 .map_err(display)?;
1047 if live.legacy_identity() != prepared.authority.legacy_identity()
1048 || live.declared().declared_identity_fingerprint()
1049 != prepared
1050 .authority
1051 .declared()
1052 .declared_identity_fingerprint()
1053 || live.released_extension_identity() != prepared.authority.released_extension_identity()
1054 {
1055 return Err(
1056 "live managed schema differs from the independently verified legacy-head snapshot"
1057 .to_owned(),
1058 );
1059 }
1060 Ok(())
1061}
1062
1063fn publish_prepared_adoption(
1070 workspace: &TypeBridgeWorkspace,
1071 migration_directory: &type_bridge_workspace::MigrationDirectoryAuthority,
1072 prepared: &PreparedLegacyAdoption,
1073) -> Result<PathBuf, String> {
1074 publish_prepared_adoption_with_after_bridge(workspace, migration_directory, prepared, || {})
1075}
1076
1077fn publish_prepared_adoption_with_after_bridge<F>(
1078 workspace: &TypeBridgeWorkspace,
1079 migration_directory: &type_bridge_workspace::MigrationDirectoryAuthority,
1080 prepared: &PreparedLegacyAdoption,
1081 after_bridge: F,
1082) -> Result<PathBuf, String>
1083where
1084 F: FnOnce(),
1085{
1086 let directory = migration_directory.directory();
1087 let _lock = directory.try_acquire_authoring_lock().map_err(|error| {
1088 if error.kind() == std::io::ErrorKind::WouldBlock {
1089 "migration adoption conflicts with another canonical history publisher".to_owned()
1090 } else {
1091 format!("cannot lock canonical migration publication: {error}")
1092 }
1093 })?;
1094 let genesis_name = type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME;
1095 let genesis_bytes = prepared.reconstructed.schema_typeql().as_bytes();
1096 let mut bridge_created = false;
1097 let mut genesis_created = false;
1098 let mut after_bridge = Some(after_bridge);
1099
1100 let publication = (|| -> Result<(), String> {
1101 if let Some(existing) = read_existing_authority(directory, genesis_name)?
1102 && existing != genesis_bytes
1103 {
1104 return Err(format!(
1105 "{genesis_name} already exists but differs from the verified legacy-head snapshot"
1106 ));
1107 }
1108 let bridge_already_published =
1109 if let Some(existing) = read_existing_authority(directory, &prepared.bridge_name)? {
1110 if existing != prepared.bridge_bytes {
1111 return Err(format!(
1112 "{} already exists with different authority bytes",
1113 prepared.bridge_name
1114 ));
1115 }
1116 true
1117 } else {
1118 false
1119 };
1120
1121 let (current, evidence) =
1122 type_bridge_schema_migration::discover_verified_migration_chain_with_evidence_in(
1123 directory,
1124 prepared.authority.declared(),
1125 workspace.delta_context(),
1126 )
1127 .map_err(display)?;
1128 let prospective = if current.manifest(prepared.bridge.id()).is_some() {
1129 current
1130 } else {
1131 let manifests = current
1132 .manifests()
1133 .map(|(_, manifest)| manifest.clone())
1134 .chain(std::iter::once(prepared.bridge.clone()))
1135 .collect::<Vec<_>>();
1136 type_bridge_schema_migration::MigrationHistoryGraph::from_verified(manifests)
1137 .map_err(display)?
1138 };
1139 type_bridge_schema_migration::require_adoption_authority_pair(&prospective, true)
1140 .map_err(display)?;
1141 evidence.require_unchanged(directory).map_err(display)?;
1142 prepared
1143 .history
1144 .require_unchanged_head(&prepared.reconstructed)
1145 .map_err(|error| {
1146 format!("legacy adoption history changed before pair publication: {error}")
1147 })?;
1148
1149 if !bridge_already_published {
1150 bridge_created =
1151 publish_authority(directory, &prepared.bridge_name, &prepared.bridge_bytes)?;
1152 }
1153 if let Some(after_bridge) = after_bridge.take() {
1154 after_bridge();
1155 }
1156 prepared
1157 .history
1158 .require_unchanged_head(&prepared.reconstructed)
1159 .map_err(|error| {
1160 format!("legacy adoption history changed before genesis publication: {error}")
1161 })?;
1162 genesis_created = publish_authority(directory, genesis_name, genesis_bytes)?;
1163 prepared
1164 .history
1165 .require_unchanged_head(&prepared.reconstructed)
1166 .map_err(|error| {
1167 format!("legacy adoption history changed after pair publication: {error}")
1168 })?;
1169 workspace
1170 .discover_migrations_in(migration_directory)
1171 .map_err(display)?;
1172 Ok(())
1173 })();
1174
1175 if let Err(error) = publication {
1176 return Err(rollback_adoption_publication(
1177 directory,
1178 &prepared.bridge_name,
1179 bridge_created,
1180 genesis_created,
1181 error,
1182 ));
1183 }
1184 Ok(migration_directory
1185 .display_path()
1186 .join(&prepared.bridge_name))
1187}
1188
1189fn rollback_adoption_publication(
1190 directory: &type_bridge_schema_migration::MigrationDirectory,
1191 bridge_name: &str,
1192 bridge_created: bool,
1193 genesis_created: bool,
1194 primary: String,
1195) -> String {
1196 let mut cleanup_errors = Vec::new();
1197 if genesis_created
1198 && let Err(error) =
1199 directory.remove_file(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME.as_ref())
1200 {
1201 cleanup_errors.push(format!("cannot remove newly published genesis: {error}"));
1202 }
1203 if bridge_created && let Err(error) = directory.remove_file(bridge_name.as_ref()) {
1204 cleanup_errors.push(format!("cannot remove newly published bridge: {error}"));
1205 }
1206 if (bridge_created || genesis_created)
1207 && let Err(error) = directory.sync_all()
1208 {
1209 cleanup_errors.push(format!("cannot flush adoption rollback: {error}"));
1210 }
1211 if cleanup_errors.is_empty() {
1212 primary
1213 } else {
1214 format!(
1215 "{primary}; adoption publication rollback failed: {}",
1216 cleanup_errors.join("; ")
1217 )
1218 }
1219}
1220
1221fn publish_authority(
1230 directory: &type_bridge_schema_migration::MigrationDirectory,
1231 name: &str,
1232 bytes: &[u8],
1233) -> Result<bool, String> {
1234 use std::io::Write;
1235 if let Some(existing) = read_existing_authority(directory, name)? {
1236 if existing == bytes {
1237 return Ok(false);
1238 }
1239 return Err(format!(
1240 "{name} already exists with different authority bytes"
1241 ));
1242 }
1243 let mut temporary = None;
1244 for attempt in 0..128_u64 {
1245 let candidate = unique_authority_temporary_name(name, attempt);
1246 match directory.create_new(candidate.as_ref()) {
1247 Ok(mut file) => {
1248 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1249 let _ = directory.remove_file(candidate.as_ref());
1250 return Err(format!("cannot write {candidate}: {error}"));
1251 }
1252 temporary = Some(candidate);
1253 break;
1254 }
1255 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1256 Err(error) => {
1257 return Err(format!("cannot create {candidate}: {error}"));
1258 }
1259 }
1260 }
1261 let temporary = temporary.ok_or_else(|| {
1262 format!("cannot allocate a unique temporary authority file beside {name}")
1263 })?;
1264 let publication = directory.hard_link(temporary.as_ref(), name.as_ref());
1265 match publication {
1266 Ok(()) => {
1267 if let Err(error) = directory.sync_all() {
1268 let _ = directory.remove_file(temporary.as_ref());
1269 return Err(format!("cannot flush migration directory: {error}"));
1270 }
1271 let _ = directory.remove_file(temporary.as_ref());
1272 directory
1273 .sync_all()
1274 .map_err(|error| format!("cannot flush migration directory: {error}"))?;
1275 Ok(true)
1276 }
1277 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1278 let _ = directory.remove_file(temporary.as_ref());
1279 let existing = read_existing_authority(directory, name)?
1280 .ok_or_else(|| format!("{name} disappeared during no-replace publication"))?;
1281 if existing == bytes {
1282 Ok(false)
1283 } else {
1284 Err(format!(
1285 "{name} was concurrently published with different authority bytes"
1286 ))
1287 }
1288 }
1289 Err(error) => {
1290 let _ = directory.remove_file(temporary.as_ref());
1291 Err(format!("cannot publish {name}: {error}"))
1292 }
1293 }
1294}
1295
1296fn read_existing_authority(
1297 directory: &type_bridge_schema_migration::MigrationDirectory,
1298 name: &str,
1299) -> Result<Option<Vec<u8>>, String> {
1300 use std::io::Read;
1301 let file = match directory.open_regular_readonly(name.as_ref()) {
1302 Ok(file) => file,
1303 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1304 Err(error) => return Err(format!("cannot read {name}: {error}")),
1305 };
1306 let limit = type_bridge_contract::limits::MAX_CANONICAL_BYTES;
1307 let mut bytes = Vec::new();
1308 file.take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
1309 .read_to_end(&mut bytes)
1310 .map_err(|error| format!("cannot read {name}: {error}"))?;
1311 if bytes.len() > limit {
1312 return Err(format!("{name} exceeds the 16 MiB authority ceiling"));
1313 }
1314 Ok(Some(bytes))
1315}
1316
1317fn unique_authority_temporary_name(name: &str, attempt: u64) -> String {
1318 use std::sync::atomic::{AtomicU64, Ordering};
1319 static NEXT_AUTHORITY_TEMPORARY: AtomicU64 = AtomicU64::new(1);
1320 let nonce = NEXT_AUTHORITY_TEMPORARY.fetch_add(1, Ordering::Relaxed);
1321 format!(".{name}.{}.{}.{}.tmp", std::process::id(), nonce, attempt)
1322}
1323
1324fn resolve_credential(
1325 reference: &type_bridge_workspace::SecretReference,
1326) -> Result<String, String> {
1327 std::env::var(reference.environment_variable()).map_err(|_| {
1328 format!(
1329 "credential environment variable {:?} is not set",
1330 reference.environment_variable()
1331 )
1332 })
1333}
1334
1335#[cfg(test)]
1336mod credential_error_redaction_tests {
1337 use super::*;
1338 use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
1339 use type_bridge_typedb_runtime::RuntimeError;
1340
1341 const PROVIDER_TEXT: &str =
1342 "TB_ADDRESS_SECRET TB_USERNAME_SECRET TB_PASSWORD_SECRET TB_PROVIDER_SECRET";
1343 const SECRETS: [&str; 4] = [
1344 "TB_ADDRESS_SECRET",
1345 "TB_USERNAME_SECRET",
1346 "TB_PASSWORD_SECRET",
1347 "TB_PROVIDER_SECRET",
1348 ];
1349
1350 fn hostile_secure_error() -> type_bridge_orm::SecureConnectError {
1351 type_bridge_orm::SecureConnectError::Runtime(RuntimeError::Connection(
1352 PROVIDER_TEXT.to_owned(),
1353 ))
1354 }
1355
1356 #[test]
1357 fn connected_lifecycle_contexts_drop_hostile_provider_text() {
1358 for (context, code) in [
1359 (
1360 "cannot check database \"managed\"",
1361 "typedb_database_exists_failed",
1362 ),
1363 (
1364 "cannot ensure database \"managed\"",
1365 "typedb_database_ensure_failed",
1366 ),
1367 (
1368 "cannot connect the managed database",
1369 "typedb_database_connect_failed",
1370 ),
1371 ] {
1372 let sanitized =
1373 sanitize_connected_error(context.to_owned(), code, hostile_secure_error());
1374 let rendered = format!("{sanitized}\n{sanitized:?}");
1375 for secret in SECRETS {
1376 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1377 }
1378 assert!(rendered.contains(context), "{rendered}");
1379 assert!(rendered.contains(code), "{rendered}");
1380 }
1381 }
1382
1383 #[test]
1384 fn connected_lifecycle_preserves_only_typed_safe_diagnostics() {
1385 let sanitized = sanitize_connected_error(
1386 "cannot connect the managed database".to_owned(),
1387 "typedb_database_connect_failed",
1388 type_bridge_orm::SecureConnectError::DriverTlsConfiguration { band: 9 },
1389 );
1390 assert!(
1391 sanitized.contains("tls_driver_lowering_failed"),
1392 "{sanitized}"
1393 );
1394 assert!(sanitized.contains("driver band 9"), "{sanitized}");
1395 assert!(
1396 !sanitized.contains("typedb_database_connect_failed"),
1397 "{sanitized}"
1398 );
1399 }
1400
1401 #[test]
1402 fn schema_export_drops_hostile_orm_text_and_source() {
1403 let sanitized = sanitize_schema_export_error(type_bridge_orm::OrmError::Connection(
1404 PROVIDER_TEXT.into(),
1405 ));
1406 let rendered = format!("{sanitized}\n{sanitized:?}");
1407 for secret in SECRETS {
1408 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1409 }
1410 assert!(
1411 rendered.contains("typedb_schema_export_failed"),
1412 "{rendered}"
1413 );
1414 }
1415
1416 #[test]
1417 fn migration_runner_display_omits_provider_details() {
1418 let diagnostic = Diagnostic::new(
1419 DiagnosticCategory::InvalidContract,
1420 DiagnosticCode::new("migration_provider_test_failed").expect("static code"),
1421 "migration provider operation failed",
1422 )
1423 .with_detail("provider", PROVIDER_TEXT);
1424 let error = type_bridge_schema_migration_typedb::MigrationDirectoryApplyError::Diagnostic(
1425 diagnostic,
1426 );
1427
1428 let rendered = display(error);
1429 for secret in SECRETS {
1430 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1431 }
1432 assert!(
1433 rendered.contains("migration_provider_test_failed"),
1434 "{rendered}"
1435 );
1436 }
1437
1438 #[test]
1439 fn migration_outcome_projection_omits_provider_details_for_apply_and_adopt() {
1440 use type_bridge_contract::migration::MigrationId;
1441 use type_bridge_schema_migration::{MigrationExecutionOutcome, MigrationExecutionPosition};
1442
1443 let diagnostic = || {
1444 Diagnostic::new(
1445 DiagnosticCategory::InvalidContract,
1446 DiagnosticCode::new("migration_provider_test_failed").expect("static code"),
1447 "migration provider operation failed",
1448 )
1449 .with_detail("provider", PROVIDER_TEXT)
1450 };
1451 for context in [
1452 "apply did not complete",
1453 "adoption checkpoint did not complete",
1454 ] {
1455 for (outcome, expected_state, expected_position) in [
1456 (
1457 MigrationExecutionOutcome::RetrySafe {
1458 migration_id: MigrationId::new("example", "0001_initial")
1459 .expect("migration id"),
1460 position: MigrationExecutionPosition::TransactionGroup(7),
1461 diagnostic: diagnostic(),
1462 },
1463 "retry-safe",
1464 "transaction group 7",
1465 ),
1466 (
1467 MigrationExecutionOutcome::RequiresExplicitRecovery {
1468 migration_id: MigrationId::new("example", "0001_initial")
1469 .expect("migration id"),
1470 position: MigrationExecutionPosition::ManifestCheckpoint,
1471 diagnostic: diagnostic(),
1472 },
1473 "explicit recovery required",
1474 "manifest checkpoint",
1475 ),
1476 ] {
1477 let rendered = sanitize_migration_execution_outcome(context, outcome);
1478 for secret in SECRETS {
1479 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1480 }
1481 for expected in [
1482 context,
1483 expected_state,
1484 "example/0001_initial",
1485 expected_position,
1486 "migration_provider_test_failed",
1487 "migration provider operation failed",
1488 ] {
1489 assert!(rendered.contains(expected), "{expected}: {rendered}");
1490 }
1491 }
1492 }
1493 }
1494}
1495
1496#[cfg(all(test, unix))]
1497mod output_authority_tests {
1498 use super::*;
1499 use std::os::unix::fs::symlink;
1500
1501 #[test]
1502 fn retained_output_authority_survives_component_swap_without_redirecting() {
1503 let workspace = tempfile::tempdir().expect("workspace directory");
1504 let outside = tempfile::tempdir().expect("outside directory");
1505 fs::create_dir_all(workspace.path().join("generated/python")).expect("output directory");
1506 let authority = WorkspaceDirectoryAuthority::open(
1507 WorkspaceRoot::new(fs::canonicalize(workspace.path()).expect("canonical workspace"))
1508 .expect("workspace root"),
1509 )
1510 .expect("workspace authority");
1511 let root = authority.output_root().expect("output authority");
1512 let output = root
1513 .open_beneath(Path::new("generated/python"))
1514 .expect("output authority");
1515
1516 let held = workspace.path().join("generated/python-held");
1517 fs::rename(workspace.path().join("generated/python"), &held)
1518 .expect("move retained output directory");
1519 symlink(outside.path(), workspace.path().join("generated/python"))
1520 .expect("redirect configured output path");
1521
1522 output
1523 .write_atomic("_models.py".as_ref(), b"retained authority")
1524 .expect("publication remains handle-relative");
1525 assert_eq!(
1526 fs::read(held.join("_models.py")).expect("retained output reads"),
1527 b"retained authority"
1528 );
1529 assert!(
1530 !outside.path().join("_models.py").exists(),
1531 "component replacement redirected output outside the workspace"
1532 );
1533 }
1534
1535 #[test]
1536 fn retained_output_root_survives_root_entry_swap_without_redirecting() {
1537 let workspace = tempfile::tempdir().expect("workspace directory");
1538 let outside = tempfile::tempdir().expect("outside directory");
1539 fs::create_dir_all(workspace.path().join("generated/python")).expect("output directory");
1540 let authority = WorkspaceDirectoryAuthority::open(
1541 WorkspaceRoot::new(fs::canonicalize(workspace.path()).expect("canonical workspace"))
1542 .expect("workspace root"),
1543 )
1544 .expect("workspace authority");
1545 let root = authority.output_root().expect("output authority");
1546 let held = workspace
1547 .path()
1548 .parent()
1549 .expect("temporary parent")
1550 .join(format!(
1551 "{}-retained",
1552 workspace
1553 .path()
1554 .file_name()
1555 .expect("temporary name")
1556 .to_string_lossy()
1557 ));
1558 fs::rename(workspace.path(), &held).expect("workspace root moves after validation");
1559 symlink(outside.path(), workspace.path()).expect("workspace name redirects outside");
1560
1561 let output = root
1562 .open_beneath(Path::new("generated/python"))
1563 .expect("output opens through retained root");
1564 output
1565 .write_atomic("_models.py".as_ref(), b"retained root authority")
1566 .expect("publication remains rooted in the retained handle");
1567 assert_eq!(
1568 fs::read(held.join("generated/python/_models.py")).expect("retained output reads"),
1569 b"retained root authority"
1570 );
1571 assert!(
1572 !outside.path().join("generated/python/_models.py").exists(),
1573 "root replacement redirected output outside the workspace"
1574 );
1575
1576 fs::remove_file(workspace.path()).expect("replacement symlink removes");
1577 fs::rename(&held, workspace.path()).expect("workspace restores for cleanup");
1578 }
1579}
1580
1581fn bind_approvals(
1582 graph: &type_bridge_schema_migration::MigrationHistoryGraph,
1583 approvals: &[String],
1584) -> Result<Vec<type_bridge_schema_migration::MigrationApplyApproval>, String> {
1585 if approvals.is_empty() {
1586 return Ok(Vec::new());
1587 }
1588 approvals
1589 .iter()
1590 .map(|compound| {
1591 let (app_label, name) = compound
1592 .split_once('/')
1593 .ok_or_else(|| format!("approval {compound:?} must be app-label/name"))?;
1594 let id = type_bridge_contract::migration::MigrationId::from_components(
1595 type_bridge_contract::migration::MigrationAppLabel::new(app_label.to_owned())
1596 .map_err(display)?,
1597 type_bridge_contract::migration::MigrationName::new(name.to_owned())
1598 .map_err(display)?,
1599 );
1600 let manifest = graph.manifest(&id).ok_or_else(|| {
1601 format!("approval target {compound:?} is not in the committed history")
1602 })?;
1603 type_bridge_schema_migration::MigrationApplyApproval::for_manifest(manifest)
1604 .map_err(display)
1605 })
1606 .collect()
1607}
1608
1609#[cfg(test)]
1610mod transport_option_tests {
1611 use super::*;
1612
1613 fn environment(policy: WorkspaceTransportPolicy) -> WorkspaceEnvironment {
1614 WorkspaceEnvironment::new(
1615 "typedb.example:1729",
1616 "example",
1617 SecretReference::environment("TYPEBRIDGE_TEST_USERNAME").expect("username reference"),
1618 SecretReference::environment("TYPEBRIDGE_TEST_PASSWORD").expect("password reference"),
1619 )
1620 .expect("environment")
1621 .with_transport_policy(policy)
1622 }
1623
1624 fn custom_root_workspace(root_bytes: &[u8]) -> (tempfile::TempDir, TypeBridgeWorkspace) {
1625 let directory = tempfile::tempdir().expect("workspace directory");
1626 fs::create_dir_all(directory.path().join("schema/fragments")).expect("schema directory");
1627 fs::create_dir_all(directory.path().join("migrations/v2")).expect("migration directory");
1628 fs::create_dir_all(directory.path().join("certs")).expect("certificate directory");
1629 fs::write(
1630 directory.path().join("schema/schema.yaml"),
1631 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
1632 )
1633 .expect("schema set writes");
1634 fs::write(
1635 directory.path().join("schema/fragments/model.yaml"),
1636 "format: typebridge.schema/v2\nentities: {person: {}}\n",
1637 )
1638 .expect("schema writes");
1639 fs::write(directory.path().join("certs/root.pem"), root_bytes).expect("certificate writes");
1640 let manifest = directory.path().join("typebridge.yaml");
1641 fs::write(
1642 &manifest,
1643 "format: typebridge.workspace/v1\n\
1644 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: tls-test\n\
1645 compatibility:\n semantic-profile: typedb-3.12.1/v1\n\
1646 migrations:\n directory: migrations/v2\n app-label: tlstest\n\
1647 environments:\n dev:\n database: tls_test\n uri: never-contact.invalid:1729\n \
1648 tls: 'true'\n tls-root-ca: certs/root.pem\n credential:\n username: \
1649 env:TYPEBRIDGE_TEST_USERNAME\n password: env:TYPEBRIDGE_TEST_PASSWORD\n",
1650 )
1651 .expect("manifest writes");
1652 let workspace = load_workspace(&manifest).expect("custom-root workspace loads");
1653 (directory, workspace)
1654 }
1655
1656 #[test]
1657 fn workspace_transport_policy_maps_without_changing_plaintext_defaults() {
1658 let defaults = type_bridge_orm::SecureConnectOptions::default();
1659 let disabled = secure_connect_options(&environment(WorkspaceTransportPolicy::Disabled));
1660 assert_eq!(disabled.tls_mode, type_bridge_orm::TlsMode::Disabled);
1661 assert_eq!(disabled.http_port, defaults.http_port);
1662 assert_eq!(disabled.server_version, defaults.server_version);
1663
1664 let native = secure_connect_options(
1665 &environment(WorkspaceTransportPolicy::NativeRoots).with_http_port(9443),
1666 );
1667 assert_eq!(native.tls_mode, type_bridge_orm::TlsMode::NativeRoots);
1668 assert_eq!(native.http_port, 9443);
1669 assert_eq!(native.server_version, defaults.server_version);
1670 }
1671
1672 #[test]
1673 fn custom_root_mapping_preserves_the_validated_canonical_path() {
1674 let directory = tempfile::tempdir().expect("workspace directory");
1675 let canonical = fs::canonicalize(directory.path()).expect("canonical workspace");
1676 fs::create_dir_all(canonical.join("certs")).expect("certificate directory");
1677 fs::write(
1678 canonical.join("certs/root.pem"),
1679 b"not parsed at workspace boundary\n",
1680 )
1681 .expect("certificate writes");
1682 let root = WorkspaceRoot::new(canonical.clone()).expect("workspace root");
1683 let root_ca = type_bridge_workspace::WorkspaceRootCa::new(
1684 &root,
1685 "certs/root.pem",
1686 &SystemSchemaSourceService,
1687 )
1688 .expect("confined root CA");
1689
1690 let options = secure_connect_options(
1691 &environment(WorkspaceTransportPolicy::CustomRootCa(root_ca)).with_http_port(8443),
1692 );
1693 assert_eq!(
1694 options.tls_mode,
1695 type_bridge_orm::TlsMode::CustomRootCa(canonical.join("certs/root.pem"))
1696 );
1697 assert_eq!(options.http_port, 8443);
1698 }
1699
1700 #[test]
1701 fn malformed_custom_root_fails_transport_preflight_before_credentials_are_needed() {
1702 let (_directory, workspace) = custom_root_workspace(b"definitely not a certificate\n");
1703
1704 let error = preflight_secure_connect_options(&workspace, "dev")
1705 .expect_err("PEM parsing must happen before credential resolution");
1706 assert!(error.contains("tls_custom_root_ca_invalid_pem"), "{error}");
1707 assert!(!error.contains("TYPEBRIDGE_TEST_USERNAME"), "{error}");
1708 assert!(!error.contains("TYPEBRIDGE_TEST_PASSWORD"), "{error}");
1709 }
1710
1711 #[cfg(unix)]
1712 #[test]
1713 fn workspace_root_swap_to_outside_symlink_is_rejected_at_transport_preflight() {
1714 use std::os::unix::fs::symlink;
1715
1716 let (directory, workspace) = custom_root_workspace(b"initial regular root\n");
1717 let outside = tempfile::tempdir().expect("outside directory");
1718 let configured = directory.path().join("certs/root.pem");
1719
1720 let outside_root = outside.path().join("malicious.pem");
1721 fs::write(
1722 &outside_root,
1723 include_bytes!("../../core/tests/fixtures/valid-root.pem"),
1724 )
1725 .expect("write outside replacement root");
1726 fs::remove_file(&configured).expect("remove validated confined root");
1727 symlink(&outside_root, &configured).expect("install outside symlink after validation");
1728
1729 let error = preflight_secure_connect_options(&workspace, "dev")
1730 .expect_err("retained workspace paths must never follow a replacement symlink");
1731 assert!(error.contains("tls_custom_root_ca_unreadable"), "{error}");
1732 assert!(!error.contains("tls_custom_root_ca_invalid_pem"), "{error}");
1733 }
1734
1735 #[cfg(unix)]
1736 #[test]
1737 fn real_directory_root_replacement_cannot_substitute_custom_trust() {
1738 let (directory, workspace) =
1739 custom_root_workspace(include_bytes!("../../core/tests/fixtures/valid-root.pem"));
1740 let configured_root = directory.path().to_path_buf();
1741 let held_root = configured_root.with_extension("retained-custom-root-ca");
1742 fs::rename(&configured_root, &held_root).expect("move retained workspace root");
1743 fs::create_dir_all(configured_root.join("certs")).expect("replacement root creates");
1744 fs::write(
1745 configured_root.join("certs/root.pem"),
1746 b"attacker-controlled replacement is not a certificate\n",
1747 )
1748 .expect("replacement root writes");
1749
1750 let preflight = preflight_secure_connect_options(&workspace, "dev");
1751
1752 fs::remove_dir_all(&configured_root).expect("replacement root removes");
1753 fs::rename(&held_root, &configured_root).expect("retained root restores");
1754 preflight.expect("transport must use the CA under the retained original root");
1755 }
1756}
1757
1758#[cfg(test)]
1759mod adoption_file_tests {
1760 use super::*;
1761 use sha2::{Digest as _, Sha256};
1762
1763 const LEGACY_SCHEMA: &str = "define\nentity person;\n";
1764
1765 fn adoption_workspace() -> (tempfile::TempDir, TypeBridgeWorkspace) {
1766 let directory = tempfile::tempdir().expect("workspace directory");
1767 fs::create_dir_all(directory.path().join("schema/fragments")).expect("schema directory");
1768 fs::write(
1769 directory.path().join("schema/schema.yaml"),
1770 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
1771 )
1772 .expect("schema set writes");
1773 fs::write(
1774 directory.path().join("schema/fragments/model.yaml"),
1775 "format: typebridge.schema/v2\nentities: {person: {}}\n",
1776 )
1777 .expect("schema writes");
1778 let manifest = directory.path().join("typebridge.yaml");
1779 fs::write(
1780 &manifest,
1781 "format: typebridge.workspace/v1\n\
1782 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: adoption-test\n\
1783 compatibility:\n semantic-profile: typedb-3.12.1/v1\n\
1784 migrations:\n directory: migrations/v2\n app-label: smoke\n\
1785 environments:\n dev:\n database: adoption_test\n uri: never-contact.invalid:1729\n migrate: 'true'\n credential:\n username: env:TYPEBRIDGE_TEST_USERNAME\n password: env:TYPEBRIDGE_TEST_PASSWORD\n",
1786 )
1787 .expect("manifest writes");
1788 let workspace = load_workspace(&manifest).expect("workspace loads");
1789 (directory, workspace)
1790 }
1791
1792 fn write_legacy_fixture(root: &Path) -> PathBuf {
1793 let directory = root.join("migrations/legacy");
1794 fs::create_dir_all(&directory).expect("legacy directory");
1795 let name = "0001_initial";
1796 let python_source = "class Migration:\n operations = []\n";
1797 let checksum = type_bridge_migration::migration_file_checksum(python_source);
1798 let source_sha256 = format!("{:x}", Sha256::digest(python_source.as_bytes()));
1799 let schema_hash = format!("{:x}", Sha256::digest(LEGACY_SCHEMA.as_bytes()));
1800 fs::write(directory.join(format!("{name}.py")), python_source)
1801 .expect("legacy source writes");
1802 let adoption = type_bridge_migration::LegacyAdoptionMetadata::new(
1803 "legacy",
1804 name,
1805 Vec::new(),
1806 checksum,
1807 source_sha256,
1808 type_bridge_migration::LegacySchemaEffect::Snapshot,
1809 type_bridge_migration::MigrationDependencySpec {
1810 app_label: "legacy".to_owned(),
1811 migration_name: name.to_owned(),
1812 },
1813 schema_hash.clone(),
1814 )
1815 .expect("legacy adoption metadata");
1816 fs::write(
1817 directory.join(format!("{name}.adoption.json")),
1818 serde_json::to_vec_pretty(&adoption).expect("metadata encodes"),
1819 )
1820 .expect("metadata writes");
1821 let snapshot = directory.join("snapshots/v0001");
1822 fs::create_dir_all(&snapshot).expect("snapshot directory");
1823 fs::write(snapshot.join("schema.tql"), LEGACY_SCHEMA).expect("snapshot schema writes");
1824 fs::write(
1825 snapshot.join("snapshot.json"),
1826 serde_json::to_vec_pretty(&serde_json::json!({
1827 "version": "v0001",
1828 "source_migration": name,
1829 "schema_hash": schema_hash,
1830 "file_hashes": {"schema.tql": schema_hash},
1831 "type_bridge_version": "1.5.11",
1832 "type_bridge_core_version": "1.5.11"
1833 }))
1834 .expect("snapshot manifest encodes"),
1835 )
1836 .expect("snapshot manifest writes");
1837 directory
1838 }
1839
1840 #[test]
1841 fn authority_publication_is_atomic_no_replace_and_resumable() {
1842 let directory = tempfile::tempdir().expect("directory");
1843 let authority =
1844 type_bridge_schema_migration::MigrationDirectory::open_ambient(directory.path())
1845 .expect("directory authority");
1846 let name = "adopted-genesis.typeql";
1847 let path = directory.path().join("adopted-genesis.typeql");
1848 assert!(
1849 publish_authority(&authority, name, b"define\nentity person;\n")
1850 .expect("first publish")
1851 );
1852 assert!(
1853 !publish_authority(&authority, name, b"define\nentity person;\n")
1854 .expect("identical recovery")
1855 );
1856 assert!(publish_authority(&authority, name, b"define\nentity company;\n").is_err());
1857 assert_eq!(
1858 fs::read(&path).expect("authority reads"),
1859 b"define\nentity person;\n"
1860 );
1861 assert!(
1862 fs::read_dir(directory.path())
1863 .expect("directory reads")
1864 .all(|entry| !entry
1865 .expect("entry")
1866 .file_name()
1867 .to_string_lossy()
1868 .ends_with(".tmp"))
1869 );
1870 }
1871
1872 #[cfg(unix)]
1873 #[test]
1874 fn authority_publication_rejects_final_symlink() {
1875 use std::os::unix::fs::symlink;
1876
1877 let directory = tempfile::tempdir().expect("directory");
1878 let outside = directory.path().join("outside");
1879 fs::write(&outside, b"untouched").expect("outside writes");
1880 let path = directory.path().join("adopted-genesis.typeql");
1881 symlink(&outside, &path).expect("symlink");
1882 let authority =
1883 type_bridge_schema_migration::MigrationDirectory::open_ambient(directory.path())
1884 .expect("directory authority");
1885
1886 assert!(publish_authority(&authority, "adopted-genesis.typeql", b"replacement").is_err());
1887 assert_eq!(fs::read(&outside).expect("outside reads"), b"untouched");
1888 }
1889
1890 #[test]
1891 fn invalid_adoption_name_creates_no_canonical_directory() {
1892 let (directory, workspace) = adoption_workspace();
1893 let missing_legacy = directory.path().join("missing-legacy");
1894 let error = run_connected(
1895 &workspace,
1896 "dev",
1897 ConnectedAction::Adopt {
1898 legacy_directory: missing_legacy,
1899 name: String::new(),
1900 },
1901 )
1902 .expect_err("invalid name fails before history or network access");
1903 assert!(error.contains("migration"), "{error}");
1904 assert!(
1905 !directory.path().join("migrations/v2").exists(),
1906 "bad-name validation must not create canonical filesystem state"
1907 );
1908 }
1909
1910 #[test]
1911 fn invalid_apply_approvals_fail_before_credentials_or_network() {
1912 let (directory, workspace) = adoption_workspace();
1913 fs::create_dir_all(directory.path().join("migrations/v2")).expect("canonical directory");
1914
1915 for (approval, expected) in [
1916 ("not-a-compound-id", "must be app-label/name"),
1917 ("smoke/0001_missing", "is not in the committed history"),
1918 ] {
1919 let error = run_connected(
1920 &workspace,
1921 "dev",
1922 ConnectedAction::Apply {
1923 approvals: vec![approval.to_owned()],
1924 },
1925 )
1926 .expect_err("invalid approval is rejected by local authority");
1927 assert!(error.contains(expected), "{approval}: {error}");
1928 assert!(
1929 !error.contains("credential")
1930 && !error.contains("connect")
1931 && !error.contains("database"),
1932 "approval validation ran after external setup: {error}"
1933 );
1934 }
1935 }
1936
1937 #[test]
1938 fn adoption_retry_completes_either_exact_orphan_direction() {
1939 for orphan in ["genesis", "bridge"] {
1940 let (directory, workspace) = adoption_workspace();
1941 let legacy = write_legacy_fixture(directory.path());
1942 let prepared = prepare_legacy_adoption(&workspace, &legacy, "0000_legacy_frontier")
1943 .expect("adoption prepares");
1944 let migration_directory = workspace
1945 .ensure_migration_directory()
1946 .expect("canonical directory");
1947 match orphan {
1948 "genesis" => {
1949 publish_authority(
1950 migration_directory.directory(),
1951 type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME,
1952 prepared.reconstructed.schema_typeql().as_bytes(),
1953 )
1954 .expect("genesis orphan publishes");
1955 }
1956 "bridge" => {
1957 publish_authority(
1958 migration_directory.directory(),
1959 &prepared.bridge_name,
1960 &prepared.bridge_bytes,
1961 )
1962 .expect("bridge orphan publishes");
1963 }
1964 _ => unreachable!(),
1965 }
1966
1967 publish_prepared_adoption(&workspace, &migration_directory, &prepared)
1968 .expect("adoption retry completes the exact orphan");
1969 workspace
1970 .discover_migrations_in(&migration_directory)
1971 .expect("completed adoption pair discovers");
1972 assert!(
1973 migration_directory
1974 .display_path()
1975 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
1976 .is_file()
1977 );
1978 assert!(
1979 migration_directory
1980 .display_path()
1981 .join(&prepared.bridge_name)
1982 .is_file()
1983 );
1984 }
1985 }
1986
1987 #[test]
1988 fn legacy_history_race_after_bridge_rolls_back_new_publication() {
1989 let (directory, workspace) = adoption_workspace();
1990 let legacy = write_legacy_fixture(directory.path());
1991 let prepared = prepare_legacy_adoption(&workspace, &legacy, "0000_legacy_frontier")
1992 .expect("adoption prepares");
1993 let migration_directory = workspace
1994 .ensure_migration_directory()
1995 .expect("canonical directory");
1996 let legacy_source = legacy.join("0001_initial.py");
1997
1998 let error = publish_prepared_adoption_with_after_bridge(
1999 &workspace,
2000 &migration_directory,
2001 &prepared,
2002 || {
2003 fs::write(
2004 &legacy_source,
2005 "class Migration:\n operations = ['changed']\n",
2006 )
2007 .expect("race mutation writes");
2008 },
2009 )
2010 .expect_err("legacy authority race aborts pair publication");
2011 assert!(error.contains("changed"), "{error}");
2012 assert!(
2013 !migration_directory
2014 .display_path()
2015 .join(&prepared.bridge_name)
2016 .exists(),
2017 "new bridge is rolled back"
2018 );
2019 assert!(
2020 !migration_directory
2021 .display_path()
2022 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
2023 .exists(),
2024 "genesis is never published after the race"
2025 );
2026 }
2027}