1#![forbid(unsafe_code)]
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256, Sha384};
6use std::{
7 collections::{BTreeMap, BTreeSet},
8 fs,
9 path::{Path, PathBuf},
10};
11use thiserror::Error;
12
13mod seed;
14
15pub use seed::*;
16
17pub const MIGRATION_SET_MANIFEST: &str = ".minco-migrations.toml";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum DatabaseBackend {
22 Postgres,
23 Sqlite,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum MigrationRisk {
29 Additive,
30 DataRewrite,
31 Destructive,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct MigrationCatalog {
36 pub schema_version: u32,
37 pub digest: String,
38 pub sets: Vec<MigrationSet>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct MigrationPlan {
43 pub schema_version: u32,
44 pub catalog_digest: String,
45 pub selected_set: Option<String>,
46 pub digest: String,
47 pub sets: Vec<MigrationSet>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct MigrationSet {
52 pub id: String,
53 pub owner: String,
54 pub backend: DatabaseBackend,
55 pub root: PathBuf,
56 pub history_table: String,
57 pub depends_on: Vec<String>,
58 pub verify_tables: Vec<String>,
59 pub digest: String,
60 pub migrations: Vec<Migration>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct Migration {
65 pub id: String,
66 pub version: i64,
67 pub description: String,
68 pub path: PathBuf,
69 pub sha256: String,
70 pub sqlx_checksum_sha384: String,
71 pub risk: MigrationRisk,
72 pub reversible: bool,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct AppliedMigration {
77 pub version: i64,
78 pub sqlx_checksum_sha384: String,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82pub struct TargetState {
83 pub dirty_version: Option<i64>,
84 pub applied: Vec<AppliedMigration>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum MigrationState {
90 Applied,
91 Pending,
92 Drift,
93 MissingSource,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct MigrationStatusEntry {
98 pub id: String,
99 pub version: i64,
100 pub state: MigrationState,
101 pub source_checksum_sha384: Option<String>,
102 pub applied_checksum_sha384: Option<String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct MigrationStatus {
107 pub set_id: String,
108 pub dirty_version: Option<i64>,
109 pub entries: Vec<MigrationStatusEntry>,
110}
111
112#[derive(Debug, Error)]
113pub enum DbLifecycleError {
114 #[error("database lifecycle metadata is invalid: {0}")]
115 Invalid(String),
116 #[error("database lifecycle I/O failed at {path}: {source}")]
117 Io {
118 path: PathBuf,
119 #[source]
120 source: std::io::Error,
121 },
122 #[error("database lifecycle TOML failed at {path}: {source}")]
123 Toml {
124 path: PathBuf,
125 #[source]
126 source: toml::de::Error,
127 },
128 #[error("database lifecycle serialization failed: {0}")]
129 Json(#[from] serde_json::Error),
130}
131
132pub fn load_catalog(
133 project_root: &Path,
134 migration_roots: &[PathBuf],
135) -> Result<MigrationCatalog, DbLifecycleError> {
136 let project_root = canonicalize(project_root)?;
137 let mut roots = BTreeSet::new();
138 let mut sets = Vec::new();
139 for configured_root in migration_roots {
140 if configured_root.is_absolute() {
141 return Err(DbLifecycleError::Invalid(format!(
142 "migration root {} must be relative to the project",
143 configured_root.display()
144 )));
145 }
146 let root = canonicalize(&project_root.join(configured_root))?;
147 if !root.starts_with(&project_root) {
148 return Err(DbLifecycleError::Invalid(format!(
149 "migration root {} escapes the project",
150 configured_root.display()
151 )));
152 }
153 let relative_root = root
154 .strip_prefix(&project_root)
155 .map_err(|_| {
156 DbLifecycleError::Invalid(format!(
157 "migration root {} escapes the project",
158 configured_root.display()
159 ))
160 })?
161 .to_path_buf();
162 if !roots.insert(relative_root.clone()) {
163 return Err(DbLifecycleError::Invalid(format!(
164 "migration root {} is configured more than once",
165 relative_root.display()
166 )));
167 }
168 sets.push(load_set(&project_root, &root, relative_root)?);
169 }
170 sets.sort_by(|left, right| left.id.cmp(&right.id));
171 validate_dependencies(&sets)?;
172 let digest = sha256_hex(&serde_json::to_vec(&sets)?);
173 Ok(MigrationCatalog {
174 schema_version: 1,
175 digest,
176 sets,
177 })
178}
179
180pub fn compare_target(set: &MigrationSet, target: &TargetState) -> MigrationStatus {
181 let applied = target
182 .applied
183 .iter()
184 .map(|migration| (migration.version, migration))
185 .collect::<BTreeMap<_, _>>();
186 let source_versions = set
187 .migrations
188 .iter()
189 .map(|migration| migration.version)
190 .collect::<BTreeSet<_>>();
191 let mut entries = set
192 .migrations
193 .iter()
194 .map(|migration| {
195 let target = applied.get(&migration.version);
196 let state = match target {
197 None => MigrationState::Pending,
198 Some(target) if target.sqlx_checksum_sha384 == migration.sqlx_checksum_sha384 => {
199 MigrationState::Applied
200 }
201 Some(_) => MigrationState::Drift,
202 };
203 MigrationStatusEntry {
204 id: migration.id.clone(),
205 version: migration.version,
206 state,
207 source_checksum_sha384: Some(migration.sqlx_checksum_sha384.clone()),
208 applied_checksum_sha384: target
209 .map(|migration| migration.sqlx_checksum_sha384.clone()),
210 }
211 })
212 .collect::<Vec<_>>();
213 entries.extend(
214 target
215 .applied
216 .iter()
217 .filter(|migration| !source_versions.contains(&migration.version))
218 .map(|migration| MigrationStatusEntry {
219 id: format!("{}:{}", set.id, migration.version),
220 version: migration.version,
221 state: MigrationState::MissingSource,
222 source_checksum_sha384: None,
223 applied_checksum_sha384: Some(migration.sqlx_checksum_sha384.clone()),
224 }),
225 );
226 entries.sort_by_key(|entry| entry.version);
227 MigrationStatus {
228 set_id: set.id.clone(),
229 dirty_version: target.dirty_version,
230 entries,
231 }
232}
233
234pub fn build_plan(
235 catalog: &MigrationCatalog,
236 selected_set: Option<&str>,
237) -> Result<MigrationPlan, DbLifecycleError> {
238 let by_id = catalog
239 .sets
240 .iter()
241 .map(|set| (set.id.as_str(), set))
242 .collect::<BTreeMap<_, _>>();
243 let mut visited = BTreeSet::new();
244 let mut ordered = Vec::new();
245 match selected_set {
246 Some(id) => {
247 if !by_id.contains_key(id) {
248 return Err(DbLifecycleError::Invalid(format!(
249 "unknown migration set {id}"
250 )));
251 }
252 collect_plan_set(id, &by_id, &mut visited, &mut ordered)?;
253 }
254 None => {
255 for id in by_id.keys() {
256 collect_plan_set(id, &by_id, &mut visited, &mut ordered)?;
257 }
258 }
259 }
260 let sets = ordered.into_iter().cloned().collect::<Vec<_>>();
261 let selected_set = selected_set.map(str::to_owned);
262 let digest_input = serde_json::to_vec(&(
263 1_u32,
264 catalog.digest.as_str(),
265 selected_set.as_deref(),
266 &sets,
267 ))?;
268 Ok(MigrationPlan {
269 schema_version: 1,
270 catalog_digest: catalog.digest.clone(),
271 selected_set,
272 digest: sha256_hex(&digest_input),
273 sets,
274 })
275}
276
277fn collect_plan_set<'a>(
278 id: &'a str,
279 sets: &BTreeMap<&'a str, &'a MigrationSet>,
280 visited: &mut BTreeSet<&'a str>,
281 ordered: &mut Vec<&'a MigrationSet>,
282) -> Result<(), DbLifecycleError> {
283 if !visited.insert(id) {
284 return Ok(());
285 }
286 let set = sets
287 .get(id)
288 .ok_or_else(|| DbLifecycleError::Invalid(format!("unknown migration set {id}")))?;
289 for dependency in &set.depends_on {
290 collect_plan_set(dependency, sets, visited, ordered)?;
291 }
292 ordered.push(set);
293 Ok(())
294}
295
296#[derive(Debug, Deserialize)]
297#[serde(deny_unknown_fields)]
298struct MigrationSetManifest {
299 schema: u32,
300 id: String,
301 owner: String,
302 backend: DatabaseBackend,
303 history_table: String,
304 #[serde(default)]
305 depends_on: Vec<String>,
306 #[serde(default)]
307 verify_tables: Vec<String>,
308 #[serde(default)]
309 migration: Vec<MigrationMetadata>,
310}
311
312#[derive(Debug, Deserialize)]
313#[serde(deny_unknown_fields)]
314struct MigrationMetadata {
315 version: i64,
316 risk: MigrationRisk,
317 reversible: bool,
318}
319
320fn metadata_by_version(
321 manifest: &MigrationSetManifest,
322) -> Result<BTreeMap<i64, &MigrationMetadata>, DbLifecycleError> {
323 let mut metadata = BTreeMap::new();
324 for migration in &manifest.migration {
325 if migration.version <= 0 {
326 return Err(DbLifecycleError::Invalid(format!(
327 "migration set {} has non-positive metadata version {}",
328 manifest.id, migration.version
329 )));
330 }
331 if metadata.insert(migration.version, migration).is_some() {
332 return Err(DbLifecycleError::Invalid(format!(
333 "migration set {} repeats metadata for version {}",
334 manifest.id, migration.version
335 )));
336 }
337 }
338 Ok(metadata)
339}
340
341fn load_set(
342 project_root: &Path,
343 root: &Path,
344 relative_root: PathBuf,
345) -> Result<MigrationSet, DbLifecycleError> {
346 let manifest_path = canonicalize(&root.join(MIGRATION_SET_MANIFEST))?;
347 if !manifest_path.starts_with(root) {
348 return Err(DbLifecycleError::Invalid(format!(
349 "migration metadata for root {} escapes its configured root",
350 relative_root.display()
351 )));
352 }
353 let manifest: MigrationSetManifest =
354 toml::from_str(&read_to_string(&manifest_path)?).map_err(|source| {
355 DbLifecycleError::Toml {
356 path: manifest_path.clone(),
357 source,
358 }
359 })?;
360 if manifest.schema != 1 {
361 return Err(DbLifecycleError::Invalid(format!(
362 "migration set {} uses unsupported schema {}",
363 manifest.id, manifest.schema
364 )));
365 }
366 validate_stable_id(&manifest.id, "migration set ID")?;
367 validate_owner(&manifest.owner)?;
368 validate_identifier(&manifest.history_table, "migration history table")?;
369 if manifest.verify_tables.is_empty() {
370 return Err(DbLifecycleError::Invalid(format!(
371 "migration set {} must declare at least one verification table",
372 manifest.id
373 )));
374 }
375 let mut verification_tables = BTreeSet::new();
376 for table in &manifest.verify_tables {
377 validate_identifier(table, "verification table")?;
378 if !verification_tables.insert(table) {
379 return Err(DbLifecycleError::Invalid(format!(
380 "migration set {} repeats verification table {}",
381 manifest.id, table
382 )));
383 }
384 }
385 let mut dependencies = BTreeSet::new();
386 for dependency in &manifest.depends_on {
387 validate_stable_id(dependency, "migration dependency")?;
388 if dependency == &manifest.id {
389 return Err(DbLifecycleError::Invalid(format!(
390 "migration set {} cannot depend on itself",
391 manifest.id
392 )));
393 }
394 if !dependencies.insert(dependency) {
395 return Err(DbLifecycleError::Invalid(format!(
396 "migration set {} repeats dependency {}",
397 manifest.id, dependency
398 )));
399 }
400 }
401
402 let metadata = metadata_by_version(&manifest)?;
403 let mut seen_versions = BTreeSet::new();
404 let mut migrations = Vec::new();
405 for entry in fs::read_dir(root).map_err(|source| DbLifecycleError::Io {
406 path: root.to_path_buf(),
407 source,
408 })? {
409 let entry = entry.map_err(|source| DbLifecycleError::Io {
410 path: root.to_path_buf(),
411 source,
412 })?;
413 let path = entry.path();
414 if path.extension().and_then(|extension| extension.to_str()) != Some("sql") {
415 continue;
416 }
417 let canonical_path = canonicalize(&path)?;
418 if !canonical_path.starts_with(root) {
419 return Err(DbLifecycleError::Invalid(format!(
420 "migration file {} escapes its configured root",
421 path.display()
422 )));
423 }
424 let file_name = canonical_path
425 .file_stem()
426 .and_then(|value| value.to_str())
427 .ok_or_else(|| {
428 DbLifecycleError::Invalid(format!(
429 "migration file {} must have a UTF-8 name",
430 canonical_path.display()
431 ))
432 })?;
433 let (version, description) = parse_migration_name(file_name)?;
434 if !seen_versions.insert(version) {
435 return Err(DbLifecycleError::Invalid(format!(
436 "migration set {} repeats version {}",
437 manifest.id, version
438 )));
439 }
440 let migration_metadata = metadata.get(&version).ok_or_else(|| {
441 DbLifecycleError::Invalid(format!(
442 "migration set {} has no risk metadata for version {}",
443 manifest.id, version
444 ))
445 })?;
446 let sql = fs::read(&canonical_path).map_err(|source| DbLifecycleError::Io {
447 path: canonical_path.clone(),
448 source,
449 })?;
450 let sql_text = std::str::from_utf8(&sql).map_err(|_| {
451 DbLifecycleError::Invalid(format!(
452 "migration file {} is not UTF-8",
453 canonical_path.display()
454 ))
455 })?;
456 let relative_path = canonical_path
457 .strip_prefix(project_root)
458 .map_err(|_| {
459 DbLifecycleError::Invalid(format!(
460 "migration file {} escapes the project",
461 canonical_path.display()
462 ))
463 })?
464 .to_path_buf();
465 migrations.push(Migration {
466 id: format!("{}:{version}", manifest.id),
467 version,
468 description,
469 path: relative_path,
470 sha256: sha256_hex(&sql),
471 sqlx_checksum_sha384: sha384_hex(sql_text.as_bytes()),
472 risk: migration_metadata.risk,
473 reversible: migration_metadata.reversible,
474 });
475 }
476 migrations.sort_by_key(|migration| migration.version);
477 if migrations.is_empty() {
478 return Err(DbLifecycleError::Invalid(format!(
479 "migration set {} contains no SQL migrations",
480 manifest.id
481 )));
482 }
483 for version in metadata.keys() {
484 if !seen_versions.contains(version) {
485 return Err(DbLifecycleError::Invalid(format!(
486 "migration set {} has metadata for missing version {}",
487 manifest.id, version
488 )));
489 }
490 }
491 let mut set = MigrationSet {
492 id: manifest.id,
493 owner: manifest.owner,
494 backend: manifest.backend,
495 root: relative_root,
496 history_table: manifest.history_table,
497 depends_on: manifest.depends_on,
498 verify_tables: manifest.verify_tables,
499 digest: String::new(),
500 migrations,
501 };
502 set.depends_on.sort();
503 set.verify_tables.sort();
504 set.digest = sha256_hex(&serde_json::to_vec(&set)?);
505 Ok(set)
506}
507
508fn validate_dependencies(sets: &[MigrationSet]) -> Result<(), DbLifecycleError> {
509 let mut by_id = BTreeMap::new();
510 for set in sets {
511 if by_id.insert(set.id.as_str(), set).is_some() {
512 return Err(DbLifecycleError::Invalid(format!(
513 "migration catalog repeats migration set ID {}",
514 set.id
515 )));
516 }
517 }
518 let mut history_owners = BTreeMap::new();
519 for set in sets {
520 let key = (set.backend, set.history_table.as_str());
521 if let Some(previous) = history_owners.insert(key, set.id.as_str()) {
522 return Err(DbLifecycleError::Invalid(format!(
523 "migration set {} shares migration history table {} with set {}",
524 set.id, set.history_table, previous
525 )));
526 }
527 }
528 for set in sets {
529 for dependency in &set.depends_on {
530 let dependency_set = by_id.get(dependency.as_str()).ok_or_else(|| {
531 DbLifecycleError::Invalid(format!(
532 "migration set {} depends on unknown set {}",
533 set.id, dependency
534 ))
535 })?;
536 if dependency_set.backend != set.backend {
537 return Err(DbLifecycleError::Invalid(format!(
538 "migration set {} cannot depend on {} because their backends differ",
539 set.id, dependency
540 )));
541 }
542 }
543 }
544 let mut visiting = BTreeSet::new();
545 let mut visited = BTreeSet::new();
546 for set in sets {
547 visit_dependency(set.id.as_str(), &by_id, &mut visiting, &mut visited)?;
548 }
549 Ok(())
550}
551
552fn visit_dependency<'a>(
553 id: &'a str,
554 sets: &BTreeMap<&'a str, &'a MigrationSet>,
555 visiting: &mut BTreeSet<&'a str>,
556 visited: &mut BTreeSet<&'a str>,
557) -> Result<(), DbLifecycleError> {
558 if visited.contains(id) {
559 return Ok(());
560 }
561 if !visiting.insert(id) {
562 return Err(DbLifecycleError::Invalid(format!(
563 "migration dependency cycle contains {id}"
564 )));
565 }
566 let set = sets
567 .get(id)
568 .ok_or_else(|| DbLifecycleError::Invalid(format!("unknown migration set {id}")))?;
569 for dependency in &set.depends_on {
570 visit_dependency(dependency, sets, visiting, visited)?;
571 }
572 visiting.remove(id);
573 visited.insert(id);
574 Ok(())
575}
576
577fn parse_migration_name(file_stem: &str) -> Result<(i64, String), DbLifecycleError> {
578 let (version, description) = file_stem.split_once('_').ok_or_else(|| {
579 DbLifecycleError::Invalid(format!(
580 "migration file {file_stem}.sql must use <version>_<description>.sql"
581 ))
582 })?;
583 let version = version.parse::<i64>().map_err(|_| {
584 DbLifecycleError::Invalid(format!(
585 "migration file {file_stem}.sql has an invalid version"
586 ))
587 })?;
588 if version <= 0 || description.is_empty() {
589 return Err(DbLifecycleError::Invalid(format!(
590 "migration file {file_stem}.sql has an invalid identity"
591 )));
592 }
593 Ok((version, description.replace('_', " ")))
594}
595
596pub(crate) fn validate_stable_id(value: &str, description: &str) -> Result<(), DbLifecycleError> {
597 let valid = !value.is_empty()
598 && value.len() <= 96
599 && value
600 .bytes()
601 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
602 && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
603 && value
604 .as_bytes()
605 .last()
606 .is_some_and(u8::is_ascii_alphanumeric);
607 if !valid {
608 return Err(DbLifecycleError::Invalid(format!(
609 "{description} {value:?} must be lower-kebab-case"
610 )));
611 }
612 Ok(())
613}
614
615pub(crate) fn validate_owner(value: &str) -> Result<(), DbLifecycleError> {
616 let Some((kind, id)) = value.split_once(':') else {
617 return Err(DbLifecycleError::Invalid(format!(
618 "migration owner {value:?} must be application:<id> or plugin:<id>"
619 )));
620 };
621 if !matches!(kind, "application" | "plugin") {
622 return Err(DbLifecycleError::Invalid(format!(
623 "migration owner kind {kind:?} is unsupported"
624 )));
625 }
626 validate_stable_id(id, "migration owner ID")
627}
628
629fn validate_identifier(value: &str, description: &str) -> Result<(), DbLifecycleError> {
630 let mut bytes = value.bytes();
631 let valid_start = bytes
632 .next()
633 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
634 if !valid_start
635 || value.len() > 63
636 || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
637 {
638 return Err(DbLifecycleError::Invalid(format!(
639 "{description} {value:?} must be an ASCII SQL identifier of at most 63 characters"
640 )));
641 }
642 Ok(())
643}
644
645pub(crate) fn canonicalize(path: &Path) -> Result<PathBuf, DbLifecycleError> {
646 path.canonicalize().map_err(|source| DbLifecycleError::Io {
647 path: path.to_path_buf(),
648 source,
649 })
650}
651
652fn read_to_string(path: &Path) -> Result<String, DbLifecycleError> {
653 fs::read_to_string(path).map_err(|source| DbLifecycleError::Io {
654 path: path.to_path_buf(),
655 source,
656 })
657}
658
659pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
660 format!("{:x}", Sha256::digest(bytes))
661}
662
663fn sha384_hex(bytes: &[u8]) -> String {
664 format!("{:x}", Sha384::digest(bytes))
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use std::fs;
671 use tempfile::TempDir;
672
673 fn write_set(directory: &Path, id: &str, depends_on: &[&str], include_metadata: bool) {
674 fs::create_dir_all(directory).expect("create migration root");
675 fs::write(
676 directory.join("0001_foundation.sql"),
677 "CREATE TABLE example (id INTEGER PRIMARY KEY);\n",
678 )
679 .expect("write migration");
680 let dependencies = depends_on
681 .iter()
682 .map(|dependency| format!("\"{dependency}\""))
683 .collect::<Vec<_>>()
684 .join(", ");
685 let metadata = if include_metadata {
686 "\n[[migration]]\nversion = 1\nrisk = \"additive\"\nreversible = false\n"
687 } else {
688 ""
689 };
690 fs::write(
691 directory.join(MIGRATION_SET_MANIFEST),
692 format!(
693 "schema = 1\nid = \"{id}\"\nowner = \"application:test\"\nbackend = \"sqlite\"\nhistory_table = \"_minco_{}_migrations\"\ndepends_on = [{dependencies}]\nverify_tables = [\"example\"]\n{metadata}",
694 id.replace('-', "_")
695 ),
696 )
697 .expect("write lifecycle metadata");
698 }
699
700 #[test]
701 fn catalog_is_deterministic_and_carries_explicit_metadata() {
702 let project = TempDir::new().expect("temporary project");
703 write_set(&project.path().join("migrations"), "app", &[], true);
704
705 let first =
706 load_catalog(project.path(), &[PathBuf::from("migrations")]).expect("load catalog");
707 let second = load_catalog(project.path(), &[PathBuf::from("migrations")])
708 .expect("load catalog again");
709
710 assert_eq!(first, second);
711 assert_eq!(first.sets.len(), 1);
712 assert_eq!(first.sets[0].id, "app");
713 assert_eq!(first.sets[0].owner, "application:test");
714 assert_eq!(first.sets[0].migrations[0].risk, MigrationRisk::Additive);
715 assert_eq!(first.digest.len(), 64);
716 assert_eq!(first.sets[0].migrations[0].sha256.len(), 64);
717 assert_eq!(first.sets[0].migrations[0].sqlx_checksum_sha384.len(), 96);
718 }
719
720 #[test]
721 fn every_sql_migration_requires_explicit_risk_metadata() {
722 let project = TempDir::new().expect("temporary project");
723 write_set(&project.path().join("migrations"), "app", &[], false);
724
725 let error = load_catalog(project.path(), &[PathBuf::from("migrations")])
726 .expect_err("missing risk metadata must fail");
727 assert!(error.to_string().contains("version 1"));
728 }
729
730 #[test]
731 fn dependency_cycles_fail_before_database_access() {
732 let project = TempDir::new().expect("temporary project");
733 write_set(&project.path().join("a"), "a", &["b"], true);
734 write_set(&project.path().join("b"), "b", &["a"], true);
735
736 let error = load_catalog(project.path(), &[PathBuf::from("a"), PathBuf::from("b")])
737 .expect_err("cycle must fail");
738 assert!(error.to_string().contains("cycle"));
739 }
740
741 #[test]
742 fn duplicate_set_ids_fail_before_database_access() {
743 let project = TempDir::new().expect("temporary project");
744 write_set(&project.path().join("first"), "app", &[], true);
745 write_set(&project.path().join("second"), "app", &[], true);
746
747 let error = load_catalog(
748 project.path(),
749 &[PathBuf::from("first"), PathBuf::from("second")],
750 )
751 .expect_err("duplicate set IDs must fail");
752 assert!(error.to_string().contains("repeats migration set ID app"));
753 }
754
755 #[test]
756 fn migration_history_is_attributable_to_only_one_set_per_backend() {
757 let project = TempDir::new().expect("temporary project");
758 write_set(&project.path().join("first"), "first", &[], true);
759 write_set(&project.path().join("second"), "second", &[], true);
760 let second_manifest = project.path().join("second").join(MIGRATION_SET_MANIFEST);
761 let manifest = fs::read_to_string(&second_manifest).expect("read second manifest");
762 fs::write(
763 &second_manifest,
764 manifest.replace("_minco_second_migrations", "_minco_first_migrations"),
765 )
766 .expect("reuse history table");
767
768 let error = load_catalog(
769 project.path(),
770 &[PathBuf::from("first"), PathBuf::from("second")],
771 )
772 .expect_err("shared history table must fail");
773 assert!(error.to_string().contains("shares migration history table"));
774 }
775
776 #[cfg(unix)]
777 #[test]
778 fn migration_roots_cannot_escape_through_symlinks() {
779 use std::os::unix::fs::symlink;
780
781 let project = TempDir::new().expect("temporary project");
782 let outside = TempDir::new().expect("outside directory");
783 write_set(&outside.path().join("migrations"), "app", &[], true);
784 symlink(
785 outside.path().join("migrations"),
786 project.path().join("migrations"),
787 )
788 .expect("create migration-root symlink");
789
790 let error = load_catalog(project.path(), &[PathBuf::from("migrations")])
791 .expect_err("symlink escape must fail");
792 assert!(error.to_string().contains("escapes the project"));
793 }
794
795 #[test]
796 fn dynamic_sql_identifiers_are_strictly_validated() {
797 let project = TempDir::new().expect("temporary project");
798 write_set(&project.path().join("migrations"), "app", &[], true);
799 let manifest_path = project
800 .path()
801 .join("migrations")
802 .join(MIGRATION_SET_MANIFEST);
803 let manifest = fs::read_to_string(&manifest_path).expect("read manifest");
804 fs::write(
805 &manifest_path,
806 manifest.replace(
807 "history_table = \"_minco_app_migrations\"",
808 "history_table = \"migrations; DROP TABLE example\"",
809 ),
810 )
811 .expect("write malicious identifier");
812
813 let error = load_catalog(project.path(), &[PathBuf::from("migrations")])
814 .expect_err("unsafe SQL identifier must fail");
815 assert!(error.to_string().contains("ASCII SQL identifier"));
816 }
817
818 #[test]
819 fn selected_plan_orders_dependency_closure_and_has_a_stable_digest() {
820 let project = TempDir::new().expect("temporary project");
821 write_set(&project.path().join("foundation"), "foundation", &[], true);
822 write_set(
823 &project.path().join("application"),
824 "application",
825 &["foundation"],
826 true,
827 );
828 write_set(&project.path().join("unrelated"), "unrelated", &[], true);
829 let catalog = load_catalog(
830 project.path(),
831 &[
832 PathBuf::from("application"),
833 PathBuf::from("unrelated"),
834 PathBuf::from("foundation"),
835 ],
836 )
837 .expect("load catalog");
838
839 let first = build_plan(&catalog, Some("application")).expect("build selected plan");
840 let second = build_plan(&catalog, Some("application")).expect("build selected plan again");
841
842 assert_eq!(first, second);
843 assert_eq!(first.selected_set.as_deref(), Some("application"));
844 assert_eq!(
845 first
846 .sets
847 .iter()
848 .map(|set| set.id.as_str())
849 .collect::<Vec<_>>(),
850 ["foundation", "application"]
851 );
852 assert_eq!(first.digest.len(), 64);
853 }
854
855 #[test]
856 fn target_status_detects_checksum_drift_and_orphaned_history() {
857 let set = MigrationSet {
858 id: "app".into(),
859 owner: "application:test".into(),
860 backend: DatabaseBackend::Sqlite,
861 root: "migrations".into(),
862 history_table: "_minco_test_migrations".into(),
863 depends_on: Vec::new(),
864 verify_tables: vec!["example".into()],
865 digest: "set-digest".into(),
866 migrations: vec![Migration {
867 id: "app:1".into(),
868 version: 1,
869 description: "foundation".into(),
870 path: "migrations/0001_foundation.sql".into(),
871 sha256: "source".into(),
872 sqlx_checksum_sha384: "expected".into(),
873 risk: MigrationRisk::Additive,
874 reversible: false,
875 }],
876 };
877 let status = compare_target(
878 &set,
879 &TargetState {
880 dirty_version: None,
881 applied: vec![
882 AppliedMigration {
883 version: 1,
884 sqlx_checksum_sha384: "changed".into(),
885 },
886 AppliedMigration {
887 version: 2,
888 sqlx_checksum_sha384: "orphan".into(),
889 },
890 ],
891 },
892 );
893
894 assert_eq!(status.entries[0].state, MigrationState::Drift);
895 assert_eq!(status.entries[1].state, MigrationState::MissingSource);
896 }
897}