1use std::collections::{BTreeMap, BTreeSet};
14use std::ffi::{OsStr, OsString};
15#[cfg(test)]
16use std::fs;
17use std::io;
18use std::io::{Read as _, Write as _};
19use std::path::{Component, Path, PathBuf};
20use std::time::SystemTime;
21
22use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
23use cap_std::ambient_authority;
24use cap_std::fs::{Dir, Metadata};
25use serde::{Deserialize, Serialize};
26use sha2::{Digest as _, Sha256};
27
28#[cfg(test)]
29use crate::checksum::migration_file_checksum;
30use crate::error::{MigrationError, Result};
31use crate::graph::validate_graph;
32use crate::spec::{MigrationDependencySpec, MigrationGraph, MigrationSpec, OperationSpec};
33
34pub const LEGACY_ADOPTION_METADATA_V2: &str = "typebridge.migration-adoption-metadata/v2";
36pub const LEGACY_SIDECAR_ADOPTION_METADATA_V1: &str = "typebridge.migration-adoption-sidecar/v1";
38pub const LEGACY_IGNORED_SOURCE_METADATA_V1: &str =
40 "typebridge.migration-adoption-ignored-source/v1";
41
42pub const MAX_LEGACY_DIRECTORY_ENTRIES: usize = 65_536;
44pub const MAX_LEGACY_ARTIFACT_BYTES: usize = 16 * 1024 * 1024;
46pub const MAX_LEGACY_HISTORY_BYTES: usize = 256 * 1024 * 1024;
48
49const ADOPTION_SUFFIX: &str = ".adoption.json";
50const SNAPSHOT_MANIFEST: &str = "snapshot.json";
51const SNAPSHOT_SCHEMA: &str = "schema.tql";
52const CONVERSION_JOURNAL: &str = ".typebridge-adoption-conversion.json";
53
54#[cfg(test)]
55thread_local! {
56 static TEST_CAPTURED_DIRECTORY_ENTRIES: std::cell::Cell<usize> = const {
57 std::cell::Cell::new(0)
58 };
59 static TEST_BEFORE_JOURNAL_QUARANTINE: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
60 std::cell::RefCell::new(None);
61 static TEST_AFTER_SNAPSHOT_SCAN: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
62 std::cell::RefCell::new(None);
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66enum LegacyEntryKind {
67 File,
68 Directory,
69 Symlink,
70 Other,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct LegacyMetadataRevision {
76 kind: LegacyEntryKind,
77 len: u64,
78 modified: Option<SystemTime>,
79 created: Option<SystemTime>,
80 #[cfg(unix)]
81 device: u64,
82 #[cfg(unix)]
83 inode: u64,
84 #[cfg(unix)]
85 modified_seconds: i64,
86 #[cfg(unix)]
87 modified_nanoseconds: i64,
88 #[cfg(unix)]
89 changed_seconds: i64,
90 #[cfg(unix)]
91 changed_nanoseconds: i64,
92 #[cfg(windows)]
93 volume_serial_number: u32,
94 #[cfg(windows)]
95 file_index: u64,
96 #[cfg(windows)]
97 number_of_links: u32,
98 #[cfg(windows)]
99 file_attributes: u32,
100 #[cfg(windows)]
101 creation_time: u64,
102 #[cfg(windows)]
103 last_write_time: u64,
104}
105
106impl LegacyMetadataRevision {
107 fn capture(metadata: &Metadata) -> Result<Self> {
108 let kind = if metadata.is_file() {
109 LegacyEntryKind::File
110 } else if metadata.is_dir() {
111 LegacyEntryKind::Directory
112 } else if metadata.is_symlink() {
113 LegacyEntryKind::Symlink
114 } else {
115 LegacyEntryKind::Other
116 };
117 #[cfg(unix)]
118 {
119 use cap_std::fs::MetadataExt as _;
120 Ok(Self {
121 kind,
122 len: metadata.len(),
123 modified: metadata.modified().ok().map(|value| value.into_std()),
124 created: metadata.created().ok().map(|value| value.into_std()),
125 device: metadata.dev(),
126 inode: metadata.ino(),
127 modified_seconds: metadata.mtime(),
128 modified_nanoseconds: metadata.mtime_nsec(),
129 changed_seconds: metadata.ctime(),
130 changed_nanoseconds: metadata.ctime_nsec(),
131 })
132 }
133 #[cfg(windows)]
134 {
135 use cap_primitives::fs::_WindowsByHandle;
142 use cap_std::fs::MetadataExt as _;
143 let volume_serial_number = _WindowsByHandle::volume_serial_number(metadata)
144 .ok_or_else(|| {
145 loader_error("legacy authority metadata has no stable Windows volume identity")
146 })?;
147 let file_index = _WindowsByHandle::file_index(metadata).ok_or_else(|| {
148 loader_error("legacy authority metadata has no stable Windows file identity")
149 })?;
150 let number_of_links = _WindowsByHandle::number_of_links(metadata).ok_or_else(|| {
151 loader_error("legacy authority metadata has no stable Windows link identity")
152 })?;
153 Ok(Self {
154 kind,
155 len: metadata.len(),
156 modified: metadata.modified().ok().map(|value| value.into_std()),
157 created: metadata.created().ok().map(|value| value.into_std()),
158 volume_serial_number,
159 file_index,
160 number_of_links,
161 file_attributes: _WindowsByHandle::file_attributes(metadata),
162 creation_time: metadata.creation_time(),
163 last_write_time: metadata.last_write_time(),
164 })
165 }
166 #[cfg(not(any(unix, windows)))]
167 {
168 let _ = (kind, metadata);
169 Err(loader_error(
170 "legacy directory authority is unsupported without stable filesystem identity",
171 ))
172 }
173 }
174
175 fn same_retained_directory(&self, other: &Self) -> bool {
176 if self.kind != LegacyEntryKind::Directory || other.kind != LegacyEntryKind::Directory {
177 return false;
178 }
179 #[cfg(unix)]
180 {
181 self.len == other.len
186 && self.modified == other.modified
187 && self.created == other.created
188 && self.device == other.device
189 && self.inode == other.inode
190 && self.modified_seconds == other.modified_seconds
191 && self.modified_nanoseconds == other.modified_nanoseconds
192 }
193 #[cfg(not(unix))]
194 {
195 self == other
196 }
197 }
198
199 fn same_file_after_rename(&self, other: &Self) -> bool {
200 if self.kind != LegacyEntryKind::File || other.kind != LegacyEntryKind::File {
201 return false;
202 }
203 #[cfg(unix)]
204 {
205 self.len == other.len
206 && self.modified == other.modified
207 && self.created == other.created
208 && self.device == other.device
209 && self.inode == other.inode
210 && self.modified_seconds == other.modified_seconds
211 && self.modified_nanoseconds == other.modified_nanoseconds
212 }
213 #[cfg(not(unix))]
214 {
215 self == other
216 }
217 }
218}
219
220#[derive(Clone, Debug, Eq, PartialEq)]
222pub struct LegacyDirectoryEntry {
223 name: OsString,
224 revision: LegacyMetadataRevision,
225}
226
227impl LegacyDirectoryEntry {
228 pub fn name(&self) -> &OsStr {
230 &self.name
231 }
232
233 pub const fn revision(&self) -> &LegacyMetadataRevision {
235 &self.revision
236 }
237
238 pub const fn is_file(&self) -> bool {
240 matches!(self.revision.kind, LegacyEntryKind::File)
241 }
242
243 pub const fn is_directory(&self) -> bool {
245 matches!(self.revision.kind, LegacyEntryKind::Directory)
246 }
247
248 pub const fn is_symlink(&self) -> bool {
250 matches!(self.revision.kind, LegacyEntryKind::Symlink)
251 }
252}
253
254#[derive(Clone, Debug)]
255struct LegacyDirectoryCapture {
256 revision: LegacyMetadataRevision,
257 entries: Vec<LegacyDirectoryEntry>,
258}
259
260pub struct LegacyDirectoryAuthority {
262 directory: Dir,
263 display_path: PathBuf,
264}
265
266impl std::fmt::Debug for LegacyDirectoryAuthority {
267 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268 formatter
269 .debug_struct("LegacyDirectoryAuthority")
270 .field("display_path", &self.display_path)
271 .finish_non_exhaustive()
272 }
273}
274
275impl LegacyDirectoryAuthority {
276 pub fn open_root(path: &Path) -> Result<Self> {
278 let display_path = lexical_absolute(path).map_err(|_| {
279 loader_error("legacy directory cannot be retained as filesystem authority")
280 })?;
281 let directory =
282 Dir::open_ambient_dir(&display_path, ambient_authority()).map_err(|_| {
283 loader_error("legacy directory cannot be retained as filesystem authority")
284 })?;
285 Ok(Self {
286 directory,
287 display_path,
288 })
289 }
290
291 fn open_child(&self, entry: &LegacyDirectoryEntry) -> Result<Self> {
292 if entry.revision.kind != LegacyEntryKind::Directory {
293 return Err(loader_error(
294 "legacy directory entry is not a retained real directory",
295 ));
296 }
297 let directory = self
298 .directory
299 .open_dir_nofollow(&entry.name)
300 .map_err(|_| loader_error("legacy directory descendant cannot be retained"))?;
301 let actual = LegacyMetadataRevision::capture(
302 &directory
303 .metadata(".")
304 .map_err(|_| loader_error("legacy directory descendant cannot be inspected"))?,
305 )?;
306 if !actual.same_retained_directory(&entry.revision) {
307 return Err(loader_error(
308 "legacy directory descendant changed after enumeration",
309 ));
310 }
311 Ok(Self {
312 directory,
313 display_path: self.display_path.join(&entry.name),
314 })
315 }
316
317 fn capture(&self) -> Result<LegacyDirectoryCapture> {
318 self.capture_with_limit(MAX_LEGACY_DIRECTORY_ENTRIES)
319 }
320
321 fn capture_with_limit(&self, maximum_entries: usize) -> Result<LegacyDirectoryCapture> {
322 let before = self.revision()?;
323 let mut entries = Vec::new();
324 for entry in self
325 .directory
326 .read_dir(".")
327 .map_err(|_| loader_error("legacy directory authority cannot be enumerated"))?
328 {
329 if entries.len() == maximum_entries {
330 return Err(loader_error("legacy directory exceeds the entry ceiling"));
331 }
332 let entry = entry
333 .map_err(|_| loader_error("legacy directory authority cannot be enumerated"))?;
334 let name = entry.file_name();
335 let metadata = self
336 .directory
337 .symlink_metadata(&name)
338 .map_err(|_| loader_error("legacy directory entry cannot be inspected"))?;
339 entries.push(LegacyDirectoryEntry {
340 name,
341 revision: LegacyMetadataRevision::capture(&metadata)?,
342 });
343 #[cfg(test)]
344 TEST_CAPTURED_DIRECTORY_ENTRIES.with(|count| count.set(count.get() + 1));
345 }
346 entries.sort_by(|left, right| left.name.cmp(&right.name));
347 let after = self.revision()?;
348 if !before.same_retained_directory(&after) {
349 return Err(loader_error(
350 "legacy directory changed during bounded enumeration",
351 ));
352 }
353 Ok(LegacyDirectoryCapture {
354 revision: after,
355 entries,
356 })
357 }
358
359 fn require_revision(&self, expected: &LegacyMetadataRevision) -> Result<()> {
360 if !self.revision()?.same_retained_directory(expected) {
361 return Err(loader_error(
362 "legacy directory changed during bounded adoption",
363 ));
364 }
365 Ok(())
366 }
367
368 fn require_capture(&self, capture: &LegacyDirectoryCapture) -> Result<()> {
369 self.require_revision(&capture.revision)?;
370 for expected in &capture.entries {
371 let metadata = self
372 .directory
373 .symlink_metadata(&expected.name)
374 .map_err(|_| loader_error("legacy retained entry is absent during revalidation"))?;
375 if LegacyMetadataRevision::capture(&metadata)? != expected.revision {
376 return Err(loader_error(
377 "legacy retained entry changed after bounded capture",
378 ));
379 }
380 }
381 self.require_revision(&capture.revision)
382 }
383
384 fn require_filtered_capture(
385 &self,
386 capture: &LegacyDirectoryCapture,
387 include: impl Fn(&OsStr) -> bool,
388 message: &'static str,
389 ) -> Result<()> {
390 self.require_capture(capture)?;
391 let mut observed = self.capture()?;
392 observed.entries.retain(|entry| include(&entry.name));
393 if observed.entries != capture.entries {
394 return Err(loader_error(message));
395 }
396 self.require_capture(capture)
397 }
398
399 fn require_recognized_root_capture(&self, capture: &LegacyDirectoryCapture) -> Result<()> {
400 self.require_filtered_capture(
401 capture,
402 is_recognized_legacy_root_name,
403 "recognized legacy migration membership changed after bounded capture",
404 )
405 }
406
407 fn require_snapshot_version_capture(&self, capture: &LegacyDirectoryCapture) -> Result<()> {
408 self.require_filtered_capture(
409 capture,
410 |name| name.to_str().is_some_and(is_snapshot_version),
411 "legacy snapshot version membership changed after bounded capture",
412 )
413 }
414
415 fn revision(&self) -> Result<LegacyMetadataRevision> {
416 self.directory
417 .metadata(".")
418 .map_err(|_| loader_error("legacy directory authority cannot be inspected"))
419 .and_then(|metadata| LegacyMetadataRevision::capture(&metadata))
420 }
421
422 pub fn directory_revision(&self) -> Result<LegacyMetadataRevision> {
424 self.revision()
425 }
426
427 pub fn require_directory_revision(&self, expected: &LegacyMetadataRevision) -> Result<()> {
429 self.require_revision(expected)
430 }
431
432 pub fn entries_relative(
434 &self,
435 relative: &Path,
436 maximum_entries: usize,
437 expected_directory: Option<&LegacyDirectoryEntry>,
438 ) -> Result<Vec<LegacyDirectoryEntry>> {
439 let directory = self.open_relative_directory(relative)?;
440 if let Some(expected) = expected_directory
441 && (expected.revision.kind != LegacyEntryKind::Directory
442 || !directory
443 .revision()?
444 .same_retained_directory(&expected.revision))
445 {
446 return Err(loader_error(
447 "legacy directory descendant changed after enumeration",
448 ));
449 }
450 let capture =
451 directory.capture_with_limit(maximum_entries.min(MAX_LEGACY_DIRECTORY_ENTRIES))?;
452 if let Some(expected) = expected_directory
453 && !capture.revision.same_retained_directory(&expected.revision)
454 {
455 return Err(loader_error(
456 "legacy directory descendant changed during enumeration",
457 ));
458 }
459 Ok(capture.entries)
460 }
461
462 pub fn inspect_relative(
464 &self,
465 relative: &Path,
466 expected_parent: Option<&LegacyDirectoryEntry>,
467 ) -> Result<Option<LegacyDirectoryEntry>> {
468 let (parent, name) = self.open_relative_parent(relative)?;
469 if let Some(expected) = expected_parent
470 && (expected.revision.kind != LegacyEntryKind::Directory
471 || !parent
472 .revision()?
473 .same_retained_directory(&expected.revision))
474 {
475 return Err(loader_error(
476 "legacy directory descendant changed after enumeration",
477 ));
478 }
479 match parent.directory.symlink_metadata(&name) {
480 Ok(metadata) => Ok(Some(LegacyDirectoryEntry {
481 name,
482 revision: LegacyMetadataRevision::capture(&metadata)?,
483 })),
484 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
485 Err(_) => Err(loader_error("legacy directory entry cannot be inspected")),
486 }
487 }
488
489 pub fn read_relative_bounded(
491 &self,
492 relative: &Path,
493 limit: usize,
494 expected: Option<&LegacyDirectoryEntry>,
495 ) -> Result<Vec<u8>> {
496 let (parent, name) = self.open_relative_parent(relative)?;
497 let observed = match expected {
498 Some(entry) if entry.name == name => entry.clone(),
499 Some(_) => {
500 return Err(loader_error(
501 "legacy artifact identity does not match its retained name",
502 ));
503 }
504 None => parent
505 .inspect_relative(Path::new(&name), None)?
506 .ok_or_else(|| loader_error("legacy artifact is absent"))?,
507 };
508 let mut aggregate = 0;
509 read_bounded(
510 &parent,
511 &observed,
512 limit.min(MAX_LEGACY_ARTIFACT_BYTES),
513 &mut aggregate,
514 )
515 }
516
517 pub fn write_atomic_no_replace(&self, name: &str, contents: &[u8]) -> Result<()> {
519 validate_direct_component(Path::new(name))?;
520 if contents.len() > MAX_LEGACY_ARTIFACT_BYTES {
521 return Err(loader_error(
522 "legacy artifact publication exceeds the byte ceiling",
523 ));
524 }
525 let mut temporary = None;
526 for attempt in 0..128_u64 {
527 let candidate = adoption_temporary_name("pub", name, contents, attempt);
528 let mut options = cap_std::fs::OpenOptions::new();
529 options
530 .write(true)
531 .create_new(true)
532 .follow(FollowSymlinks::No);
533 match self.directory.open_with(&candidate, &options) {
534 Ok(mut file) => {
535 if file
536 .write_all(contents)
537 .and_then(|()| file.sync_all())
538 .is_err()
539 {
540 drop(file);
543 let _ = self.directory.remove_file(&candidate);
544 return Err(loader_error("legacy artifact temporary cannot be flushed"));
545 }
546 temporary = Some(candidate);
547 break;
548 }
549 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
550 Err(_) => {
551 return Err(loader_error("legacy artifact temporary cannot be created"));
552 }
553 }
554 }
555 let temporary = temporary
556 .ok_or_else(|| loader_error("legacy artifact temporary name ceiling exhausted"))?;
557 let publication = self.directory.hard_link(&temporary, &self.directory, name);
558 let _ = self.directory.remove_file(&temporary);
559 publication.map_err(|_| loader_error("legacy artifact no-replace publication failed"))?;
560 self.sync_directory()
561 }
562
563 pub fn validate_publication_name(&self, name: &str) -> Result<()> {
565 let _ = self;
566 validate_direct_component(Path::new(name))
567 }
568
569 pub fn remove_owned_temporary_if_matches(
578 &self,
579 name: &str,
580 target: &str,
581 expected: &LegacyDirectoryEntry,
582 expected_bytes: &[u8],
583 ) -> Result<bool> {
584 validate_direct_component(Path::new(name))?;
585 validate_direct_component(Path::new(target))?;
586 if expected.name != OsStr::new(name)
587 || expected_bytes.len() > MAX_LEGACY_ARTIFACT_BYTES
588 || !adoption_temporary_matches(name, target, expected_bytes)
589 {
590 return Err(loader_error(
591 "adoption temporary identity does not match its target and exact body",
592 ));
593 }
594 let Some(observed) = self.inspect_relative(Path::new(name), None)? else {
595 return Ok(false);
596 };
597 if observed != *expected
598 || self.read_relative_bounded(Path::new(name), expected_bytes.len(), Some(expected))?
599 != expected_bytes
600 || self.inspect_relative(Path::new(name), None)?.as_ref() != Some(expected)
601 {
602 return Ok(false);
603 }
604
605 let mut quarantine = None;
606 for attempt in 0..128_u64 {
607 let candidate = adoption_temporary_name("gc", target, expected_bytes, attempt);
608 if candidate == name {
609 continue;
610 }
611 match self.rename_no_replace(OsStr::new(name), OsStr::new(&candidate)) {
612 Ok(()) => {
613 quarantine = Some(candidate);
614 break;
615 }
616 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
617 Err(_) => {
618 return Err(loader_error(
619 "adoption temporary cannot be quarantined exactly",
620 ));
621 }
622 }
623 }
624 let quarantine = quarantine
625 .ok_or_else(|| loader_error("adoption temporary quarantine name ceiling exhausted"))?;
626 let moved = self.inspect_relative(Path::new(&quarantine), None)?;
627 let moved_matches = match moved {
628 Some(ref entry) if entry.revision.same_file_after_rename(&expected.revision) => self
629 .read_relative_bounded(Path::new(&quarantine), expected_bytes.len(), Some(entry))
630 .is_ok_and(|bytes| bytes == expected_bytes),
631 _ => false,
632 };
633 if !moved_matches {
634 self.rename_no_replace(OsStr::new(&quarantine), OsStr::new(name))
635 .map_err(|_| {
636 loader_error(
637 "adoption temporary quarantine mismatch could not be restored without replacement",
638 )
639 })?;
640 self.sync_directory()?;
641 return Ok(false);
642 }
643 self.directory.remove_file(&quarantine).map_err(|_| {
644 loader_error("adoption temporary quarantine could not remove the exact publication")
645 })?;
646 self.sync_directory()?;
647 Ok(true)
648 }
649
650 pub fn remove_if_matches(
652 &self,
653 name: &str,
654 expected: &LegacyDirectoryEntry,
655 expected_bytes: &[u8],
656 ) -> Result<bool> {
657 validate_direct_component(Path::new(name))?;
658 if name != CONVERSION_JOURNAL
659 || expected.name != OsStr::new(name)
660 || expected_bytes.len() > MAX_LEGACY_ARTIFACT_BYTES
661 {
662 return Err(loader_error(
663 "legacy journal removal identity does not match its publication",
664 ));
665 }
666 let Some(observed) = self.inspect_relative(Path::new(name), None)? else {
667 return Ok(false);
668 };
669 if observed != *expected {
670 return Ok(false);
671 }
672 let bytes =
673 self.read_relative_bounded(Path::new(name), expected_bytes.len(), Some(expected))?;
674 if bytes != expected_bytes {
675 return Ok(false);
676 }
677 let Some(revalidated) = self.inspect_relative(Path::new(name), None)? else {
678 return Ok(false);
679 };
680 if revalidated != *expected {
681 return Ok(false);
682 }
683
684 #[cfg(test)]
685 TEST_BEFORE_JOURNAL_QUARANTINE.with(|hook| {
686 if let Some(hook) = hook.borrow_mut().take() {
687 hook();
688 }
689 });
690
691 let mut quarantine = None;
695 for attempt in 0..128_u64 {
696 let candidate = adoption_temporary_name("rm", name, expected_bytes, attempt);
697 match self.rename_no_replace(OsStr::new(name), OsStr::new(&candidate)) {
698 Ok(()) => {
699 quarantine = Some(candidate);
700 break;
701 }
702 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
703 Err(_) => {
704 return Err(loader_error("legacy journal cannot be quarantined exactly"));
705 }
706 }
707 }
708 let quarantine = quarantine
709 .ok_or_else(|| loader_error("legacy journal quarantine name ceiling exhausted"))?;
710 let moved = self.inspect_relative(Path::new(&quarantine), None)?;
711 let moved_matches = match moved {
712 Some(ref entry) if entry.revision.same_file_after_rename(&expected.revision) => self
713 .read_relative_bounded(Path::new(&quarantine), expected_bytes.len(), Some(entry))
714 .is_ok_and(|bytes| bytes == expected_bytes),
715 _ => false,
716 };
717 if !moved_matches {
718 self.rename_no_replace(OsStr::new(&quarantine), OsStr::new(name))
719 .map_err(|_| {
720 loader_error(
721 "legacy journal quarantine mismatch could not be restored without replacement",
722 )
723 })?;
724 self.sync_directory()?;
725 return Ok(false);
726 }
727 self.directory.remove_file(&quarantine).map_err(|_| {
728 loader_error("legacy journal quarantine could not remove the exact publication")
729 })?;
730 self.sync_directory()?;
731 Ok(true)
732 }
733
734 #[allow(clippy::needless_return)] fn rename_no_replace(&self, source: &OsStr, destination: &OsStr) -> io::Result<()> {
736 #[cfg(any(
737 target_os = "android",
738 target_os = "linux",
739 target_os = "macos",
740 target_os = "ios",
741 target_os = "redox"
742 ))]
743 {
744 use std::os::fd::AsFd as _;
745
746 return rustix::fs::renameat_with(
747 self.directory.as_fd(),
748 source,
749 self.directory.as_fd(),
750 destination,
751 rustix::fs::RenameFlags::NOREPLACE,
752 )
753 .map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()));
754 }
755 #[cfg(windows)]
756 {
757 return self.directory.rename(source, &self.directory, destination);
759 }
760 #[cfg(not(any(
761 target_os = "android",
762 target_os = "linux",
763 target_os = "macos",
764 target_os = "ios",
765 target_os = "redox",
766 windows
767 )))]
768 {
769 let _ = (source, destination);
770 Err(io::Error::new(
771 io::ErrorKind::Unsupported,
772 "conditional journal quarantine is unsupported on this platform",
773 ))
774 }
775 }
776
777 fn open_relative_directory(&self, relative: &Path) -> Result<Self> {
778 let mut directory = self
779 .directory
780 .try_clone()
781 .map_err(|_| loader_error("legacy directory authority cannot be cloned"))?;
782 let mut display_path = self.display_path.clone();
783 for component in relative_components(relative)? {
784 directory = directory
785 .open_dir_nofollow(&component)
786 .map_err(|_| loader_error("legacy directory descendant cannot be retained"))?;
787 display_path.push(component);
788 }
789 Ok(Self {
790 directory,
791 display_path,
792 })
793 }
794
795 fn open_relative_parent(&self, relative: &Path) -> Result<(Self, OsString)> {
796 let mut components = relative_components(relative)?;
797 let name = components
798 .pop()
799 .ok_or_else(|| loader_error("legacy artifact name is empty"))?;
800 let parent = components.iter().collect::<PathBuf>();
801 Ok((self.open_relative_directory(&parent)?, name))
802 }
803
804 fn sync_directory(&self) -> Result<()> {
805 #[cfg(unix)]
806 {
807 use cap_fs_ext::OpenOptionsMaybeDirExt as _;
808 let mut options = cap_std::fs::OpenOptions::new();
809 options
810 .read(true)
811 .maybe_dir(true)
812 .follow(FollowSymlinks::No);
813 self.directory
814 .open_with(".", &options)
815 .and_then(|directory| directory.sync_all())
816 .map_err(|_| loader_error("legacy directory publication cannot be flushed"))
817 }
818 #[cfg(not(unix))]
819 {
820 Ok(())
821 }
822 }
823}
824
825fn relative_components(path: &Path) -> Result<Vec<OsString>> {
826 let mut names = Vec::new();
827 for component in path.components() {
828 match component {
829 Component::Normal(name) => names.push(name.to_owned()),
830 Component::CurDir => {}
831 Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
832 return Err(loader_error(
833 "legacy authority path is not a confined relative descendant",
834 ));
835 }
836 }
837 }
838 Ok(names)
839}
840
841fn validate_direct_component(path: &Path) -> Result<()> {
842 let components = relative_components(path)?;
843 if components.len() != 1 || !is_current_platform_direct_child(path.as_os_str()) {
844 return Err(loader_error(
845 "legacy authority publication name is not a safe direct child on this platform",
846 ));
847 }
848 Ok(())
849}
850
851fn adoption_temporary_name(kind: &str, target: &str, contents: &[u8], attempt: u64) -> String {
852 let target_digest = hex_digest(Sha256::digest(target.as_bytes()));
853 let contents_digest = hex_digest(Sha256::digest(contents));
854 format!(".tb-adopt-{kind}-{target_digest}-{contents_digest}-{attempt}.tmp")
855}
856
857fn adoption_temporary_matches(name: &str, target: &str, contents: &[u8]) -> bool {
858 let Some(identity) = name
859 .strip_prefix(".tb-adopt-")
860 .and_then(|value| value.strip_suffix(".tmp"))
861 else {
862 return false;
863 };
864 let mut fields = identity.split('-');
865 let (Some(kind), Some(target_digest), Some(contents_digest), Some(raw_attempt)) =
866 (fields.next(), fields.next(), fields.next(), fields.next())
867 else {
868 return false;
869 };
870 if fields.next().is_some()
871 || !matches!(kind, "pub" | "rm" | "gc")
872 || !is_lower_hex(target_digest, 64)
873 || !is_lower_hex(contents_digest, 64)
874 || (raw_attempt.len() > 1 && raw_attempt.starts_with('0'))
875 || raw_attempt
876 .parse::<u64>()
877 .map_or(true, |attempt| attempt >= 128)
878 {
879 return false;
880 }
881 target_digest == hex_digest(Sha256::digest(target.as_bytes()))
882 && contents_digest == hex_digest(Sha256::digest(contents))
883}
884
885fn lexical_absolute(path: &Path) -> io::Result<PathBuf> {
886 let absolute = if path.is_absolute() {
887 path.to_path_buf()
888 } else {
889 std::env::current_dir()?.join(path)
890 };
891 if !absolute.is_absolute() {
892 return Err(io::Error::new(
893 io::ErrorKind::InvalidInput,
894 "legacy directory path is not absolute",
895 ));
896 }
897 Ok(absolute)
898}
899
900#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
902#[serde(rename_all = "snake_case")]
903pub enum LegacySchemaEffect {
904 Snapshot,
906 UnchangedRunPython,
908 UnchangedNoop,
910 UnchangedCopyAttribute,
912}
913
914#[derive(Clone, Debug, Eq, PartialEq)]
915struct SnapshotAuthority {
916 source: MigrationDependencySpec,
917 schema_hash: String,
918}
919
920#[derive(Clone, Debug, Eq, PartialEq)]
921struct SchemaBinding {
922 effect: LegacySchemaEffect,
923 authority: SnapshotAuthority,
924}
925
926#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
928#[serde(deny_unknown_fields)]
929pub struct LegacyAdoptionMetadata {
930 format: String,
931 app_label: String,
932 name: String,
933 #[serde(default)]
934 dependencies: Vec<MigrationDependencySpec>,
935 checksum: String,
936 source_sha256: String,
937 schema_effect: LegacySchemaEffect,
938 schema_source: MigrationDependencySpec,
939 snapshot_schema_hash: String,
940 metadata_digest: String,
941}
942
943impl LegacyAdoptionMetadata {
944 #[allow(clippy::too_many_arguments)] pub fn new(
947 app_label: impl Into<String>,
948 name: impl Into<String>,
949 dependencies: Vec<MigrationDependencySpec>,
950 checksum: impl Into<String>,
951 source_sha256: impl Into<String>,
952 schema_effect: LegacySchemaEffect,
953 schema_source: MigrationDependencySpec,
954 snapshot_schema_hash: impl Into<String>,
955 ) -> Result<Self> {
956 let mut metadata = Self {
957 format: LEGACY_ADOPTION_METADATA_V2.to_owned(),
958 app_label: app_label.into(),
959 name: name.into(),
960 dependencies,
961 checksum: checksum.into(),
962 source_sha256: source_sha256.into(),
963 schema_effect,
964 schema_source,
965 snapshot_schema_hash: snapshot_schema_hash.into(),
966 metadata_digest: String::new(),
967 };
968 metadata.validate_fields()?;
969 metadata.metadata_digest = metadata.expected_digest();
970 Ok(metadata)
971 }
972
973 pub fn verify(&self) -> Result<()> {
975 self.validate_fields()?;
976 if self.metadata_digest != self.expected_digest() {
977 return Err(loader_error(
978 "legacy adoption metadata digest does not match its identity and dependencies",
979 ));
980 }
981 Ok(())
982 }
983
984 pub fn checksum(&self) -> &str {
986 &self.checksum
987 }
988
989 pub fn source_sha256(&self) -> &str {
991 &self.source_sha256
992 }
993
994 fn validate_fields(&self) -> Result<()> {
995 if self.format != LEGACY_ADOPTION_METADATA_V2 {
996 return Err(loader_error(
997 "legacy adoption metadata uses an unsupported format discriminator",
998 ));
999 }
1000 if self.app_label.is_empty() || self.name.is_empty() {
1001 return Err(loader_error(
1002 "legacy adoption metadata has an empty migration identity",
1003 ));
1004 }
1005 if self.checksum.len() != 16
1006 || !self
1007 .checksum
1008 .bytes()
1009 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1010 {
1011 return Err(loader_error(
1012 "legacy adoption metadata carries a malformed Python-source checksum",
1013 ));
1014 }
1015 if !is_lower_hex(&self.source_sha256, 64) {
1016 return Err(loader_error(
1017 "legacy adoption metadata carries a malformed raw-source digest",
1018 ));
1019 }
1020 if (self.metadata_digest.len() != 64 && !self.metadata_digest.is_empty())
1021 || self
1022 .metadata_digest
1023 .bytes()
1024 .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
1025 {
1026 return Err(loader_error(
1027 "legacy adoption metadata carries a malformed metadata digest",
1028 ));
1029 }
1030 if self.schema_source.app_label.is_empty() || self.schema_source.migration_name.is_empty() {
1031 return Err(loader_error(
1032 "legacy adoption metadata has an empty snapshot source identity",
1033 ));
1034 }
1035 if !is_lower_hex(&self.snapshot_schema_hash, 64) {
1036 return Err(loader_error(
1037 "legacy adoption metadata carries a malformed snapshot schema hash",
1038 ));
1039 }
1040 if self.schema_effect == LegacySchemaEffect::Snapshot
1041 && (self.schema_source.app_label != self.app_label
1042 || self.schema_source.migration_name != self.name)
1043 {
1044 return Err(loader_error(
1045 "snapshot schema effect must name the migration that owns the snapshot",
1046 ));
1047 }
1048 if matches!(
1049 self.schema_effect,
1050 LegacySchemaEffect::UnchangedRunPython
1051 | LegacySchemaEffect::UnchangedNoop
1052 | LegacySchemaEffect::UnchangedCopyAttribute
1053 ) && self.dependencies.is_empty()
1054 {
1055 return Err(loader_error(
1056 "unchanged schema effect requires a snapshot-bound dependency",
1057 ));
1058 }
1059 if self.schema_effect == LegacySchemaEffect::UnchangedCopyAttribute {
1060 return Err(loader_error(
1061 "Python-source adoption metadata cannot claim a sidecar-only copy-attribute effect",
1062 ));
1063 }
1064 Ok(())
1065 }
1066
1067 fn expected_digest(&self) -> String {
1068 let mut hasher = Sha256::new();
1069 hasher.update(b"typebridge.migration-adoption-metadata/v2\0");
1070 hash_field(&mut hasher, self.app_label.as_bytes());
1071 hash_field(&mut hasher, self.name.as_bytes());
1072 hash_field(&mut hasher, self.checksum.as_bytes());
1073 hash_field(&mut hasher, self.source_sha256.as_bytes());
1074 let schema_effect: &[u8] = match self.schema_effect {
1075 LegacySchemaEffect::Snapshot => b"snapshot",
1076 LegacySchemaEffect::UnchangedRunPython => b"unchanged_run_python",
1077 LegacySchemaEffect::UnchangedNoop => b"unchanged_noop",
1078 LegacySchemaEffect::UnchangedCopyAttribute => b"unchanged_copy_attribute",
1079 };
1080 hash_field(&mut hasher, schema_effect);
1081 hash_field(&mut hasher, self.schema_source.app_label.as_bytes());
1082 hash_field(&mut hasher, self.schema_source.migration_name.as_bytes());
1083 hash_field(&mut hasher, self.snapshot_schema_hash.as_bytes());
1084 hasher.update(
1085 u64::try_from(self.dependencies.len())
1086 .unwrap_or(u64::MAX)
1087 .to_be_bytes(),
1088 );
1089 for dependency in &self.dependencies {
1090 hash_field(&mut hasher, dependency.app_label.as_bytes());
1091 hash_field(&mut hasher, dependency.migration_name.as_bytes());
1092 }
1093 hex_digest(hasher.finalize())
1094 }
1095
1096 fn schema_binding(&self) -> SchemaBinding {
1097 SchemaBinding {
1098 effect: self.schema_effect,
1099 authority: SnapshotAuthority {
1100 source: self.schema_source.clone(),
1101 schema_hash: self.snapshot_schema_hash.clone(),
1102 },
1103 }
1104 }
1105
1106 fn into_spec(self) -> MigrationSpec {
1107 MigrationSpec {
1108 app_label: self.app_label,
1109 name: self.name,
1110 dependencies: self.dependencies,
1111 operations: Vec::new(),
1112 checksum: Some(self.checksum),
1113 source_sha256: Some(self.source_sha256),
1114 reversible: false,
1117 }
1118 }
1119}
1120
1121#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1128#[serde(deny_unknown_fields)]
1129pub struct LegacySidecarAdoptionMetadata {
1130 format: String,
1131 source_name: String,
1132 app_label: String,
1133 name: String,
1134 #[serde(default)]
1135 dependencies: Vec<MigrationDependencySpec>,
1136 checksum: String,
1137 #[serde(default)]
1138 sidecar_checksum: Option<String>,
1139 source_sha256: String,
1140 sidecar_sha256: String,
1141 schema_effect: LegacySchemaEffect,
1142 schema_source: MigrationDependencySpec,
1143 snapshot_schema_hash: String,
1144 metadata_digest: String,
1145}
1146
1147impl LegacySidecarAdoptionMetadata {
1148 #[allow(clippy::too_many_arguments)]
1150 pub fn new(
1151 source_name: impl Into<String>,
1152 app_label: impl Into<String>,
1153 name: impl Into<String>,
1154 dependencies: Vec<MigrationDependencySpec>,
1155 checksum: impl Into<String>,
1156 sidecar_checksum: Option<String>,
1157 source_sha256: impl Into<String>,
1158 sidecar_sha256: impl Into<String>,
1159 schema_effect: LegacySchemaEffect,
1160 schema_source: MigrationDependencySpec,
1161 snapshot_schema_hash: impl Into<String>,
1162 ) -> Result<Self> {
1163 let mut metadata = Self {
1164 format: LEGACY_SIDECAR_ADOPTION_METADATA_V1.to_owned(),
1165 source_name: source_name.into(),
1166 app_label: app_label.into(),
1167 name: name.into(),
1168 dependencies,
1169 checksum: checksum.into(),
1170 sidecar_checksum,
1171 source_sha256: source_sha256.into(),
1172 sidecar_sha256: sidecar_sha256.into(),
1173 schema_effect,
1174 schema_source,
1175 snapshot_schema_hash: snapshot_schema_hash.into(),
1176 metadata_digest: String::new(),
1177 };
1178 metadata.validate_fields()?;
1179 metadata.metadata_digest = metadata.expected_digest();
1180 Ok(metadata)
1181 }
1182
1183 fn verify(&self) -> Result<()> {
1184 self.validate_fields()?;
1185 if self.metadata_digest != self.expected_digest() {
1186 return Err(loader_error(
1187 "legacy sidecar adoption metadata digest does not match its authority",
1188 ));
1189 }
1190 Ok(())
1191 }
1192
1193 fn validate_fields(&self) -> Result<()> {
1194 if self.format != LEGACY_SIDECAR_ADOPTION_METADATA_V1 {
1195 return Err(loader_error(
1196 "legacy sidecar adoption metadata uses an unsupported format discriminator",
1197 ));
1198 }
1199 if !is_migration_stem(&self.source_name) {
1200 return Err(loader_error(
1201 "legacy sidecar adoption metadata has an invalid source filename stem",
1202 ));
1203 }
1204 if self.app_label.is_empty() || self.name.is_empty() {
1205 return Err(loader_error(
1206 "legacy sidecar adoption metadata has an empty effective identity",
1207 ));
1208 }
1209 if !is_lower_hex(&self.checksum, 16) {
1210 return Err(loader_error(
1211 "legacy sidecar adoption metadata carries a malformed effective checksum",
1212 ));
1213 }
1214 if let Some(sidecar_checksum) = &self.sidecar_checksum
1215 && (!is_lower_hex(sidecar_checksum, 16) || sidecar_checksum != &self.checksum)
1216 {
1217 return Err(loader_error(
1218 "legacy sidecar adoption metadata carries an invalid released checksum binding",
1219 ));
1220 }
1221 if !is_lower_hex(&self.source_sha256, 64) || !is_lower_hex(&self.sidecar_sha256, 64) {
1222 return Err(loader_error(
1223 "legacy sidecar adoption metadata carries a malformed exact-file digest",
1224 ));
1225 }
1226 if !self.metadata_digest.is_empty() && !is_lower_hex(&self.metadata_digest, 64) {
1227 return Err(loader_error(
1228 "legacy sidecar adoption metadata carries a malformed metadata digest",
1229 ));
1230 }
1231 if self.schema_source.app_label.is_empty() || self.schema_source.migration_name.is_empty() {
1232 return Err(loader_error(
1233 "legacy sidecar adoption metadata has an empty snapshot source identity",
1234 ));
1235 }
1236 if !is_lower_hex(&self.snapshot_schema_hash, 64) {
1237 return Err(loader_error(
1238 "legacy sidecar adoption metadata carries a malformed snapshot schema hash",
1239 ));
1240 }
1241 if self.schema_effect == LegacySchemaEffect::Snapshot
1242 && (self.schema_source.app_label != self.app_label
1243 || self.schema_source.migration_name != self.name)
1244 {
1245 return Err(loader_error(
1246 "sidecar snapshot schema effect must name the effective migration identity",
1247 ));
1248 }
1249 if self.schema_effect != LegacySchemaEffect::Snapshot && self.dependencies.is_empty() {
1250 return Err(loader_error(
1251 "unchanged sidecar schema effect requires a snapshot-bound dependency",
1252 ));
1253 }
1254 if self.schema_effect == LegacySchemaEffect::UnchangedRunPython {
1255 return Err(loader_error(
1256 "released sidecar authority cannot claim a Python-only schema effect",
1257 ));
1258 }
1259 Ok(())
1260 }
1261
1262 fn expected_digest(&self) -> String {
1263 let mut hasher = Sha256::new();
1264 hasher.update(b"typebridge.migration-adoption-sidecar/v1\0");
1265 hash_field(&mut hasher, self.source_name.as_bytes());
1266 hash_field(&mut hasher, self.app_label.as_bytes());
1267 hash_field(&mut hasher, self.name.as_bytes());
1268 hash_field(&mut hasher, self.checksum.as_bytes());
1269 hash_field(&mut hasher, self.source_sha256.as_bytes());
1270 hash_field(&mut hasher, self.sidecar_sha256.as_bytes());
1271 match &self.sidecar_checksum {
1272 None => hasher.update([0]),
1273 Some(checksum) => {
1274 hasher.update([1]);
1275 hash_field(&mut hasher, checksum.as_bytes());
1276 }
1277 }
1278 let schema_effect: &[u8] = match self.schema_effect {
1279 LegacySchemaEffect::Snapshot => b"snapshot",
1280 LegacySchemaEffect::UnchangedRunPython => b"unchanged_run_python",
1281 LegacySchemaEffect::UnchangedNoop => b"unchanged_noop",
1282 LegacySchemaEffect::UnchangedCopyAttribute => b"unchanged_copy_attribute",
1283 };
1284 hash_field(&mut hasher, schema_effect);
1285 hash_field(&mut hasher, self.schema_source.app_label.as_bytes());
1286 hash_field(&mut hasher, self.schema_source.migration_name.as_bytes());
1287 hash_field(&mut hasher, self.snapshot_schema_hash.as_bytes());
1288 hasher.update(
1289 u64::try_from(self.dependencies.len())
1290 .unwrap_or(u64::MAX)
1291 .to_be_bytes(),
1292 );
1293 for dependency in &self.dependencies {
1294 hash_field(&mut hasher, dependency.app_label.as_bytes());
1295 hash_field(&mut hasher, dependency.migration_name.as_bytes());
1296 }
1297 hex_digest(hasher.finalize())
1298 }
1299
1300 fn schema_binding(&self) -> SchemaBinding {
1301 SchemaBinding {
1302 effect: self.schema_effect,
1303 authority: SnapshotAuthority {
1304 source: self.schema_source.clone(),
1305 schema_hash: self.snapshot_schema_hash.clone(),
1306 },
1307 }
1308 }
1309
1310 fn into_spec(
1311 self,
1312 mut sidecar: MigrationSpec,
1313 fallback_app_label: &str,
1314 ) -> Result<MigrationSpec> {
1315 let effective_app_label = if sidecar.app_label.is_empty() {
1316 fallback_app_label
1317 } else {
1318 sidecar.app_label.as_str()
1319 };
1320 let effective_name = if sidecar.name.is_empty() {
1321 self.source_name.as_str()
1322 } else {
1323 sidecar.name.as_str()
1324 };
1325 if effective_app_label != self.app_label
1326 || effective_name != self.name
1327 || sidecar.dependencies != self.dependencies
1328 || sidecar.checksum != self.sidecar_checksum
1329 {
1330 return Err(loader_error(
1331 "retained legacy sidecar semantics differ from their adoption metadata",
1332 ));
1333 }
1334 match self.schema_effect {
1335 LegacySchemaEffect::Snapshot => {}
1336 LegacySchemaEffect::UnchangedNoop if sidecar.operations.is_empty() => {}
1337 LegacySchemaEffect::UnchangedCopyAttribute
1338 if !sidecar.operations.is_empty()
1339 && sidecar.operations.iter().all(|operation| {
1340 matches!(operation, OperationSpec::CopyAttribute { .. })
1341 }) => {}
1342 LegacySchemaEffect::UnchangedRunPython
1343 | LegacySchemaEffect::UnchangedNoop
1344 | LegacySchemaEffect::UnchangedCopyAttribute => {
1345 return Err(loader_error(
1346 "retained legacy sidecar operations contradict their schema-effect binding",
1347 ));
1348 }
1349 }
1350 sidecar.app_label = self.app_label;
1351 sidecar.name = self.name;
1352 sidecar.checksum = Some(self.checksum);
1353 sidecar.source_sha256 = Some(self.source_sha256);
1354 sidecar.operations.clear();
1357 Ok(sidecar)
1358 }
1359}
1360
1361#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1369#[serde(deny_unknown_fields)]
1370pub struct LegacyIgnoredSourceMetadata {
1371 format: String,
1372 name: String,
1373 checksum: String,
1374 source_sha256: String,
1375 metadata_digest: String,
1376}
1377
1378impl LegacyIgnoredSourceMetadata {
1379 pub fn new(
1381 name: impl Into<String>,
1382 checksum: impl Into<String>,
1383 source_sha256: impl Into<String>,
1384 ) -> Result<Self> {
1385 let mut metadata = Self {
1386 format: LEGACY_IGNORED_SOURCE_METADATA_V1.to_owned(),
1387 name: name.into(),
1388 checksum: checksum.into(),
1389 source_sha256: source_sha256.into(),
1390 metadata_digest: String::new(),
1391 };
1392 metadata.validate_fields()?;
1393 metadata.metadata_digest = metadata.expected_digest();
1394 Ok(metadata)
1395 }
1396
1397 pub fn verify(&self) -> Result<()> {
1399 self.validate_fields()?;
1400 if self.metadata_digest != self.expected_digest() {
1401 return Err(loader_error(
1402 "ignored legacy source metadata digest does not match its identity",
1403 ));
1404 }
1405 Ok(())
1406 }
1407
1408 pub fn name(&self) -> &str {
1410 &self.name
1411 }
1412
1413 pub fn checksum(&self) -> &str {
1415 &self.checksum
1416 }
1417
1418 pub fn source_sha256(&self) -> &str {
1420 &self.source_sha256
1421 }
1422
1423 fn validate_fields(&self) -> Result<()> {
1424 if self.format != LEGACY_IGNORED_SOURCE_METADATA_V1 {
1425 return Err(loader_error(
1426 "ignored legacy source metadata uses an unsupported format discriminator",
1427 ));
1428 }
1429 if !is_migration_stem(&self.name) {
1430 return Err(loader_error(
1431 "ignored legacy source metadata has an invalid migration filename stem",
1432 ));
1433 }
1434 if !is_lower_hex(&self.checksum, 16) {
1435 return Err(loader_error(
1436 "ignored legacy source metadata carries a malformed Python-source checksum",
1437 ));
1438 }
1439 if !is_lower_hex(&self.source_sha256, 64) {
1440 return Err(loader_error(
1441 "ignored legacy source metadata carries a malformed raw-source digest",
1442 ));
1443 }
1444 if !self.metadata_digest.is_empty() && !is_lower_hex(&self.metadata_digest, 64) {
1445 return Err(loader_error(
1446 "ignored legacy source metadata carries a malformed metadata digest",
1447 ));
1448 }
1449 Ok(())
1450 }
1451
1452 fn expected_digest(&self) -> String {
1453 let mut hasher = Sha256::new();
1454 hasher.update(b"typebridge.migration-adoption-ignored-source/v1\0");
1455 hash_field(&mut hasher, self.name.as_bytes());
1456 hash_field(&mut hasher, self.checksum.as_bytes());
1457 hash_field(&mut hasher, self.source_sha256.as_bytes());
1458 hex_digest(hasher.finalize())
1459 }
1460}
1461
1462#[derive(Deserialize)]
1463struct LegacyMetadataFormatProbe {
1464 format: String,
1465}
1466
1467enum LegacySourceMetadata {
1468 Migration(LegacyAdoptionMetadata),
1469 Sidecar(LegacySidecarAdoptionMetadata),
1470 Ignored(LegacyIgnoredSourceMetadata),
1471}
1472
1473pub struct LegacyAdoptionHistory {
1475 graph: MigrationGraph,
1476 directory: LegacyDirectoryAuthority,
1477 directory_capture: LegacyDirectoryCapture,
1478 snapshots: Option<(LegacyDirectoryAuthority, LegacyDirectoryCapture)>,
1479 snapshot_authorities: BTreeMap<(String, String), SnapshotAuthority>,
1480 root_file_digests: BTreeMap<OsString, String>,
1481 consumed_bytes: usize,
1482}
1483
1484impl std::fmt::Debug for LegacyAdoptionHistory {
1485 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1486 formatter
1487 .debug_struct("LegacyAdoptionHistory")
1488 .field("graph", &self.graph)
1489 .field("directory", &self.directory.display_path)
1490 .field("snapshot_authorities", &self.snapshot_authorities)
1491 .finish_non_exhaustive()
1492 }
1493}
1494
1495impl LegacyAdoptionHistory {
1496 pub const fn graph(&self) -> &MigrationGraph {
1498 &self.graph
1499 }
1500
1501 pub fn directory(&self) -> &Path {
1503 &self.directory.display_path
1504 }
1505
1506 pub fn require_unchanged(&self) -> Result<()> {
1514 reconstruct_legacy_head(self).map(|_| ())
1515 }
1516
1517 pub fn require_unchanged_head(&self, expected: &VerifiedLegacyHead) -> Result<()> {
1519 let observed = reconstruct_legacy_head(self)?;
1520 if &observed != expected {
1521 return Err(loader_error(
1522 "legacy reconstructed head changed after its checked capture",
1523 ));
1524 }
1525 Ok(())
1526 }
1527
1528 fn require_retained_membership(&self) -> Result<()> {
1529 self.directory
1530 .require_recognized_root_capture(&self.directory_capture)?;
1531 let mut aggregate = 0usize;
1532 for (name, expected_digest) in &self.root_file_digests {
1533 let entry = self
1534 .directory_capture
1535 .entries
1536 .iter()
1537 .find(|entry| &entry.name == name)
1538 .ok_or_else(|| loader_error("retained root digest entry disappeared"))?;
1539 let bytes = read_bounded(
1540 &self.directory,
1541 entry,
1542 MAX_LEGACY_ARTIFACT_BYTES,
1543 &mut aggregate,
1544 )?;
1545 if hex_digest(Sha256::digest(&bytes)) != *expected_digest {
1546 return Err(loader_error(
1547 "recognized legacy root file body changed after checked capture",
1548 ));
1549 }
1550 }
1551 if let Some((snapshots, capture)) = &self.snapshots {
1552 snapshots.require_snapshot_version_capture(capture)?;
1553 }
1554 Ok(())
1555 }
1556
1557 fn heads(&self) -> Result<Vec<&MigrationSpec>> {
1558 let depended_on = self
1559 .graph
1560 .migrations
1561 .iter()
1562 .flat_map(|migration| {
1563 migration.dependencies.iter().map(|dependency| {
1564 (
1565 dependency.app_label.clone(),
1566 dependency.migration_name.clone(),
1567 )
1568 })
1569 })
1570 .collect::<BTreeSet<_>>();
1571 let mut heads = self
1572 .graph
1573 .migrations
1574 .iter()
1575 .filter(|migration| {
1576 !depended_on.contains(&(migration.app_label.clone(), migration.name.clone()))
1577 })
1578 .collect::<Vec<_>>();
1579 if heads.is_empty() {
1580 return Err(loader_error(
1581 "legacy adoption history has no graph head after validation",
1582 ));
1583 }
1584 heads.sort_by(|left, right| {
1585 (&left.app_label, &left.name).cmp(&(&right.app_label, &right.name))
1586 });
1587 Ok(heads)
1588 }
1589}
1590
1591#[derive(Clone, Debug, Eq, PartialEq)]
1593pub struct VerifiedLegacyHead {
1594 source_migration: String,
1595 schema_typeql: String,
1596}
1597
1598impl VerifiedLegacyHead {
1599 pub fn source_migration(&self) -> &str {
1601 &self.source_migration
1602 }
1603
1604 pub fn schema_typeql(&self) -> &str {
1606 &self.schema_typeql
1607 }
1608}
1609
1610fn parse_legacy_source_metadata(bytes: &[u8], path: &Path) -> Result<LegacySourceMetadata> {
1611 let probe: LegacyMetadataFormatProbe = serde_json::from_slice(bytes).map_err(|error| {
1612 loader_error(format!(
1613 "failed to inspect adoption metadata {}: {error}",
1614 path.display()
1615 ))
1616 })?;
1617 match probe.format.as_str() {
1618 LEGACY_ADOPTION_METADATA_V2 => serde_json::from_slice(bytes)
1619 .map(LegacySourceMetadata::Migration)
1620 .map_err(|error| {
1621 loader_error(format!(
1622 "failed to parse adoption metadata {}: {error}",
1623 path.display()
1624 ))
1625 }),
1626 LEGACY_SIDECAR_ADOPTION_METADATA_V1 => serde_json::from_slice(bytes)
1627 .map(LegacySourceMetadata::Sidecar)
1628 .map_err(|error| {
1629 loader_error(format!(
1630 "failed to parse sidecar adoption metadata {}: {error}",
1631 path.display()
1632 ))
1633 }),
1634 LEGACY_IGNORED_SOURCE_METADATA_V1 => serde_json::from_slice(bytes)
1635 .map(LegacySourceMetadata::Ignored)
1636 .map_err(|error| {
1637 loader_error(format!(
1638 "failed to parse ignored-source adoption metadata {}: {error}",
1639 path.display()
1640 ))
1641 }),
1642 _ => Err(loader_error(
1643 "legacy adoption metadata uses an unsupported format discriminator",
1644 )),
1645 }
1646}
1647
1648pub fn load_adoption_history(directory: &Path) -> Result<LegacyAdoptionHistory> {
1657 let directory = LegacyDirectoryAuthority::open_root(directory)?;
1658 load_adoption_history_in(directory)
1659}
1660
1661fn load_adoption_history_in(directory: LegacyDirectoryAuthority) -> Result<LegacyAdoptionHistory> {
1662 let fallback_app_label = directory
1663 .display_path
1664 .file_name()
1665 .and_then(OsStr::to_str)
1666 .filter(|name| !name.is_empty())
1667 .ok_or_else(|| loader_error("legacy migration directory has no UTF-8 app-label name"))?
1668 .to_owned();
1669 let mut directory_capture = directory.capture()?;
1670 let mut python = BTreeMap::<String, LegacyDirectoryEntry>::new();
1671 let mut sidecars = BTreeMap::<String, LegacyDirectoryEntry>::new();
1672 let mut archives = BTreeMap::<String, LegacyDirectoryEntry>::new();
1673
1674 if directory_capture
1675 .entries
1676 .iter()
1677 .any(|entry| entry.name == OsStr::new(CONVERSION_JOURNAL))
1678 {
1679 return Err(loader_error(
1680 "legacy adoption conversion journal is present; resume the trusted converter",
1681 ));
1682 }
1683
1684 for entry in &directory_capture.entries {
1685 let Some(name) = entry.name.to_str() else {
1686 if looks_migration_shaped_bytes(&entry.name) {
1687 return Err(loader_error(
1688 "recognized legacy migration filename is not valid UTF-8",
1689 ));
1690 }
1691 continue;
1692 };
1693 if let Some(stem) = name.strip_suffix(ADOPTION_SUFFIX) {
1694 if is_migration_stem(stem) {
1695 require_regular(entry)?;
1696 archives.insert(stem.to_owned(), entry.clone());
1697 }
1698 continue;
1699 }
1700 let Some((stem, extension)) = name.rsplit_once('.') else {
1701 continue;
1702 };
1703 if !is_migration_stem(stem) {
1704 continue;
1705 }
1706 match extension {
1707 "py" => {
1708 require_regular(entry)?;
1709 python.insert(stem.to_owned(), entry.clone());
1710 }
1711 "json" => {
1712 sidecars.insert(stem.to_owned(), entry.clone());
1713 }
1714 _ => {}
1715 }
1716 }
1717
1718 for stem in archives.keys() {
1719 if !python.contains_key(stem) {
1720 return Err(loader_error(format!(
1721 "legacy metadata {stem} has no Python source to verify"
1722 )));
1723 }
1724 }
1725
1726 let mut retained_names = python
1727 .values()
1728 .chain(sidecars.values())
1729 .chain(archives.values())
1730 .map(|entry| entry.name.clone())
1731 .collect::<BTreeSet<_>>();
1732 retained_names.insert(OsString::from("snapshots"));
1733 directory_capture
1734 .entries
1735 .retain(|entry| retained_names.contains(&entry.name));
1736
1737 let mut aggregate = 0usize;
1738 let mut migrations = Vec::with_capacity(python.len());
1739 let mut schema_bindings = BTreeMap::new();
1740 let mut root_file_digests = BTreeMap::<OsString, String>::new();
1741 for (stem, py_entry) in python {
1742 let py_bytes = read_bounded(
1743 &directory,
1744 &py_entry,
1745 MAX_LEGACY_ARTIFACT_BYTES,
1746 &mut aggregate,
1747 )?;
1748 let source_sha256 = hex_digest(Sha256::digest(&py_bytes));
1749 root_file_digests.insert(py_entry.name.clone(), source_sha256.clone());
1750 let source_metadata = if let Some(archive_entry) = archives.get(&stem) {
1751 let bytes = read_bounded(
1752 &directory,
1753 archive_entry,
1754 MAX_LEGACY_ARTIFACT_BYTES,
1755 &mut aggregate,
1756 )?;
1757 root_file_digests.insert(
1758 archive_entry.name.clone(),
1759 hex_digest(Sha256::digest(&bytes)),
1760 );
1761 parse_legacy_source_metadata(&bytes, &directory.display_path.join(&archive_entry.name))?
1762 } else {
1763 return Err(loader_error(format!(
1764 "migration {stem} has no checksum-bound adoption metadata; run `python -m type_bridge.migration.sidecar {}` through the trusted Python environment",
1765 directory.display_path.display()
1766 )));
1767 };
1768
1769 match source_metadata {
1770 LegacySourceMetadata::Migration(archive) => {
1771 archive.verify()?;
1772 if sidecars.contains_key(&stem) {
1773 return Err(loader_error(format!(
1774 "source-authoritative adoption metadata for {stem} conflicts with a retained JSON sidecar"
1775 )));
1776 }
1777 if archive.source_sha256() != source_sha256 {
1778 return Err(loader_error(format!(
1779 "adoption metadata drift detected for {stem}: raw Python source digest differs"
1780 )));
1781 }
1782 if archive.name != stem {
1783 return Err(loader_error(format!(
1784 "legacy metadata identity {} does not match filename stem {stem}",
1785 archive.name
1786 )));
1787 }
1788 if archive.app_label != fallback_app_label {
1789 return Err(loader_error(format!(
1790 "legacy metadata owner {} does not match migration directory app label {fallback_app_label}",
1791 archive.app_label
1792 )));
1793 }
1794 schema_bindings.insert(
1795 (archive.app_label.clone(), archive.name.clone()),
1796 archive.schema_binding(),
1797 );
1798 migrations.push(archive.into_spec());
1799 }
1800 LegacySourceMetadata::Sidecar(archive) => {
1801 archive.verify()?;
1802 if archive.source_sha256 != source_sha256 {
1803 return Err(loader_error(format!(
1804 "sidecar adoption metadata drift detected for {stem}: raw Python source digest differs"
1805 )));
1806 }
1807 if archive.source_name != stem {
1808 return Err(loader_error(format!(
1809 "sidecar adoption source identity {} does not match filename stem {stem}",
1810 archive.source_name
1811 )));
1812 }
1813 let sidecar_entry = sidecars.get(&stem).ok_or_else(|| {
1814 loader_error(format!(
1815 "sidecar-authoritative migration {stem} has no retained JSON sidecar"
1816 ))
1817 })?;
1818 let sidecar_bytes = read_bounded(
1819 &directory,
1820 sidecar_entry,
1821 MAX_LEGACY_ARTIFACT_BYTES,
1822 &mut aggregate,
1823 )?;
1824 let sidecar_sha256 = hex_digest(Sha256::digest(&sidecar_bytes));
1825 if archive.sidecar_sha256 != sidecar_sha256 {
1826 return Err(loader_error(format!(
1827 "sidecar adoption metadata drift detected for {stem}: exact JSON digest differs"
1828 )));
1829 }
1830 root_file_digests.insert(sidecar_entry.name.clone(), sidecar_sha256);
1831 let sidecar: MigrationSpec =
1832 serde_json::from_slice(&sidecar_bytes).map_err(|error| {
1833 loader_error(format!(
1834 "failed to parse retained sidecar {}: {error}",
1835 directory.display_path.join(&sidecar_entry.name).display()
1836 ))
1837 })?;
1838 let key = (archive.app_label.clone(), archive.name.clone());
1839 schema_bindings.insert(key, archive.schema_binding());
1840 migrations.push(archive.into_spec(sidecar, &fallback_app_label)?);
1841 }
1842 LegacySourceMetadata::Ignored(ignored) => {
1843 ignored.verify()?;
1844 if sidecars.contains_key(&stem) {
1845 return Err(loader_error(format!(
1846 "ignored-source adoption metadata for {stem} conflicts with a retained JSON sidecar"
1847 )));
1848 }
1849 if ignored.source_sha256() != source_sha256 {
1850 return Err(loader_error(format!(
1851 "ignored-source adoption metadata drift detected for {stem}: raw Python source digest differs"
1852 )));
1853 }
1854 if ignored.name() != stem {
1855 return Err(loader_error(format!(
1856 "ignored-source metadata identity {} does not match filename stem {stem}",
1857 ignored.name()
1858 )));
1859 }
1860 }
1861 }
1862 }
1863 for entry in sidecars.values() {
1864 if entry.is_file() && !root_file_digests.contains_key(&entry.name) {
1865 let bytes = read_bounded(&directory, entry, MAX_LEGACY_ARTIFACT_BYTES, &mut aggregate)?;
1866 root_file_digests.insert(entry.name.clone(), hex_digest(Sha256::digest(&bytes)));
1867 }
1868 }
1869
1870 let graph = MigrationGraph { migrations };
1871 let errors = validate_graph(&graph, &[]);
1872 if !errors.is_empty() {
1873 return Err(MigrationError::Planning { errors });
1874 }
1875 let snapshot_authorities = resolve_schema_bindings(&graph, &schema_bindings)?;
1876 let snapshots = if let Some(entry) = directory_capture
1877 .entries
1878 .iter()
1879 .find(|entry| entry.name == OsStr::new("snapshots"))
1880 {
1881 let retained = directory.open_child(entry)?;
1882 let mut capture = retained.capture()?;
1883 capture
1884 .entries
1885 .retain(|candidate| candidate.name.to_str().is_some_and(is_snapshot_version));
1886 Some((retained, capture))
1887 } else {
1888 None
1889 };
1890 if let Some((snapshots, capture)) = &snapshots {
1891 snapshots.require_snapshot_version_capture(capture)?;
1892 }
1893 directory.require_recognized_root_capture(&directory_capture)?;
1894 Ok(LegacyAdoptionHistory {
1895 graph,
1896 directory,
1897 directory_capture,
1898 snapshots,
1899 snapshot_authorities,
1900 root_file_digests,
1901 consumed_bytes: aggregate,
1902 })
1903}
1904
1905fn resolve_schema_bindings(
1906 graph: &MigrationGraph,
1907 bindings: &BTreeMap<(String, String), SchemaBinding>,
1908) -> Result<BTreeMap<(String, String), SnapshotAuthority>> {
1909 let mut resolved = BTreeMap::new();
1910 let mut pending = BTreeMap::<
1911 (String, String),
1912 (LegacySchemaEffect, SnapshotAuthority, Vec<(String, String)>),
1913 >::new();
1914 let mut dependents = BTreeMap::<(String, String), Vec<(String, String)>>::new();
1915
1916 for migration in &graph.migrations {
1917 let key = (migration.app_label.clone(), migration.name.clone());
1918 let binding = bindings.get(&key).ok_or_else(|| {
1919 loader_error(format!(
1920 "legacy migration {}.{} has no schema-effect binding",
1921 migration.app_label, migration.name
1922 ))
1923 })?;
1924 match binding.effect {
1925 LegacySchemaEffect::Snapshot => {
1926 resolved.insert(key, binding.authority.clone());
1927 }
1928 LegacySchemaEffect::UnchangedRunPython
1929 | LegacySchemaEffect::UnchangedNoop
1930 | LegacySchemaEffect::UnchangedCopyAttribute => {
1931 let dependencies = migration
1932 .dependencies
1933 .iter()
1934 .map(|dependency| {
1935 (
1936 dependency.app_label.clone(),
1937 dependency.migration_name.clone(),
1938 )
1939 })
1940 .collect::<Vec<_>>();
1941 if dependencies.is_empty() {
1942 return Err(loader_error(format!(
1943 "unchanged migration {}.{} has no parent authority",
1944 migration.app_label, migration.name
1945 )));
1946 }
1947 for dependency in &dependencies {
1948 dependents
1949 .entry(dependency.clone())
1950 .or_default()
1951 .push(key.clone());
1952 }
1953 pending.insert(
1954 key,
1955 (binding.effect, binding.authority.clone(), dependencies),
1956 );
1957 }
1958 }
1959 }
1960
1961 let mut unresolved_parent_counts = pending
1962 .iter()
1963 .map(|(key, (_, _, dependencies))| {
1964 (
1965 key.clone(),
1966 dependencies
1967 .iter()
1968 .filter(|dependency| !resolved.contains_key(*dependency))
1969 .count(),
1970 )
1971 })
1972 .collect::<BTreeMap<_, _>>();
1973 let mut ready = unresolved_parent_counts
1974 .iter()
1975 .filter_map(|(key, count)| (*count == 0).then_some(key.clone()))
1976 .collect::<BTreeSet<_>>();
1977
1978 while let Some(key) = ready.pop_first() {
1979 let (_, bound_authority, dependencies) = pending
1980 .get(&key)
1981 .ok_or_else(|| loader_error("legacy schema binding work item disappeared"))?;
1982 let parents = dependencies
1983 .iter()
1984 .map(|dependency| {
1985 resolved.get(dependency).ok_or_else(|| {
1986 loader_error("legacy schema binding parent was not resolved deterministically")
1987 })
1988 })
1989 .collect::<Result<Vec<_>>>()?;
1990 let (parent, remaining_parents) = parents.split_first().ok_or_else(|| {
1991 loader_error(format!(
1992 "unchanged migration {}.{} has no parent authority",
1993 key.0, key.1
1994 ))
1995 })?;
1996 let parent = *parent;
1997 if remaining_parents
1998 .iter()
1999 .any(|authority| authority.schema_hash != parent.schema_hash)
2000 {
2001 return Err(loader_error(format!(
2002 "unchanged migration {}.{} merges divergent snapshot authorities with different schema hashes",
2003 key.0, key.1
2004 )));
2005 }
2006 let canonical_parent = parents
2007 .iter()
2008 .copied()
2009 .min_by(|left, right| {
2010 (
2011 &left.source.app_label,
2012 &left.source.migration_name,
2013 &left.schema_hash,
2014 )
2015 .cmp(&(
2016 &right.source.app_label,
2017 &right.source.migration_name,
2018 &right.schema_hash,
2019 ))
2020 })
2021 .ok_or_else(|| loader_error("legacy schema binding parent set disappeared"))?;
2022 if bound_authority != canonical_parent {
2023 return Err(loader_error(format!(
2024 "unchanged migration {}.{} does not bind its deterministic parent snapshot authority",
2025 key.0, key.1
2026 )));
2027 }
2028 resolved.insert(key.clone(), bound_authority.clone());
2029 if let Some(children) = dependents.get(&key) {
2030 for child in children {
2031 let remaining = unresolved_parent_counts.get_mut(child).ok_or_else(|| {
2032 loader_error("legacy schema binding dependency count disappeared")
2033 })?;
2034 *remaining = remaining.saturating_sub(1);
2035 if *remaining == 0 {
2036 ready.insert(child.clone());
2037 }
2038 }
2039 }
2040 }
2041 if resolved.len() != graph.migrations.len() {
2042 return Err(loader_error(
2043 "legacy schema-effect bindings could not be resolved over the validated graph",
2044 ));
2045 }
2046 require_unambiguous_snapshot_owners(&resolved)?;
2047 Ok(resolved)
2048}
2049
2050fn require_unambiguous_snapshot_owners(
2051 authorities: &BTreeMap<(String, String), SnapshotAuthority>,
2052) -> Result<()> {
2053 let mut owners_by_source = BTreeMap::<&str, BTreeSet<&str>>::new();
2054 for authority in authorities.values() {
2055 owners_by_source
2056 .entry(authority.source.migration_name.as_str())
2057 .or_default()
2058 .insert(authority.source.app_label.as_str());
2059 }
2060 if let Some((source, owners)) = owners_by_source
2061 .into_iter()
2062 .find(|(_, owners)| owners.len() > 1)
2063 {
2064 return Err(loader_error(format!(
2065 "legacy snapshot source {source} is ambiguous across app labels: {}",
2066 owners.into_iter().collect::<Vec<_>>().join(", ")
2067 )));
2068 }
2069 Ok(())
2070}
2071
2072struct SnapshotManifest {
2073 version: String,
2074 source_migration: String,
2075 schema_hash: String,
2076 file_hashes: BTreeMap<String, String>,
2077}
2078
2079fn parse_snapshot_manifest(bytes: &[u8], path: &Path) -> Result<SnapshotManifest> {
2080 let mut python_compatible = sanitize_python_json_constants(bytes);
2097 sanitize_python_json_root_key_surrogates(&mut python_compatible);
2098 let mut fields: BTreeMap<String, Box<serde_json::value::RawValue>> =
2099 serde_json::from_slice(&python_compatible).map_err(|error| {
2100 loader_error(format!(
2101 "failed to parse snapshot manifest {}: {error}",
2102 path.display()
2103 ))
2104 })?;
2105 let version = parse_snapshot_string_field(&mut fields, "version", path)?;
2106 let source_migration = parse_snapshot_string_field(&mut fields, "source_migration", path)?;
2107 let schema_hash = parse_snapshot_string_field(&mut fields, "schema_hash", path)?;
2108 let file_hashes_raw = fields.remove("file_hashes").ok_or_else(|| {
2109 loader_error(format!(
2110 "failed to validate snapshot manifest {}: missing field `file_hashes`",
2111 path.display()
2112 ))
2113 })?;
2114 let raw_file_hashes: BTreeMap<String, Box<serde_json::value::RawValue>> =
2115 serde_json::from_str(file_hashes_raw.get()).map_err(|error| {
2116 loader_error(format!(
2117 "failed to validate snapshot manifest {}: file_hashes: {error}",
2118 path.display()
2119 ))
2120 })?;
2121 let mut file_hashes = BTreeMap::new();
2122 for (name, raw_hash) in raw_file_hashes {
2123 let hash = serde_json::from_str(raw_hash.get()).map_err(|error| {
2124 loader_error(format!(
2125 "failed to validate snapshot manifest {}: file_hashes[{name:?}]: {error}",
2126 path.display()
2127 ))
2128 })?;
2129 file_hashes.insert(name, hash);
2130 }
2131 Ok(SnapshotManifest {
2132 version,
2133 source_migration,
2134 schema_hash,
2135 file_hashes,
2136 })
2137}
2138
2139fn parse_snapshot_string_field(
2140 fields: &mut BTreeMap<String, Box<serde_json::value::RawValue>>,
2141 name: &'static str,
2142 path: &Path,
2143) -> Result<String> {
2144 let raw = fields.remove(name).ok_or_else(|| {
2145 loader_error(format!(
2146 "failed to validate snapshot manifest {}: missing field `{name}`",
2147 path.display()
2148 ))
2149 })?;
2150 serde_json::from_str(raw.get()).map_err(|error| {
2151 loader_error(format!(
2152 "failed to validate snapshot manifest {}: {name}: {error}",
2153 path.display()
2154 ))
2155 })
2156}
2157
2158fn sanitize_python_json_constants(bytes: &[u8]) -> Vec<u8> {
2159 const CONSTANTS: [(&[u8], &[u8]); 3] = [
2160 (b"-Infinity", b"-0.000000"),
2161 (b"Infinity", b"0.000000"),
2162 (b"NaN", b"0.0"),
2163 ];
2164 let mut sanitized = bytes.to_vec();
2165 let mut offset = 0usize;
2166 let mut in_string = false;
2167 let mut escaped = false;
2168 while offset < bytes.len() {
2169 let byte = bytes[offset];
2170 if in_string {
2171 if escaped {
2172 escaped = false;
2173 } else if byte == b'\\' {
2174 escaped = true;
2175 } else if byte == b'"' {
2176 in_string = false;
2177 }
2178 offset += 1;
2179 continue;
2180 }
2181 if byte == b'"' {
2182 in_string = true;
2183 offset += 1;
2184 continue;
2185 }
2186 let mut replaced = false;
2187 for (constant, replacement) in CONSTANTS {
2188 let end = offset.saturating_add(constant.len());
2189 if bytes.get(offset..end) == Some(constant)
2190 && is_json_value_boundary_before(bytes, offset)
2191 && is_json_value_boundary_after(bytes, end)
2192 {
2193 sanitized[offset..end].copy_from_slice(replacement);
2194 offset = end;
2195 replaced = true;
2196 break;
2197 }
2198 }
2199 if !replaced {
2200 offset += 1;
2201 }
2202 }
2203 sanitized
2204}
2205
2206fn sanitize_python_json_root_key_surrogates(bytes: &mut [u8]) {
2207 let mut offset = 0usize;
2208 let mut object_depth = 0usize;
2209 let mut array_depth = 0usize;
2210 while offset < bytes.len() {
2211 match bytes[offset] {
2212 b'{' => {
2213 object_depth = object_depth.saturating_add(1);
2214 offset += 1;
2215 }
2216 b'}' => {
2217 object_depth = object_depth.saturating_sub(1);
2218 offset += 1;
2219 }
2220 b'[' => {
2221 array_depth = array_depth.saturating_add(1);
2222 offset += 1;
2223 }
2224 b']' => {
2225 array_depth = array_depth.saturating_sub(1);
2226 offset += 1;
2227 }
2228 b'"' => {
2229 let Some(end) = scan_json_string_end(bytes, offset) else {
2230 return;
2231 };
2232 let mut following = end + 1;
2233 while bytes
2234 .get(following)
2235 .is_some_and(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
2236 {
2237 following += 1;
2238 }
2239 if object_depth == 1 && array_depth == 0 && bytes.get(following) == Some(&b':') {
2240 replace_lone_surrogate_escapes(bytes, offset + 1, end);
2241 }
2242 offset = end + 1;
2243 }
2244 _ => offset += 1,
2245 }
2246 }
2247}
2248
2249fn scan_json_string_end(bytes: &[u8], start: usize) -> Option<usize> {
2250 let mut offset = start + 1;
2251 while offset < bytes.len() {
2252 match bytes[offset] {
2253 b'"' => return Some(offset),
2254 b'\\' => offset = offset.saturating_add(2),
2255 _ => offset += 1,
2256 }
2257 }
2258 None
2259}
2260
2261fn parse_json_hex_code_unit(bytes: &[u8]) -> Option<u16> {
2262 if bytes.len() != 4 {
2263 return None;
2264 }
2265 bytes.iter().try_fold(0u16, |value, byte| {
2266 let digit = match byte {
2267 b'0'..=b'9' => u16::from(byte - b'0'),
2268 b'a'..=b'f' => u16::from(byte - b'a') + 10,
2269 b'A'..=b'F' => u16::from(byte - b'A') + 10,
2270 _ => return None,
2271 };
2272 Some((value << 4) | digit)
2273 })
2274}
2275
2276fn replace_lone_surrogate_escapes(bytes: &mut [u8], start: usize, end: usize) {
2277 let mut offset = start;
2278 while offset < end {
2279 if bytes[offset] != b'\\' {
2280 offset += 1;
2281 continue;
2282 }
2283 let Some(code_unit) = bytes
2284 .get(offset + 2..offset + 6)
2285 .filter(|_| bytes.get(offset + 1) == Some(&b'u'))
2286 .and_then(parse_json_hex_code_unit)
2287 else {
2288 offset = offset.saturating_add(2);
2289 continue;
2290 };
2291 if (0xd800..=0xdbff).contains(&code_unit) {
2292 let paired_low = bytes
2293 .get(offset + 8..offset + 12)
2294 .filter(|_| {
2295 bytes.get(offset + 6) == Some(&b'\\') && bytes.get(offset + 7) == Some(&b'u')
2296 })
2297 .and_then(parse_json_hex_code_unit)
2298 .is_some_and(|low| (0xdc00..=0xdfff).contains(&low));
2299 if paired_low {
2300 offset += 12;
2301 continue;
2302 }
2303 }
2304 if (0xd800..=0xdfff).contains(&code_unit) {
2305 bytes[offset + 2..offset + 6].copy_from_slice(b"fffd");
2306 }
2307 offset += 6;
2308 }
2309}
2310
2311fn is_json_value_boundary_before(bytes: &[u8], offset: usize) -> bool {
2312 offset == 0
2313 || matches!(
2314 bytes[offset - 1],
2315 b'[' | b'{' | b',' | b':' | b' ' | b'\t' | b'\r' | b'\n'
2316 )
2317}
2318
2319fn is_json_value_boundary_after(bytes: &[u8], offset: usize) -> bool {
2320 offset == bytes.len()
2321 || matches!(
2322 bytes[offset],
2323 b']' | b'}' | b',' | b' ' | b'\t' | b'\r' | b'\n'
2324 )
2325}
2326
2327pub fn reconstruct_legacy_head(history: &LegacyAdoptionHistory) -> Result<VerifiedLegacyHead> {
2335 require_unambiguous_snapshot_owners(&history.snapshot_authorities)?;
2336 let heads = history.heads()?;
2337 history.require_retained_membership()?;
2338 let (snapshots, snapshot_capture) = history.snapshots.as_ref().ok_or_else(|| {
2339 loader_error("legacy snapshots authority was absent when adoption history was retained")
2340 })?;
2341 let needed_authorities = history
2342 .snapshot_authorities
2343 .values()
2344 .map(|authority| {
2345 (
2346 authority.source.app_label.clone(),
2347 authority.source.migration_name.clone(),
2348 authority.schema_hash.clone(),
2349 )
2350 })
2351 .collect::<BTreeSet<_>>();
2352 let mut expected_hashes_by_source = BTreeMap::<String, BTreeSet<String>>::new();
2353 for (_, source, schema_hash) in &needed_authorities {
2354 expected_hashes_by_source
2355 .entry(source.clone())
2356 .or_default()
2357 .insert(schema_hash.clone());
2358 }
2359 let mut available = BTreeMap::<(String, String), VerifiedLegacyHead>::new();
2360 let mut verified_snapshot_captures = Vec::new();
2361 let mut scanned_snapshot_manifests = Vec::new();
2362 let mut aggregate = history.consumed_bytes;
2363 let mut nested_entries = snapshot_capture.entries.len();
2364 for entry in &snapshot_capture.entries {
2365 let Some(version) = entry.name.to_str() else {
2366 continue;
2367 };
2368 if !is_snapshot_version(version) {
2369 continue;
2370 }
2371 if nested_entries == MAX_LEGACY_DIRECTORY_ENTRIES {
2372 return Err(loader_error(
2373 "legacy snapshot tree exceeds the entry ceiling",
2374 ));
2375 }
2376 nested_entries += 1;
2377 let snapshot = snapshots.open_child(entry)?;
2378 let snapshot_before = snapshot.revision()?;
2379 let manifest_entry = snapshot
2380 .inspect_relative(Path::new(SNAPSHOT_MANIFEST), None)?
2381 .ok_or_else(|| loader_error("legacy snapshot manifest is absent"))?;
2382 require_regular(&manifest_entry)?;
2383 let bytes = read_bounded(
2384 &snapshot,
2385 &manifest_entry,
2386 MAX_LEGACY_ARTIFACT_BYTES,
2387 &mut aggregate,
2388 )?;
2389 scanned_snapshot_manifests.push((
2390 PathBuf::from(version).join(SNAPSHOT_MANIFEST),
2391 manifest_entry.clone(),
2392 hex_digest(Sha256::digest(&bytes)),
2393 ));
2394 let manifest =
2395 parse_snapshot_manifest(&bytes, &snapshot.display_path.join(SNAPSHOT_MANIFEST))?;
2396 if manifest.version != version {
2397 return Err(loader_error(
2398 "legacy snapshot records a different version identity",
2399 ));
2400 }
2401 snapshot.require_revision(&snapshot_before)?;
2402 let candidate_key = (
2403 manifest.source_migration.clone(),
2404 manifest.schema_hash.clone(),
2405 );
2406 let Some(expected_hashes) = expected_hashes_by_source.get(&manifest.source_migration)
2407 else {
2408 continue;
2409 };
2410 if !expected_hashes.contains(&manifest.schema_hash) {
2411 return Err(loader_error(format!(
2412 "legacy snapshot source {} has a non-equivalent schema hash claim",
2413 manifest.source_migration
2414 )));
2415 }
2416
2417 let remaining_entries = MAX_LEGACY_DIRECTORY_ENTRIES.saturating_sub(nested_entries);
2418 let capture = snapshot.capture_with_limit(remaining_entries)?;
2419 nested_entries = nested_entries.saturating_add(capture.entries.len());
2420 let captured_manifest_entry = capture
2421 .entries
2422 .iter()
2423 .find(|candidate| candidate.name == OsStr::new(SNAPSHOT_MANIFEST))
2424 .ok_or_else(|| loader_error("legacy snapshot manifest is absent"))?;
2425 require_regular(captured_manifest_entry)?;
2426 let mut revalidation_bytes = 0usize;
2427 let captured_bytes = read_bounded(
2428 &snapshot,
2429 captured_manifest_entry,
2430 MAX_LEGACY_ARTIFACT_BYTES,
2431 &mut revalidation_bytes,
2432 )?;
2433 if captured_bytes != bytes {
2434 return Err(loader_error(
2435 "legacy snapshot manifest changed before authoritative verification",
2436 ));
2437 }
2438 let captured_manifest = parse_snapshot_manifest(
2439 &captured_bytes,
2440 &snapshot.display_path.join(SNAPSHOT_MANIFEST),
2441 )?;
2442 let verified = verify_snapshot(&snapshot, &capture, &captured_manifest, &mut aggregate)?;
2443 match available.get(&candidate_key) {
2444 Some(existing) if existing.schema_typeql != verified.schema_typeql => {
2445 return Err(loader_error(format!(
2446 "legacy snapshot source {} has non-equivalent candidates for one schema hash",
2447 captured_manifest.source_migration
2448 )));
2449 }
2450 Some(_) => {}
2451 None => {
2452 available.insert(candidate_key, verified);
2453 }
2454 }
2455 snapshot.require_capture(&capture)?;
2456 verified_snapshot_captures.push((snapshot, capture));
2457 }
2458 #[cfg(test)]
2459 TEST_AFTER_SNAPSHOT_SCAN.with(|hook| {
2460 if let Some(hook) = hook.borrow_mut().take() {
2461 hook();
2462 }
2463 });
2464 require_scanned_snapshot_manifests(snapshots, &scanned_snapshot_manifests)?;
2465 history.require_retained_membership()?;
2466
2467 for (_, source, schema_hash) in &needed_authorities {
2468 if !available.contains_key(&(source.clone(), schema_hash.clone())) {
2469 return Err(loader_error(format!(
2470 "legacy snapshot authority {source} has no immutable snapshot with its trusted schema hash"
2471 )));
2472 }
2473 }
2474
2475 let mut verified_by_authority = BTreeMap::<(String, String, String), VerifiedLegacyHead>::new();
2476 let mut converged: Option<(String, VerifiedLegacyHead)> = None;
2477 for head in heads {
2478 let authority = history
2479 .snapshot_authorities
2480 .get(&(head.app_label.clone(), head.name.clone()))
2481 .ok_or_else(|| {
2482 loader_error(format!(
2483 "legacy graph head {} has no resolved snapshot authority",
2484 head.name
2485 ))
2486 })?;
2487 let authority_key = (
2488 authority.source.app_label.clone(),
2489 authority.source.migration_name.clone(),
2490 authority.schema_hash.clone(),
2491 );
2492 let verified = if let Some(verified) = verified_by_authority.get(&authority_key) {
2493 verified.clone()
2494 } else {
2495 let verified = available
2496 .get(&(
2497 authority.source.migration_name.clone(),
2498 authority.schema_hash.clone(),
2499 ))
2500 .ok_or_else(|| {
2501 loader_error(format!(
2502 "legacy graph head {} resolves to snapshot source {} but no immutable snapshot with its trusted schema hash exists",
2503 head.name, authority.source.migration_name
2504 ))
2505 })?
2506 .clone();
2507 verified_by_authority.insert(authority_key, verified.clone());
2508 verified
2509 };
2510
2511 match &converged {
2512 Some((schema_hash, existing))
2513 if schema_hash != &authority.schema_hash
2514 || existing.schema_typeql != verified.schema_typeql =>
2515 {
2516 return Err(loader_error(
2517 "legacy graph heads resolve to divergent authoritative snapshots",
2518 ));
2519 }
2520 Some(_) => {}
2521 None => converged = Some((authority.schema_hash.clone(), verified)),
2522 }
2523 }
2524
2525 for (snapshot, capture) in &verified_snapshot_captures {
2526 snapshot.require_capture(capture)?;
2527 }
2528 require_scanned_snapshot_manifests(snapshots, &scanned_snapshot_manifests)?;
2529 history.require_retained_membership()?;
2530 converged
2531 .map(|(_, verified)| verified)
2532 .ok_or_else(|| loader_error("legacy adoption history has no verified graph head"))
2533}
2534
2535fn require_scanned_snapshot_manifests(
2536 snapshots: &LegacyDirectoryAuthority,
2537 manifests: &[(PathBuf, LegacyDirectoryEntry, String)],
2538) -> Result<()> {
2539 let mut aggregate = 0usize;
2540 for (relative, entry, expected_digest) in manifests {
2541 let bytes = snapshots
2542 .read_relative_bounded(relative, MAX_LEGACY_ARTIFACT_BYTES, Some(entry))
2543 .map_err(|_| {
2544 loader_error("scanned legacy snapshot manifest changed after bounded scan")
2545 })?;
2546 aggregate = aggregate.saturating_add(bytes.len());
2547 if aggregate > MAX_LEGACY_HISTORY_BYTES
2548 || hex_digest(Sha256::digest(&bytes)) != *expected_digest
2549 {
2550 return Err(loader_error(
2551 "scanned legacy snapshot manifest changed after bounded scan",
2552 ));
2553 }
2554 }
2555 Ok(())
2556}
2557
2558fn verify_snapshot(
2559 directory: &LegacyDirectoryAuthority,
2560 capture: &LegacyDirectoryCapture,
2561 manifest: &SnapshotManifest,
2562 aggregate: &mut usize,
2563) -> Result<VerifiedLegacyHead> {
2564 if !is_lower_hex(&manifest.schema_hash, 64) {
2565 return Err(loader_error("legacy snapshot schema hash is malformed"));
2566 }
2567 if manifest.file_hashes.len() > MAX_LEGACY_DIRECTORY_ENTRIES {
2568 return Err(loader_error(
2569 "legacy snapshot file manifest exceeds the entry ceiling",
2570 ));
2571 }
2572 let expected_files = manifest
2573 .file_hashes
2574 .keys()
2575 .cloned()
2576 .collect::<BTreeSet<_>>();
2577 if !expected_files.contains(SNAPSHOT_SCHEMA) {
2578 return Err(loader_error(
2579 "legacy snapshot manifest does not bind schema.tql",
2580 ));
2581 }
2582 for (name, hash) in &manifest.file_hashes {
2583 if Path::new(name).components().count() != 1 || !is_lower_hex(hash, 64) {
2584 return Err(loader_error(
2585 "legacy snapshot manifest contains an invalid file identity",
2586 ));
2587 }
2588 }
2589
2590 let mut observed = BTreeSet::new();
2591 let mut schema = None;
2592 for entry in &capture.entries {
2593 if entry.name == OsStr::new(SNAPSHOT_MANIFEST) {
2594 require_regular(entry)?;
2595 continue;
2596 }
2597 let Some(name) = entry.name.to_str() else {
2602 continue;
2603 };
2604 if !expected_files.contains(name) {
2605 continue;
2606 }
2607 require_regular(entry)?;
2608 let bytes = read_bounded(directory, entry, MAX_LEGACY_ARTIFACT_BYTES, aggregate)?;
2609 let actual = hex_digest(Sha256::digest(&bytes));
2610 if manifest.file_hashes.get(name) != Some(&actual) {
2611 return Err(loader_error(format!(
2612 "legacy snapshot file hash mismatch for {name}"
2613 )));
2614 }
2615 observed.insert(name.to_owned());
2616 if name == SNAPSHOT_SCHEMA {
2617 schema = Some(
2618 String::from_utf8(bytes)
2619 .map_err(|_| loader_error("legacy snapshot schema.tql is not valid UTF-8"))?,
2620 );
2621 }
2622 }
2623 if observed != expected_files {
2624 return Err(loader_error(
2625 "legacy snapshot is missing a file bound by snapshot.json",
2626 ));
2627 }
2628 let schema_typeql =
2629 schema.ok_or_else(|| loader_error("legacy snapshot schema.tql is absent"))?;
2630 if hex_digest(Sha256::digest(schema_typeql.as_bytes())) != manifest.schema_hash {
2631 return Err(loader_error(
2632 "legacy snapshot schema hash disagrees with schema.tql",
2633 ));
2634 }
2635 directory.require_capture(capture)?;
2636 Ok(VerifiedLegacyHead {
2637 source_migration: manifest.source_migration.clone(),
2638 schema_typeql,
2639 })
2640}
2641
2642fn read_bounded(
2643 directory: &LegacyDirectoryAuthority,
2644 entry: &LegacyDirectoryEntry,
2645 limit: usize,
2646 aggregate: &mut usize,
2647) -> Result<Vec<u8>> {
2648 let mut file = crate::loader::open_regular_readonly_nofollow(&directory.directory, &entry.name)
2649 .map_err(|_| loader_error("legacy artifact cannot be opened through retained authority"))?;
2650 let before = LegacyMetadataRevision::capture(
2651 &file
2652 .metadata()
2653 .map_err(|_| loader_error("legacy artifact metadata cannot be inspected"))?,
2654 )?;
2655 if before != entry.revision {
2656 return Err(loader_error(
2657 "legacy artifact changed after bounded directory enumeration",
2658 ));
2659 }
2660 let mut bytes = Vec::new();
2661 (&mut file)
2662 .take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
2663 .read_to_end(&mut bytes)
2664 .map_err(|_| loader_error("legacy artifact failed during bounded read"))?;
2665 let after = LegacyMetadataRevision::capture(
2666 &file
2667 .metadata()
2668 .map_err(|_| loader_error("legacy artifact metadata cannot be re-inspected"))?,
2669 )?;
2670 if before != after {
2671 return Err(loader_error("legacy artifact changed during bounded read"));
2672 }
2673 if bytes.len() > limit {
2674 return Err(loader_error("legacy artifact exceeds the byte ceiling"));
2675 }
2676 *aggregate = aggregate.saturating_add(bytes.len());
2677 if *aggregate > MAX_LEGACY_HISTORY_BYTES {
2678 return Err(loader_error(
2679 "legacy history exceeds the aggregate byte ceiling",
2680 ));
2681 }
2682 Ok(bytes)
2683}
2684
2685fn require_regular(entry: &LegacyDirectoryEntry) -> Result<()> {
2686 if entry.revision.kind != LegacyEntryKind::File {
2687 return Err(loader_error(
2688 "legacy authority must be a regular file, not a link or special entry",
2689 ));
2690 }
2691 Ok(())
2692}
2693
2694fn is_migration_stem(stem: &str) -> bool {
2695 let bytes = stem.as_bytes();
2696 bytes.len() >= 5 && bytes[..4].iter().all(u8::is_ascii_digit) && bytes[4] == b'_'
2697}
2698
2699fn looks_migration_shaped_bytes(name: &OsStr) -> bool {
2700 #[cfg(unix)]
2701 {
2702 use std::os::unix::ffi::OsStrExt as _;
2703 let bytes = name.as_bytes();
2704 bytes.len() >= 8
2705 && bytes[..4].iter().all(u8::is_ascii_digit)
2706 && bytes[4] == b'_'
2707 && (bytes.ends_with(b".py")
2708 || bytes.ends_with(b".json")
2709 || bytes.ends_with(ADOPTION_SUFFIX.as_bytes()))
2710 }
2711 #[cfg(not(unix))]
2712 {
2713 let _ = name;
2714 false
2715 }
2716}
2717
2718fn is_recognized_legacy_root_name(name: &OsStr) -> bool {
2719 if name == OsStr::new(CONVERSION_JOURNAL) || name == OsStr::new("snapshots") {
2720 return true;
2721 }
2722 let Some(name) = name.to_str() else {
2723 return looks_migration_shaped_bytes(name);
2724 };
2725 if let Some(stem) = name.strip_suffix(ADOPTION_SUFFIX) {
2726 return is_migration_stem(stem);
2727 }
2728 let Some((stem, extension)) = name.rsplit_once('.') else {
2729 return false;
2730 };
2731 matches!(extension, "py" | "json") && is_migration_stem(stem)
2732}
2733
2734fn is_current_platform_direct_child(name: &OsStr) -> bool {
2735 let path = Path::new(name);
2736 let mut components = path.components();
2737 if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() {
2738 return false;
2739 }
2740 #[cfg(not(windows))]
2741 {
2742 !name.is_empty()
2743 }
2744 #[cfg(windows)]
2745 {
2746 let Some(portable) = name.to_str() else {
2747 return false;
2748 };
2749 let trimmed = portable.trim_end_matches(['.', ' ']);
2750 let windows_stem = portable
2751 .split('.')
2752 .next()
2753 .unwrap_or(portable)
2754 .trim_end_matches(['.', ' '])
2755 .to_ascii_uppercase();
2756 let windows_device = matches!(
2757 windows_stem.as_str(),
2758 "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$"
2759 ) || ["COM", "LPT"].iter().any(|prefix| {
2760 windows_stem.strip_prefix(prefix).is_some_and(|suffix| {
2761 matches!(
2762 suffix,
2763 "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
2764 )
2765 })
2766 });
2767 !portable.is_empty()
2768 && !portable.contains(['<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0'])
2769 && !portable.chars().any(char::is_control)
2770 && trimmed == portable
2771 && !windows_device
2772 }
2773}
2774
2775fn is_snapshot_version(value: &str) -> bool {
2776 value.len() == 5
2777 && value.starts_with('v')
2778 && value.as_bytes()[1..].iter().all(u8::is_ascii_digit)
2779}
2780
2781fn is_lower_hex(value: &str, length: usize) -> bool {
2782 value.len() == length
2783 && value
2784 .bytes()
2785 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2786}
2787
2788fn hash_field(hasher: &mut Sha256, value: &[u8]) {
2789 hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
2790 hasher.update(value);
2791}
2792
2793fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
2794 bytes
2795 .as_ref()
2796 .iter()
2797 .map(|byte| format!("{byte:02x}"))
2798 .collect()
2799}
2800
2801fn loader_error(message: impl Into<String>) -> MigrationError {
2802 MigrationError::Loader {
2803 message: message.into(),
2804 }
2805}
2806
2807#[cfg(test)]
2808mod tests {
2809 use super::*;
2810
2811 fn dependency(name: &str) -> MigrationDependencySpec {
2812 MigrationDependencySpec {
2813 app_label: "example".to_owned(),
2814 migration_name: name.to_owned(),
2815 }
2816 }
2817
2818 fn archive(
2819 name: &str,
2820 dependencies: Vec<MigrationDependencySpec>,
2821 source_text: &str,
2822 effect: LegacySchemaEffect,
2823 schema_source: &str,
2824 schema_hash: &str,
2825 ) -> LegacyAdoptionMetadata {
2826 LegacyAdoptionMetadata::new(
2827 "example",
2828 name,
2829 dependencies,
2830 migration_file_checksum(source_text),
2831 hex_digest(Sha256::digest(source_text.as_bytes())),
2832 effect,
2833 dependency(schema_source),
2834 schema_hash,
2835 )
2836 .expect("valid archive")
2837 }
2838
2839 fn write_archive(directory: &Path, name: &str, source: &str, archive: &LegacyAdoptionMetadata) {
2840 let mut archive = archive.clone();
2841 let directory_app_label = directory
2842 .file_name()
2843 .and_then(OsStr::to_str)
2844 .expect("test directory app label");
2845 if archive.app_label == "example" && directory_app_label != "example" {
2846 archive.app_label = directory_app_label.to_owned();
2847 for dependency in &mut archive.dependencies {
2848 if dependency.app_label == "example" {
2849 dependency.app_label = directory_app_label.to_owned();
2850 }
2851 }
2852 if archive.schema_source.app_label == "example" {
2853 archive.schema_source.app_label = directory_app_label.to_owned();
2854 }
2855 archive.metadata_digest = archive.expected_digest();
2856 }
2857 fs::write(directory.join(format!("{name}.py")), source).expect("Python source");
2858 fs::write(
2859 directory.join(format!("{name}{ADOPTION_SUFFIX}")),
2860 serde_json::to_vec(&archive).expect("archive JSON"),
2861 )
2862 .expect("archive writes");
2863 }
2864
2865 fn sidecar_spec(
2866 app_label: &str,
2867 name: &str,
2868 dependencies: Vec<MigrationDependencySpec>,
2869 source: &str,
2870 operations: Vec<OperationSpec>,
2871 ) -> MigrationSpec {
2872 MigrationSpec {
2873 app_label: app_label.to_owned(),
2874 name: name.to_owned(),
2875 dependencies,
2876 operations,
2877 checksum: Some(migration_file_checksum(source)),
2878 source_sha256: Some("0".repeat(64)),
2879 reversible: true,
2880 }
2881 }
2882
2883 fn sidecar_archive(
2884 source_name: &str,
2885 source: &str,
2886 sidecar: &MigrationSpec,
2887 sidecar_bytes: &[u8],
2888 effect: LegacySchemaEffect,
2889 schema_source: &str,
2890 schema_hash: &str,
2891 ) -> LegacySidecarAdoptionMetadata {
2892 let effective_app_label = if sidecar.app_label.is_empty() {
2893 "example"
2894 } else {
2895 &sidecar.app_label
2896 };
2897 let effective_name = if sidecar.name.is_empty() {
2898 source_name
2899 } else {
2900 &sidecar.name
2901 };
2902 LegacySidecarAdoptionMetadata::new(
2903 source_name,
2904 effective_app_label,
2905 effective_name,
2906 sidecar.dependencies.clone(),
2907 migration_file_checksum(source),
2908 sidecar.checksum.clone(),
2909 hex_digest(Sha256::digest(source.as_bytes())),
2910 hex_digest(Sha256::digest(sidecar_bytes)),
2911 effect,
2912 dependency(schema_source),
2913 schema_hash,
2914 )
2915 .expect("valid sidecar archive")
2916 }
2917
2918 fn write_sidecar_archive(
2919 directory: &Path,
2920 source_name: &str,
2921 source: &str,
2922 sidecar: &MigrationSpec,
2923 archive: &LegacySidecarAdoptionMetadata,
2924 ) {
2925 fs::write(directory.join(format!("{source_name}.py")), source)
2926 .expect("sidecar Python source");
2927 fs::write(
2928 directory.join(format!("{source_name}.json")),
2929 serde_json::to_vec(sidecar).expect("sidecar JSON"),
2930 )
2931 .expect("sidecar writes");
2932 fs::write(
2933 directory.join(format!("{source_name}{ADOPTION_SUFFIX}")),
2934 serde_json::to_vec(archive).expect("sidecar adoption JSON"),
2935 )
2936 .expect("sidecar archive writes");
2937 }
2938
2939 fn ignored(name: &str, source: &str) -> LegacyIgnoredSourceMetadata {
2940 LegacyIgnoredSourceMetadata::new(
2941 name,
2942 migration_file_checksum(source),
2943 hex_digest(Sha256::digest(source.as_bytes())),
2944 )
2945 .expect("valid ignored-source metadata")
2946 }
2947
2948 fn write_ignored(
2949 directory: &Path,
2950 name: &str,
2951 source: &str,
2952 metadata: &LegacyIgnoredSourceMetadata,
2953 ) {
2954 fs::write(directory.join(format!("{name}.py")), source).expect("ignored Python source");
2955 fs::write(
2956 directory.join(format!("{name}{ADOPTION_SUFFIX}")),
2957 serde_json::to_vec(metadata).expect("ignored-source metadata JSON"),
2958 )
2959 .expect("ignored-source metadata writes");
2960 }
2961
2962 fn write_snapshot(directory: &Path, version: &str, source: &str, schema: &str) {
2963 let snapshot = directory.join(format!("snapshots/{version}"));
2964 fs::create_dir_all(&snapshot).expect("snapshot directory");
2965 fs::write(snapshot.join(SNAPSHOT_SCHEMA), schema).expect("schema writes");
2966 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
2967 let manifest = serde_json::json!({
2968 "version": version,
2969 "source_migration": source,
2970 "schema_hash": schema_hash,
2971 "file_hashes": {"schema.tql": hex_digest(Sha256::digest(schema.as_bytes()))},
2972 "type_bridge_version": "1.5.11",
2973 "type_bridge_core_version": "1.5.11"
2974 });
2975 fs::write(
2976 snapshot.join(SNAPSHOT_MANIFEST),
2977 serde_json::to_vec(&manifest).expect("manifest JSON"),
2978 )
2979 .expect("manifest writes");
2980 }
2981
2982 fn write_snapshot_migration(
2983 directory: &Path,
2984 name: &str,
2985 dependencies: Vec<MigrationDependencySpec>,
2986 source: &str,
2987 version: &str,
2988 schema: &str,
2989 ) -> String {
2990 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
2991 let metadata = archive(
2992 name,
2993 dependencies,
2994 source,
2995 LegacySchemaEffect::Snapshot,
2996 name,
2997 &schema_hash,
2998 );
2999 write_archive(directory, name, source, &metadata);
3000 write_snapshot(directory, version, name, schema);
3001 schema_hash
3002 }
3003
3004 fn reset_captured_entry_count() {
3005 TEST_CAPTURED_DIRECTORY_ENTRIES.with(|count| count.set(0));
3006 }
3007
3008 fn captured_entry_count() -> usize {
3009 TEST_CAPTURED_DIRECTORY_ENTRIES.with(std::cell::Cell::get)
3010 }
3011
3012 #[test]
3013 fn archive_digest_binds_dependencies_effect_and_snapshot_source() {
3014 let cross_language = LegacyAdoptionMetadata::new(
3015 "example",
3016 "0001_initial",
3017 Vec::new(),
3018 "d3461e95d22dcdb8",
3019 hex_digest(Sha256::digest(b"class Migration: pass\n")),
3020 LegacySchemaEffect::Snapshot,
3021 dependency("0001_initial"),
3022 "0ec6fdcdeecccbcd6373795bb147f598b03b4f588ec28b9256b2b420e8dd36a9",
3023 )
3024 .expect("cross-language fixture");
3025 assert_eq!(
3026 cross_language.metadata_digest,
3027 "02b001d9756a151fec7e78f26cbc923ec563b7c0440b71b87a74dac099428431",
3028 );
3029
3030 let no_op = LegacyAdoptionMetadata::new(
3031 "example",
3032 "0002_empty",
3033 vec![dependency("0001_initial")],
3034 "0123456789abcdef",
3035 hex_digest(Sha256::digest(b"class Migration: pass\n")),
3036 LegacySchemaEffect::UnchangedNoop,
3037 dependency("0001_initial"),
3038 "a".repeat(64),
3039 )
3040 .expect("checksum-bound no-op archive");
3041 assert_eq!(
3042 no_op.metadata_digest,
3043 "5493f6efe6d50ba3f4072ad435e18fc7a702f20741d02dfdbcc6bee2c0415163",
3044 );
3045
3046 let source = "class Migration: pass\n";
3047 let archive = archive(
3048 "0002_backfill",
3049 vec![dependency("0001_initial")],
3050 source,
3051 LegacySchemaEffect::UnchangedRunPython,
3052 "0001_initial",
3053 &"a".repeat(64),
3054 );
3055 archive.verify().expect("digest verifies");
3056
3057 let original = archive.clone();
3058 let mut tampered = original.clone();
3059 tampered.dependencies[0].migration_name = "0009_forged".to_owned();
3060 assert!(tampered.verify().is_err());
3061
3062 let mut tampered = original.clone();
3063 tampered.schema_source.migration_name = "0009_forged".to_owned();
3064 assert!(tampered.verify().is_err());
3065
3066 let mut tampered = original.clone();
3067 tampered.schema_effect = LegacySchemaEffect::Snapshot;
3068 assert!(tampered.verify().is_err());
3069
3070 let mut tampered = original;
3071 let replacement = if tampered.metadata_digest.starts_with('0') {
3072 "1"
3073 } else {
3074 "0"
3075 };
3076 tampered.metadata_digest.replace_range(..1, replacement);
3077 assert!(tampered.verify().is_err());
3078 }
3079
3080 #[test]
3081 fn sidecar_digest_binds_both_files_and_effective_semantics() {
3082 let source = "raise RuntimeError('sidecar prevents import')\n";
3083 let sidecar = sidecar_spec(
3084 "legacy_app",
3085 "0001_effective",
3086 Vec::new(),
3087 source,
3088 Vec::new(),
3089 );
3090 let bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3091 let archive = LegacySidecarAdoptionMetadata::new(
3092 "0001_raw",
3093 "legacy_app",
3094 "0001_effective",
3095 Vec::new(),
3096 migration_file_checksum(source),
3097 sidecar.checksum.clone(),
3098 hex_digest(Sha256::digest(source.as_bytes())),
3099 hex_digest(Sha256::digest(&bytes)),
3100 LegacySchemaEffect::Snapshot,
3101 MigrationDependencySpec {
3102 app_label: "legacy_app".to_owned(),
3103 migration_name: "0001_effective".to_owned(),
3104 },
3105 "a".repeat(64),
3106 )
3107 .expect("sidecar metadata");
3108 archive.verify().expect("sidecar digest verifies");
3109
3110 let mut tampered = archive.clone();
3111 tampered.sidecar_sha256 = "b".repeat(64);
3112 assert!(tampered.verify().is_err());
3113
3114 let mut tampered = archive.clone();
3115 tampered.dependencies.push(MigrationDependencySpec {
3116 app_label: "legacy_app".to_owned(),
3117 migration_name: "0000_forged".to_owned(),
3118 });
3119 assert!(tampered.verify().is_err());
3120
3121 let mut tampered = archive;
3122 tampered.schema_effect = LegacySchemaEffect::UnchangedNoop;
3123 assert!(tampered.verify().is_err());
3124 }
3125
3126 #[test]
3127 fn sidecar_archive_digest_matches_the_python_converter_fixture() {
3128 let source = concat!(
3129 "from typing import ClassVar\n\n",
3130 "from type_bridge.migration import Migration\n",
3131 "from type_bridge.migration.operations import Operation\n",
3132 "from type_bridge.migration import operations as ops\n\n\n",
3133 "class LegacyMigration(Migration):\n",
3134 " dependencies: ClassVar[list[tuple[str, str]]] = []\n",
3135 " operations: ClassVar[list[Operation]] = [\n",
3136 " ops.RunTypeQL(\n",
3137 " forward=\"define attribute snapshot-name, value string;\",\n",
3138 " reverse=\"undefine attribute snapshot-name;\",\n",
3139 " ),\n",
3140 " ]\n",
3141 );
3142 let source_sha256 = hex_digest(Sha256::digest(source.as_bytes()));
3143 let sidecar = MigrationSpec {
3144 app_label: "migrations".to_owned(),
3145 name: "0001_initial".to_owned(),
3146 dependencies: Vec::new(),
3147 operations: vec![OperationSpec::RunTypeql {
3148 forward: "define attribute snapshot-name, value string;".to_owned(),
3149 reverse: Some("undefine attribute snapshot-name;".to_owned()),
3150 }],
3151 checksum: Some(migration_file_checksum(source)),
3152 source_sha256: Some(source_sha256.clone()),
3153 reversible: true,
3154 };
3155 let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar fixture JSON");
3156 let schema_hash = hex_digest(Sha256::digest(
3157 b"define\nattribute snapshot-name, value string;\n",
3158 ));
3159 let archive = LegacySidecarAdoptionMetadata::new(
3160 "0001_initial",
3161 "migrations",
3162 "0001_initial",
3163 Vec::new(),
3164 migration_file_checksum(source),
3165 sidecar.checksum.clone(),
3166 source_sha256,
3167 hex_digest(Sha256::digest(&sidecar_bytes)),
3168 LegacySchemaEffect::Snapshot,
3169 MigrationDependencySpec {
3170 app_label: "migrations".to_owned(),
3171 migration_name: "0001_initial".to_owned(),
3172 },
3173 schema_hash,
3174 )
3175 .expect("cross-language sidecar archive");
3176 assert_eq!(
3177 archive.metadata_digest,
3178 "268a1a09328bf87482f53818d93d1b613c415694138caccc7f8a5f6df1f0abe4"
3179 );
3180 }
3181
3182 #[test]
3183 fn sidecar_copy_attribute_history_loads_without_child_snapshot() {
3184 let temporary = tempfile::tempdir().expect("temporary root");
3185 let directory = temporary.path().join("example");
3186 fs::create_dir(&directory).expect("legacy directory");
3187 let schema = "define\nattribute parent-schema, value string;\n";
3188 let schema_hash = write_snapshot_migration(
3189 &directory,
3190 "0001_initial",
3191 Vec::new(),
3192 "class Migration: pass\n",
3193 "v0001",
3194 schema,
3195 );
3196 let source = "raise RuntimeError('released sidecar wins')\n";
3197 let sidecar = sidecar_spec(
3198 "example",
3199 "0002_backfill",
3200 vec![dependency("0001_initial")],
3201 source,
3202 vec![OperationSpec::CopyAttribute {
3203 owner: None,
3204 source: None,
3205 dest: None,
3206 filter: None,
3207 forward: Some("match $x isa person; insert $x has name 'x';".to_owned()),
3208 reverse: None,
3209 }],
3210 );
3211 let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3212 let archive = sidecar_archive(
3213 "0002_backfill",
3214 source,
3215 &sidecar,
3216 &sidecar_bytes,
3217 LegacySchemaEffect::UnchangedCopyAttribute,
3218 "0001_initial",
3219 &schema_hash,
3220 );
3221 write_sidecar_archive(&directory, "0002_backfill", source, &sidecar, &archive);
3222
3223 let history = load_adoption_history(&directory).expect("sidecar history loads");
3224 assert_eq!(history.graph().migrations.len(), 2);
3225 assert_eq!(history.graph().migrations[1].name, "0002_backfill");
3226 assert!(history.graph().migrations[1].operations.is_empty());
3227 assert_eq!(
3228 history.graph().migrations[1].checksum.as_deref(),
3229 Some(migration_file_checksum(source).as_str())
3230 );
3231 let head = reconstruct_legacy_head(&history).expect("parent snapshot reconstructs");
3232 assert_eq!(head.schema_typeql(), schema);
3233 }
3234
3235 #[test]
3236 fn optional_checksum_sidecar_preserves_divergent_effective_identity() {
3237 let temporary = tempfile::tempdir().expect("temporary root");
3238 let directory = temporary.path().join("migrations");
3239 fs::create_dir(&directory).expect("legacy directory");
3240 let source = "raise RuntimeError('released sidecar wins')\n";
3241 let mut sidecar = sidecar_spec(
3242 "legacy_app",
3243 "0001_effective",
3244 Vec::new(),
3245 source,
3246 Vec::new(),
3247 );
3248 sidecar.checksum = None;
3249 sidecar.source_sha256 = Some("0".repeat(64));
3251 let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3252 let schema = "define\nattribute effective-identity, value string;\n";
3253 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3254 let archive = LegacySidecarAdoptionMetadata::new(
3255 "0001_raw",
3256 "legacy_app",
3257 "0001_effective",
3258 Vec::new(),
3259 migration_file_checksum(source),
3260 None,
3261 hex_digest(Sha256::digest(source.as_bytes())),
3262 hex_digest(Sha256::digest(&sidecar_bytes)),
3263 LegacySchemaEffect::Snapshot,
3264 MigrationDependencySpec {
3265 app_label: "legacy_app".to_owned(),
3266 migration_name: "0001_effective".to_owned(),
3267 },
3268 &schema_hash,
3269 )
3270 .expect("sidecar archive");
3271 write_sidecar_archive(&directory, "0001_raw", source, &sidecar, &archive);
3272 write_snapshot(&directory, "v0001", "0001_effective", schema);
3273
3274 let history = load_adoption_history(&directory).expect("released sidecar loads");
3275 let migration = &history.graph().migrations[0];
3276 assert_eq!(migration.app_label, "legacy_app");
3277 assert_eq!(migration.name, "0001_effective");
3278 assert_eq!(
3279 migration.checksum.as_deref(),
3280 Some(migration_file_checksum(source).as_str())
3281 );
3282 }
3283
3284 #[test]
3285 fn mixed_copy_and_schema_sidecar_cannot_claim_copy_only_effect() {
3286 let temporary = tempfile::tempdir().expect("temporary root");
3287 let directory = temporary.path().join("example");
3288 fs::create_dir(&directory).expect("legacy directory");
3289 let schema = "define\nattribute parent-schema, value string;\n";
3290 let schema_hash = write_snapshot_migration(
3291 &directory,
3292 "0001_initial",
3293 Vec::new(),
3294 "class Migration: pass\n",
3295 "v0001",
3296 schema,
3297 );
3298 let source = "# released sidecar authority\n";
3299 let sidecar = sidecar_spec(
3300 "example",
3301 "0002_mixed",
3302 vec![dependency("0001_initial")],
3303 source,
3304 vec![
3305 OperationSpec::CopyAttribute {
3306 owner: None,
3307 source: None,
3308 dest: None,
3309 filter: None,
3310 forward: Some("match $x isa person; insert $x has name 'x';".to_owned()),
3311 reverse: None,
3312 },
3313 OperationSpec::RunTypeql {
3314 forward: "define attribute schema-change, value string;".to_owned(),
3315 reverse: None,
3316 },
3317 ],
3318 );
3319 let bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3320 let archive = sidecar_archive(
3321 "0002_mixed",
3322 source,
3323 &sidecar,
3324 &bytes,
3325 LegacySchemaEffect::UnchangedCopyAttribute,
3326 "0001_initial",
3327 &schema_hash,
3328 );
3329 write_sidecar_archive(&directory, "0002_mixed", source, &sidecar, &archive);
3330
3331 let error = load_adoption_history(&directory)
3332 .expect_err("mixed sidecar cannot inherit a snapshot")
3333 .to_string();
3334 assert!(error.contains("operations contradict"), "{error}");
3335 }
3336
3337 #[test]
3338 fn retained_sidecar_tamper_is_rejected_by_exact_digest() {
3339 let temporary = tempfile::tempdir().expect("temporary root");
3340 let directory = temporary.path().join("example");
3341 fs::create_dir(&directory).expect("legacy directory");
3342 let source = "raise RuntimeError('not imported')\n";
3343 let mut sidecar = sidecar_spec("example", "0001_initial", Vec::new(), source, Vec::new());
3344 let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3345 let schema = "define\nattribute exact-sidecar, value string;\n";
3346 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3347 let archive = sidecar_archive(
3348 "0001_initial",
3349 source,
3350 &sidecar,
3351 &sidecar_bytes,
3352 LegacySchemaEffect::Snapshot,
3353 "0001_initial",
3354 &schema_hash,
3355 );
3356 write_sidecar_archive(&directory, "0001_initial", source, &sidecar, &archive);
3357 write_snapshot(&directory, "v0001", "0001_initial", schema);
3358 sidecar.reversible = false;
3359 fs::write(
3360 directory.join("0001_initial.json"),
3361 serde_json::to_vec(&sidecar).expect("tampered sidecar JSON"),
3362 )
3363 .expect("sidecar tampers");
3364
3365 let error = load_adoption_history(&directory)
3366 .expect_err("exact sidecar tamper must reject")
3367 .to_string();
3368 assert!(error.contains("exact JSON digest differs"), "{error}");
3369 }
3370
3371 #[test]
3372 fn empty_sidecar_app_label_cannot_forge_fallback_owner() {
3373 let temporary = tempfile::tempdir().expect("temporary root");
3374 let directory = temporary.path().join("migrations");
3375 fs::create_dir(&directory).expect("legacy directory");
3376 let source = "raise RuntimeError('not imported')\n";
3377 let sidecar = sidecar_spec("", "0001_initial", Vec::new(), source, Vec::new());
3378 let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3379 let schema = "define\nattribute owner-check, value string;\n";
3380 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3381 let archive = LegacySidecarAdoptionMetadata::new(
3382 "0001_initial",
3383 "forged_owner",
3384 "0001_initial",
3385 Vec::new(),
3386 migration_file_checksum(source),
3387 sidecar.checksum.clone(),
3388 hex_digest(Sha256::digest(source.as_bytes())),
3389 hex_digest(Sha256::digest(&sidecar_bytes)),
3390 LegacySchemaEffect::Snapshot,
3391 MigrationDependencySpec {
3392 app_label: "forged_owner".to_owned(),
3393 migration_name: "0001_initial".to_owned(),
3394 },
3395 &schema_hash,
3396 )
3397 .expect("forged archive is internally self-consistent");
3398 write_sidecar_archive(&directory, "0001_initial", source, &sidecar, &archive);
3399 write_snapshot(&directory, "v0001", "0001_initial", schema);
3400
3401 let error = load_adoption_history(&directory)
3402 .expect_err("directory fallback must reject forged owner")
3403 .to_string();
3404 assert!(
3405 error.contains("semantics differ from their adoption metadata"),
3406 "{error}"
3407 );
3408 }
3409
3410 #[test]
3411 fn empty_sidecar_identity_uses_directory_and_source_fallbacks() {
3412 let temporary = tempfile::tempdir().expect("temporary root");
3413 let directory = temporary.path().join("migrations");
3414 fs::create_dir(&directory).expect("legacy directory");
3415 let source = "raise RuntimeError('not imported')\n";
3416 let sidecar = sidecar_spec("", "", Vec::new(), source, Vec::new());
3417 let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
3418 let schema = "define\nattribute fallback-owner, value string;\n";
3419 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3420 let archive = LegacySidecarAdoptionMetadata::new(
3421 "0001_fallback",
3422 "migrations",
3423 "0001_fallback",
3424 Vec::new(),
3425 migration_file_checksum(source),
3426 sidecar.checksum.clone(),
3427 hex_digest(Sha256::digest(source.as_bytes())),
3428 hex_digest(Sha256::digest(&sidecar_bytes)),
3429 LegacySchemaEffect::Snapshot,
3430 MigrationDependencySpec {
3431 app_label: "migrations".to_owned(),
3432 migration_name: "0001_fallback".to_owned(),
3433 },
3434 &schema_hash,
3435 )
3436 .expect("fallback archive");
3437 write_sidecar_archive(&directory, "0001_fallback", source, &sidecar, &archive);
3438 write_snapshot(&directory, "v0001", "0001_fallback", schema);
3439
3440 let history = load_adoption_history(&directory).expect("fallback sidecar loads");
3441 assert_eq!(history.graph().migrations[0].app_label, "migrations");
3442 assert_eq!(history.graph().migrations[0].name, "0001_fallback");
3443 assert_eq!(
3444 reconstruct_legacy_head(&history)
3445 .expect("fallback head reconstructs")
3446 .schema_typeql(),
3447 schema
3448 );
3449 }
3450
3451 #[test]
3452 fn source_archive_rejects_a_later_execution_sidecar() {
3453 let temporary = tempfile::tempdir().expect("temporary root");
3454 let directory = temporary.path().join("example");
3455 fs::create_dir(&directory).expect("legacy directory");
3456 let source = "class Migration: pass\n";
3457 write_snapshot_migration(
3458 &directory,
3459 "0001_initial",
3460 Vec::new(),
3461 source,
3462 "v0001",
3463 "define\nattribute source-authority, value string;\n",
3464 );
3465 let sidecar = sidecar_spec("example", "0001_initial", Vec::new(), source, Vec::new());
3466 fs::write(
3467 directory.join("0001_initial.json"),
3468 serde_json::to_vec(&sidecar).expect("sidecar JSON"),
3469 )
3470 .expect("late sidecar writes");
3471
3472 let error = load_adoption_history(&directory)
3473 .expect_err("released sidecar precedence must invalidate a source archive")
3474 .to_string();
3475 assert!(
3476 error.contains("source-authoritative adoption metadata"),
3477 "{error}"
3478 );
3479 assert!(
3480 error.contains("conflicts with a retained JSON sidecar"),
3481 "{error}"
3482 );
3483 }
3484
3485 #[test]
3486 fn ignored_archive_rejects_a_later_execution_sidecar() {
3487 let temporary = tempfile::tempdir().expect("temporary root");
3488 let directory = temporary.path().join("example");
3489 fs::create_dir(&directory).expect("legacy directory");
3490 let source = "# no public Migration subclass\n";
3491 write_ignored(
3492 &directory,
3493 "0001_notes",
3494 source,
3495 &ignored("0001_notes", source),
3496 );
3497 let sidecar = sidecar_spec("example", "0001_notes", Vec::new(), source, Vec::new());
3498 fs::write(
3499 directory.join("0001_notes.json"),
3500 serde_json::to_vec(&sidecar).expect("sidecar JSON"),
3501 )
3502 .expect("late sidecar writes");
3503
3504 let error = load_adoption_history(&directory)
3505 .expect_err("released sidecar precedence must invalidate ignored evidence")
3506 .to_string();
3507 assert!(
3508 error.contains("ignored-source adoption metadata"),
3509 "{error}"
3510 );
3511 assert!(
3512 error.contains("conflicts with a retained JSON sidecar"),
3513 "{error}"
3514 );
3515 }
3516
3517 #[test]
3518 fn source_archive_owner_must_match_the_migration_directory() {
3519 let temporary = tempfile::tempdir().expect("temporary root");
3520 let directory = temporary.path().join("migrations");
3521 fs::create_dir(&directory).expect("legacy directory");
3522 let source = "class Migration: pass\n";
3523 let schema = "define\nattribute source-owner, value string;\n";
3524 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3525 let archive = LegacyAdoptionMetadata::new(
3526 "forged_owner",
3527 "0001_initial",
3528 Vec::new(),
3529 migration_file_checksum(source),
3530 hex_digest(Sha256::digest(source.as_bytes())),
3531 LegacySchemaEffect::Snapshot,
3532 MigrationDependencySpec {
3533 app_label: "forged_owner".to_owned(),
3534 migration_name: "0001_initial".to_owned(),
3535 },
3536 &schema_hash,
3537 )
3538 .expect("internally bound forged archive");
3539 write_archive(&directory, "0001_initial", source, &archive);
3540 write_snapshot(&directory, "v0001", "0001_initial", schema);
3541
3542 let error = load_adoption_history(&directory)
3543 .expect_err("released source imports always use the directory app label")
3544 .to_string();
3545 assert!(
3546 error.contains("does not match migration directory app label"),
3547 "{error}"
3548 );
3549 }
3550
3551 #[test]
3552 fn orphan_execution_sidecars_remain_ignored_like_released_discovery() {
3553 let temporary = tempfile::tempdir().expect("temporary root");
3554 let directory = temporary.path().join("example");
3555 fs::create_dir(&directory).expect("legacy directory");
3556 let schema = "define\nattribute orphan-compatible, value string;\n";
3557 write_snapshot_migration(
3558 &directory,
3559 "0001_initial",
3560 Vec::new(),
3561 "class Migration: pass\n",
3562 "v0001",
3563 schema,
3564 );
3565 fs::write(
3566 directory.join("0009_deleted.json"),
3567 b"{ stale orphan sidecar",
3568 )
3569 .expect("orphan sidecar writes");
3570
3571 let history = load_adoption_history(&directory).expect("orphan sidecar is ignored");
3572 assert_eq!(history.graph().migrations.len(), 1);
3573 let head = reconstruct_legacy_head(&history).expect("snapshot reconstructs");
3574 assert_eq!(head.schema_typeql(), schema);
3575 }
3576
3577 #[test]
3578 fn snapshot_manifest_preserves_released_python_json_semantics() {
3579 let directory = tempfile::tempdir().expect("legacy directory");
3580 let schema = "define\nattribute python-json, value string;\n";
3581 write_snapshot_migration(
3582 directory.path(),
3583 "0001_initial",
3584 Vec::new(),
3585 "class Initial: pass\n",
3586 "v0001",
3587 schema,
3588 );
3589 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3590 let huge_integer = "9".repeat(400);
3591 let deep_ignored = format!("{}0{}", "[".repeat(512), "]".repeat(512));
3592 let manifest = format!(
3593 concat!(
3594 "{{\"version\":\"v0001\",",
3595 "\"source_migration\":false,",
3596 "\"source_migration\":\"0001_initial\",",
3597 "\"schema_hash\":\"{schema_hash}\",",
3598 "\"file_hashes\":{{\"schema.tql\":\"{forged}\",",
3599 "\"schema.tql\":\"{schema_hash}\"}},",
3600 "\"type_bridge_version\":NaN,",
3601 "\"type_bridge_core_version\":Infinity,",
3602 "\"ignored_negative\":-Infinity,",
3603 "\"ignored_huge\":{huge_integer},",
3604 "\"ignored_deep\":{deep_ignored},",
3605 "\"\\ud800leading-surrogate-key\":0,",
3606 "\"trailing-surrogate-key\\udc00\":0,",
3607 "\"ignored_nested\":{{\"\\ud800nested-surrogate-key\":0}},",
3608 "\"ignored_string\":\"NaN Infinity -Infinity\"}}"
3609 ),
3610 forged = "0".repeat(64),
3611 schema_hash = schema_hash,
3612 huge_integer = huge_integer,
3613 deep_ignored = deep_ignored,
3614 );
3615 fs::write(
3616 directory.path().join("snapshots/v0001/snapshot.json"),
3617 manifest,
3618 )
3619 .expect("Python-compatible manifest writes");
3620
3621 let history = load_adoption_history(directory.path()).expect(
3622 "Python json constants, big integers, deep ignored members, and duplicate keys remain accepted",
3623 );
3624 let head = reconstruct_legacy_head(&history).expect("last duplicate values authorize");
3625 assert_eq!(head.source_migration(), "0001_initial");
3626 assert_eq!(head.schema_typeql(), schema);
3627 }
3628
3629 #[test]
3630 fn snapshot_manifest_root_key_normalization_is_narrow() {
3631 let mut manifest = br#"{
3632 "\ud800leading": 0,
3633 "trailing\udc00": 0,
3634 "paired\ud83d\ude00": 0,
3635 "escaped-backslash\\ud800": 0,
3636 "nested": {"\ud800nested": 0}
3637 }"#
3638 .to_vec();
3639 sanitize_python_json_root_key_surrogates(&mut manifest);
3640
3641 assert_eq!(
3642 manifest,
3643 br#"{
3644 "\ufffdleading": 0,
3645 "trailing\ufffd": 0,
3646 "paired\ud83d\ude00": 0,
3647 "escaped-backslash\\ud800": 0,
3648 "nested": {"\ud800nested": 0}
3649 }"#
3650 );
3651 }
3652
3653 #[test]
3654 fn snapshot_manifest_rejects_lone_surrogates_in_authoritative_values() {
3655 let schema_hash = "0".repeat(64);
3656 let valid_fields = [
3657 ("version", "\"v0001\""),
3658 ("source_migration", "\"0001_initial\""),
3659 ("schema_hash", &format!("\"{schema_hash}\"")),
3660 ];
3661 for (malformed_field, malformed_value) in [
3662 ("version", "\"\\ud800v0001\""),
3663 ("source_migration", "\"0001_initial\\udc00\""),
3664 ("schema_hash", "\"\\ud800\""),
3665 ] {
3666 let fields = valid_fields
3667 .iter()
3668 .map(|(name, value)| {
3669 let value = if *name == malformed_field {
3670 malformed_value
3671 } else {
3672 value
3673 };
3674 format!("\"{name}\":{value}")
3675 })
3676 .chain(std::iter::once("\"file_hashes\":{}".to_owned()))
3677 .collect::<Vec<_>>()
3678 .join(",");
3679 let error = parse_snapshot_manifest(
3680 format!("{{{fields}}}").as_bytes(),
3681 Path::new("snapshot.json"),
3682 )
3683 .err()
3684 .expect("lone surrogate in an authoritative string remains invalid")
3685 .to_string();
3686 assert!(error.contains(malformed_field), "{error}");
3687 }
3688 }
3689
3690 #[test]
3691 fn snapshot_manifest_version_metadata_remains_optional() {
3692 let directory = tempfile::tempdir().expect("legacy directory");
3693 let schema = "define\nattribute optional-version, value string;\n";
3694 write_snapshot_migration(
3695 directory.path(),
3696 "0001_initial",
3697 Vec::new(),
3698 "class Initial: pass\n",
3699 "v0001",
3700 schema,
3701 );
3702 let manifest_path = directory.path().join("snapshots/v0001/snapshot.json");
3703 let mut manifest: serde_json::Value =
3704 serde_json::from_slice(&fs::read(&manifest_path).expect("manifest reads"))
3705 .expect("manifest parses");
3706 let object = manifest.as_object_mut().expect("manifest object");
3707 object.remove("type_bridge_version");
3708 object.remove("type_bridge_core_version");
3709 fs::write(
3710 &manifest_path,
3711 serde_json::to_vec(&manifest).expect("manifest serializes"),
3712 )
3713 .expect("manifest without informational versions writes");
3714
3715 let history = load_adoption_history(directory.path())
3716 .expect("released snapshots did not require informational versions");
3717 assert_eq!(
3718 reconstruct_legacy_head(&history)
3719 .expect("snapshot reconstructs")
3720 .schema_typeql(),
3721 schema
3722 );
3723 }
3724
3725 #[test]
3726 fn ignored_source_digest_binds_name_and_python_checksum() {
3727 let source = "\"\"\"Historical migration notes.\"\"\"\n";
3728 let metadata = ignored("0000_notes", source);
3729 metadata.verify().expect("ignored-source digest verifies");
3730 assert_eq!(metadata.checksum, "e14f63ac2d07fa3b");
3731 assert_eq!(
3732 metadata.metadata_digest,
3733 "0c03755f3652a25c5d52bff5367ff8fa70edc23cb5f25c76c953dbd4993b60f2",
3734 );
3735
3736 let original = metadata.clone();
3737 let mut tampered = original.clone();
3738 tampered.name = "0009_forged".to_owned();
3739 assert!(tampered.verify().is_err());
3740
3741 let mut tampered = original.clone();
3742 tampered.checksum = "0123456789abcdef".to_owned();
3743 assert!(tampered.verify().is_err());
3744
3745 let mut tampered = original;
3746 let replacement = if tampered.metadata_digest.starts_with('0') {
3747 "1"
3748 } else {
3749 "0"
3750 };
3751 tampered.metadata_digest.replace_range(..1, replacement);
3752 assert!(tampered.verify().is_err());
3753 }
3754
3755 #[test]
3756 fn ignored_sources_are_verified_and_excluded_from_the_legacy_graph() {
3757 let directory = tempfile::tempdir().expect("legacy directory");
3758 let schema = "define\nattribute name, value string;\n";
3759 write_snapshot_migration(
3760 directory.path(),
3761 "0001_initial",
3762 Vec::new(),
3763 "class Initial: pass\n",
3764 "v0001",
3765 schema,
3766 );
3767 let notes = "\"\"\"Notes retained beside migration history.\"\"\"\n";
3768 write_ignored(
3769 directory.path(),
3770 "0000_notes",
3771 notes,
3772 &ignored("0000_notes", notes),
3773 );
3774 let disabled = "class _DisabledMigration: pass\n";
3775 write_ignored(
3776 directory.path(),
3777 "0002_disabled",
3778 disabled,
3779 &ignored("0002_disabled", disabled),
3780 );
3781
3782 let history = load_adoption_history(directory.path()).expect("ignored sources verify");
3783
3784 assert_eq!(history.graph().migrations.len(), 1);
3785 assert_eq!(history.graph().migrations[0].name, "0001_initial");
3786 let head = reconstruct_legacy_head(&history).expect("normal head reconstructs");
3787 assert_eq!(head.schema_typeql(), schema);
3788 }
3789
3790 #[test]
3791 fn reconstruction_continues_the_history_wide_byte_budget() {
3792 let directory = tempfile::tempdir().expect("legacy directory");
3793 let schema = "define\nattribute budgeted, value string;\n";
3794 write_snapshot_migration(
3795 directory.path(),
3796 "0001_initial",
3797 Vec::new(),
3798 "class Initial: pass\n",
3799 "v0001",
3800 schema,
3801 );
3802 let mut history = load_adoption_history(directory.path()).expect("history loads");
3803 history.consumed_bytes = MAX_LEGACY_HISTORY_BYTES;
3804
3805 let error = reconstruct_legacy_head(&history)
3806 .expect_err("snapshot reads must continue the source/archive budget");
3807
3808 assert!(error.to_string().contains("aggregate byte ceiling"));
3809 }
3810
3811 #[test]
3812 fn ignored_source_tampering_and_forged_duplicate_records_fail_closed() {
3813 let directory = tempfile::tempdir().expect("legacy directory");
3814 let notes = "# release notes\n";
3815 write_ignored(
3816 directory.path(),
3817 "0000_notes",
3818 notes,
3819 &ignored("0000_notes", notes),
3820 );
3821 fs::write(directory.path().join("0000_notes.py"), "# tampered notes\n")
3822 .expect("ignored source tampers");
3823 let error = load_adoption_history(directory.path())
3824 .expect_err("source drift cannot retain ignored classification");
3825 assert!(
3826 error
3827 .to_string()
3828 .contains("ignored-source adoption metadata drift")
3829 );
3830
3831 let duplicate_directory = tempfile::tempdir().expect("duplicate legacy directory");
3832 let duplicated = ignored("0000_notes", notes);
3833 write_ignored(duplicate_directory.path(), "0000_notes", notes, &duplicated);
3834 write_ignored(
3835 duplicate_directory.path(),
3836 "0003_forged",
3837 notes,
3838 &duplicated,
3839 );
3840 let error = load_adoption_history(duplicate_directory.path())
3841 .expect_err("one signed identity cannot be duplicated under a forged filename");
3842 assert!(error.to_string().contains("does not match filename stem"));
3843 }
3844
3845 #[test]
3846 fn duplicate_fields_in_ignored_source_metadata_are_rejected() {
3847 let directory = tempfile::tempdir().expect("legacy directory");
3848 let source = "# notes\n";
3849 let metadata = ignored("0000_notes", source);
3850 fs::write(directory.path().join("0000_notes.py"), source).expect("Python source");
3851 fs::write(
3852 directory.path().join("0000_notes.adoption.json"),
3853 format!(
3854 "{{\"format\":\"{}\",\"name\":\"0000_notes\",\"name\":\"0000_forged\",\"checksum\":\"{}\",\"metadata_digest\":\"{}\"}}",
3855 LEGACY_IGNORED_SOURCE_METADATA_V1, metadata.checksum, metadata.metadata_digest,
3856 ),
3857 )
3858 .expect("duplicate metadata writes");
3859
3860 let error = load_adoption_history(directory.path())
3861 .expect_err("duplicate identity fields must never be last-value-wins");
3862 assert!(error.to_string().contains("duplicate field"));
3863 }
3864
3865 #[test]
3866 fn ignored_source_metadata_uses_the_legacy_artifact_byte_ceiling() {
3867 let directory = tempfile::tempdir().expect("legacy directory");
3868 fs::write(directory.path().join("0000_notes.py"), "# notes\n")
3869 .expect("ignored Python source");
3870 fs::write(
3871 directory.path().join("0000_notes.adoption.json"),
3872 vec![b' '; MAX_LEGACY_ARTIFACT_BYTES + 1],
3873 )
3874 .expect("oversized ignored-source metadata writes");
3875
3876 let error = load_adoption_history(directory.path())
3877 .expect_err("ignored-source metadata must use the common bounded reader");
3878 assert!(error.to_string().contains("exceeds the byte ceiling"));
3879 }
3880
3881 #[test]
3882 fn root_noop_requires_an_explicit_snapshot_authority() {
3883 let no_snapshot = LegacyAdoptionMetadata::new(
3884 "example",
3885 "0001_empty",
3886 Vec::new(),
3887 "0123456789abcdef",
3888 hex_digest(Sha256::digest(b"class Migration: pass\n")),
3889 LegacySchemaEffect::UnchangedNoop,
3890 dependency("0001_empty"),
3891 "a".repeat(64),
3892 )
3893 .expect_err("an authority-less root no-op must fail closed");
3894 assert!(
3895 no_snapshot
3896 .to_string()
3897 .contains("snapshot-bound dependency")
3898 );
3899
3900 let directory = tempfile::tempdir().expect("legacy directory");
3901 write_snapshot_migration(
3902 directory.path(),
3903 "0001_baseline",
3904 Vec::new(),
3905 "class Migration:\n operations = []\n",
3906 "v0001",
3907 "define\n",
3908 );
3909 let history = load_adoption_history(directory.path()).expect("baseline history loads");
3910 let head = reconstruct_legacy_head(&history).expect("empty baseline snapshot reconstructs");
3911 assert_eq!(head.source_migration(), "0001_baseline");
3912 assert_eq!(head.schema_typeql(), "define\n");
3913 let authority = type_bridge_schema_compat::parse_adopted_genesis_authority(
3914 type_bridge_contract::schema::DocumentId::new("legacy-empty-baseline.typeql")
3915 .expect("document identity"),
3916 head.schema_typeql(),
3917 )
3918 .expect("the reconstructed empty baseline must pass the production adoption parser");
3919 assert_eq!(authority.declared().facts().len(), 0);
3920 }
3921
3922 #[test]
3923 fn run_python_head_inherits_and_reconstructs_its_parent_snapshot() {
3924 let directory = tempfile::tempdir().expect("legacy directory");
3925 let initial_source = "class Migration:\n operations = [RunTypeQL()]\n";
3926 let backfill_source = "class Migration:\n operations = [RunPython()]\n";
3927 let schema = "define\nattribute tag, value string;\nentity person, owns tag[] @distinct;\n";
3928 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
3929
3930 let initial = archive(
3931 "0001_initial",
3932 Vec::new(),
3933 initial_source,
3934 LegacySchemaEffect::Snapshot,
3935 "0001_initial",
3936 &schema_hash,
3937 );
3938 write_archive(directory.path(), "0001_initial", initial_source, &initial);
3939 let backfill = archive(
3940 "0002_backfill",
3941 vec![dependency("0001_initial")],
3942 backfill_source,
3943 LegacySchemaEffect::UnchangedRunPython,
3944 "0001_initial",
3945 &schema_hash,
3946 );
3947 write_archive(
3948 directory.path(),
3949 "0002_backfill",
3950 backfill_source,
3951 &backfill,
3952 );
3953 write_snapshot(directory.path(), "v0001", "0001_initial", schema);
3954
3955 let history = load_adoption_history(directory.path()).expect("archival history loads");
3956 let head = reconstruct_legacy_head(&history).expect("snapshot reconstructs head");
3957 assert_eq!(head.source_migration(), "0001_initial");
3958 assert_eq!(head.schema_typeql(), schema);
3959 assert!(history.graph().migrations[0].operations.is_empty());
3960 }
3961
3962 #[test]
3963 fn noop_head_inherits_and_reconstructs_its_parent_snapshot() {
3964 let directory = tempfile::tempdir().expect("legacy directory");
3965 let initial_source = "class Migration:\n operations = [RunTypeQL()]\n";
3966 let empty_source = "class Migration:\n operations = []\n";
3967 let schema = "define\nattribute tag, value string;\nentity person, owns tag[] @distinct;\n";
3968 let schema_hash = write_snapshot_migration(
3969 directory.path(),
3970 "0001_initial",
3971 Vec::new(),
3972 initial_source,
3973 "v0001",
3974 schema,
3975 );
3976 let empty = archive(
3977 "0002_empty",
3978 vec![dependency("0001_initial")],
3979 empty_source,
3980 LegacySchemaEffect::UnchangedNoop,
3981 "0001_initial",
3982 &schema_hash,
3983 );
3984 write_archive(directory.path(), "0002_empty", empty_source, &empty);
3985
3986 let history = load_adoption_history(directory.path()).expect("no-op history loads");
3987 let head = reconstruct_legacy_head(&history).expect("parent snapshot reconstructs head");
3988 assert_eq!(head.source_migration(), "0001_initial");
3989 assert_eq!(head.schema_typeql(), schema);
3990 assert_eq!(history.graph().migrations.len(), 2);
3991 assert!(history.graph().migrations[1].operations.is_empty());
3992 }
3993
3994 #[test]
3995 fn noop_merge_inherits_one_converged_parent_authority() {
3996 let directory = tempfile::tempdir().expect("legacy directory");
3997 let schema = "define\nattribute name, value string;\nentity person, owns name;\n";
3998 let schema_hash = write_snapshot_migration(
3999 directory.path(),
4000 "0001_initial",
4001 Vec::new(),
4002 "class Initial: pass\n",
4003 "v0001",
4004 schema,
4005 );
4006 for (name, dependencies) in [
4007 ("0002_left", vec![dependency("0001_initial")]),
4008 ("0003_right", vec![dependency("0001_initial")]),
4009 (
4010 "0004_merge",
4011 vec![dependency("0002_left"), dependency("0003_right")],
4012 ),
4013 ] {
4014 let source = format!("class {name}:\n operations = []\n");
4015 let metadata = archive(
4016 name,
4017 dependencies,
4018 &source,
4019 LegacySchemaEffect::UnchangedNoop,
4020 "0001_initial",
4021 &schema_hash,
4022 );
4023 write_archive(directory.path(), name, &source, &metadata);
4024 }
4025
4026 let history = load_adoption_history(directory.path()).expect("converged no-op merge loads");
4027 let heads = history.heads().expect("validated heads");
4028 assert_eq!(heads.len(), 1);
4029 assert_eq!(heads[0].name, "0004_merge");
4030 let reconstructed =
4031 reconstruct_legacy_head(&history).expect("one parent authority reconstructs");
4032 assert_eq!(reconstructed.schema_typeql(), schema);
4033 }
4034
4035 #[test]
4036 fn noop_merge_accepts_distinct_parent_owners_with_the_same_schema() {
4037 let directory = tempfile::tempdir().expect("legacy directory");
4038 let schema = "define\nattribute shared, value string;\n";
4039 let schema_hash = write_snapshot_migration(
4040 directory.path(),
4041 "0001_left",
4042 Vec::new(),
4043 "class Left: pass\n",
4044 "v0001",
4045 schema,
4046 );
4047 write_snapshot_migration(
4048 directory.path(),
4049 "0002_right",
4050 Vec::new(),
4051 "class Right: pass\n",
4052 "v0002",
4053 schema,
4054 );
4055 let merge_source = "class Merge:\n operations = []\n";
4056 let merge = archive(
4057 "0003_merge",
4058 vec![dependency("0001_left"), dependency("0002_right")],
4059 merge_source,
4060 LegacySchemaEffect::UnchangedNoop,
4061 "0001_left",
4062 &schema_hash,
4063 );
4064 write_archive(directory.path(), "0003_merge", merge_source, &merge);
4065
4066 let history = load_adoption_history(directory.path())
4067 .expect("equal schemas with distinct snapshot owners converge");
4068 let head = reconstruct_legacy_head(&history)
4069 .expect("all distinct snapshot owners verify before convergence");
4070 assert_eq!(head.source_migration(), "0001_left");
4071 assert_eq!(head.schema_typeql(), schema);
4072 }
4073
4074 #[test]
4075 fn convergent_multi_head_history_reconstructs_one_authoritative_schema() {
4076 let directory = tempfile::tempdir().expect("legacy directory");
4077 let schema = "define\nattribute name, value string;\nentity person, owns name;\n";
4078 write_snapshot_migration(
4079 directory.path(),
4080 "0001_initial",
4081 Vec::new(),
4082 "class Initial: pass\n",
4083 "v0001",
4084 schema,
4085 );
4086 write_snapshot_migration(
4087 directory.path(),
4088 "0002_left",
4089 vec![dependency("0001_initial")],
4090 "class Left: pass\n",
4091 "v0002",
4092 schema,
4093 );
4094 write_snapshot_migration(
4095 directory.path(),
4096 "0003_right",
4097 vec![dependency("0001_initial")],
4098 "class Right: pass\n",
4099 "v0003",
4100 schema,
4101 );
4102
4103 let history = load_adoption_history(directory.path()).expect("multi-head history loads");
4104 assert_eq!(history.heads().expect("validated heads").len(), 2);
4105 let reconstructed =
4106 reconstruct_legacy_head(&history).expect("equal head snapshots converge");
4107 assert_eq!(reconstructed.source_migration(), "0002_left");
4108 assert_eq!(reconstructed.schema_typeql(), schema);
4109 }
4110
4111 #[test]
4112 fn divergent_multi_head_history_fails_closed() {
4113 let directory = tempfile::tempdir().expect("legacy directory");
4114 let initial_schema = "define\nattribute name, value string;\n";
4115 write_snapshot_migration(
4116 directory.path(),
4117 "0001_initial",
4118 Vec::new(),
4119 "class Initial: pass\n",
4120 "v0001",
4121 initial_schema,
4122 );
4123 write_snapshot_migration(
4124 directory.path(),
4125 "0002_left",
4126 vec![dependency("0001_initial")],
4127 "class Left: pass\n",
4128 "v0002",
4129 "define\nattribute name, value string;\nentity person, owns name;\n",
4130 );
4131 write_snapshot_migration(
4132 directory.path(),
4133 "0003_right",
4134 vec![dependency("0001_initial")],
4135 "class Right: pass\n",
4136 "v0003",
4137 "define\nattribute name, value string;\nentity company, owns name;\n",
4138 );
4139
4140 let history =
4141 load_adoption_history(directory.path()).expect("valid branched history loads");
4142 let error = reconstruct_legacy_head(&history)
4143 .expect_err("different authoritative head snapshots cannot be adopted");
4144 assert!(
4145 error
4146 .to_string()
4147 .contains("divergent authoritative snapshots")
4148 );
4149 }
4150
4151 #[test]
4152 fn run_python_inherited_head_converges_with_an_exact_sibling_head() {
4153 let directory = tempfile::tempdir().expect("legacy directory");
4154 let schema = "define\nattribute name, value string;\nentity person, owns name;\n";
4155 let schema_hash = write_snapshot_migration(
4156 directory.path(),
4157 "0001_initial",
4158 Vec::new(),
4159 "class Initial: pass\n",
4160 "v0001",
4161 schema,
4162 );
4163 write_snapshot_migration(
4164 directory.path(),
4165 "0002_exact",
4166 vec![dependency("0001_initial")],
4167 "class Exact: pass\n",
4168 "v0002",
4169 schema,
4170 );
4171 let backfill_source = "class Backfill:\n operations = [RunPython()]\n";
4172 let backfill = archive(
4173 "0003_backfill",
4174 vec![dependency("0001_initial")],
4175 backfill_source,
4176 LegacySchemaEffect::UnchangedRunPython,
4177 "0001_initial",
4178 &schema_hash,
4179 );
4180 write_archive(
4181 directory.path(),
4182 "0003_backfill",
4183 backfill_source,
4184 &backfill,
4185 );
4186
4187 let history = load_adoption_history(directory.path()).expect("inherited history loads");
4188 assert_eq!(history.heads().expect("validated heads").len(), 2);
4189 let reconstructed =
4190 reconstruct_legacy_head(&history).expect("inherited and exact authorities converge");
4191 assert_eq!(reconstructed.schema_typeql(), schema);
4192 }
4193
4194 #[test]
4195 fn unchanged_merge_rejects_divergent_parent_snapshots() {
4196 let directory = tempfile::tempdir().expect("legacy directory");
4197 let left_source = "class Left: pass\n";
4198 let right_source = "class Right: pass\n";
4199 let merge_source = "class Merge: pass\n";
4200 let left_hash = "a".repeat(64);
4201 let right_hash = "b".repeat(64);
4202 write_archive(
4203 directory.path(),
4204 "0001_left",
4205 left_source,
4206 &archive(
4207 "0001_left",
4208 Vec::new(),
4209 left_source,
4210 LegacySchemaEffect::Snapshot,
4211 "0001_left",
4212 &left_hash,
4213 ),
4214 );
4215 write_archive(
4216 directory.path(),
4217 "0002_right",
4218 right_source,
4219 &archive(
4220 "0002_right",
4221 Vec::new(),
4222 right_source,
4223 LegacySchemaEffect::Snapshot,
4224 "0002_right",
4225 &right_hash,
4226 ),
4227 );
4228 write_archive(
4229 directory.path(),
4230 "0003_merge",
4231 merge_source,
4232 &archive(
4233 "0003_merge",
4234 vec![dependency("0001_left"), dependency("0002_right")],
4235 merge_source,
4236 LegacySchemaEffect::UnchangedRunPython,
4237 "0001_left",
4238 &left_hash,
4239 ),
4240 );
4241
4242 let error = load_adoption_history(directory.path())
4243 .expect_err("a divergent schema-neutral merge has no one head authority");
4244 assert!(error.to_string().contains("divergent snapshot authorities"));
4245 }
4246
4247 #[test]
4248 fn noop_merge_rejects_divergent_parent_snapshots() {
4249 let directory = tempfile::tempdir().expect("legacy directory");
4250 let left_source = "class Left: pass\n";
4251 let right_source = "class Right: pass\n";
4252 let merge_source = "class Merge:\n operations = []\n";
4253 let left_hash = "a".repeat(64);
4254 let right_hash = "b".repeat(64);
4255 write_archive(
4256 directory.path(),
4257 "0001_left",
4258 left_source,
4259 &archive(
4260 "0001_left",
4261 Vec::new(),
4262 left_source,
4263 LegacySchemaEffect::Snapshot,
4264 "0001_left",
4265 &left_hash,
4266 ),
4267 );
4268 write_archive(
4269 directory.path(),
4270 "0002_right",
4271 right_source,
4272 &archive(
4273 "0002_right",
4274 Vec::new(),
4275 right_source,
4276 LegacySchemaEffect::Snapshot,
4277 "0002_right",
4278 &right_hash,
4279 ),
4280 );
4281 write_archive(
4282 directory.path(),
4283 "0003_merge",
4284 merge_source,
4285 &archive(
4286 "0003_merge",
4287 vec![dependency("0001_left"), dependency("0002_right")],
4288 merge_source,
4289 LegacySchemaEffect::UnchangedNoop,
4290 "0001_left",
4291 &left_hash,
4292 ),
4293 );
4294
4295 let error = load_adoption_history(directory.path())
4296 .expect_err("a no-op merge cannot choose between divergent authorities");
4297 assert!(error.to_string().contains("divergent snapshot authorities"));
4298 }
4299
4300 #[cfg(unix)]
4301 #[test]
4302 fn supplied_root_symlink_is_followed_once_and_later_replacement_cannot_redirect_reads() {
4303 use std::os::unix::fs::symlink;
4304
4305 let original = tempfile::tempdir().expect("original legacy directory");
4306 let replacement = tempfile::tempdir().expect("replacement legacy directory");
4307 let links = tempfile::tempdir().expect("link parent");
4308 let original_directory = original.path().join("legacy");
4309 let replacement_directory = replacement.path().join("legacy");
4310 fs::create_dir(&original_directory).expect("original legacy root");
4311 fs::create_dir(&replacement_directory).expect("replacement legacy root");
4312 let schema = "define\nattribute original-name, value string;\n";
4313 write_snapshot_migration(
4314 &original_directory,
4315 "0001_initial",
4316 Vec::new(),
4317 "class Initial: pass\n",
4318 "v0001",
4319 schema,
4320 );
4321 write_snapshot_migration(
4322 &replacement_directory,
4323 "0001_initial",
4324 Vec::new(),
4325 "class Initial: pass\n",
4326 "v0001",
4327 "define\nattribute replacement-name, value string;\n",
4328 );
4329 let link = links.path().join("legacy");
4330 symlink(&original_directory, &link).expect("root symlink");
4331
4332 let history = load_adoption_history(&link).expect("root symlink is a supported input");
4333 fs::remove_file(&link).expect("old root symlink removes");
4334 symlink(&replacement_directory, &link).expect("replacement root symlink");
4335
4336 let reconstructed = reconstruct_legacy_head(&history)
4337 .expect("retained authority reconstructs the original tree");
4338 assert_eq!(reconstructed.schema_typeql(), schema);
4339 }
4340
4341 #[cfg(unix)]
4342 #[test]
4343 fn retained_root_survives_atomic_path_replacement_without_mixing_trees() {
4344 let parent = tempfile::tempdir().expect("legacy parent");
4345 let configured = parent.path().join("legacy");
4346 let held = parent.path().join("held");
4347 fs::create_dir(&configured).expect("legacy root");
4348 let schema = "define\nattribute held-name, value string;\n";
4349 write_snapshot_migration(
4350 &configured,
4351 "0001_initial",
4352 Vec::new(),
4353 "class Initial: pass\n",
4354 "v0001",
4355 schema,
4356 );
4357 let authority = LegacyDirectoryAuthority::open_root(&configured)
4358 .expect("legacy root authority retains");
4359 fs::rename(&configured, &held).expect("original root moves");
4360 fs::create_dir(&configured).expect("replacement root");
4361 write_snapshot_migration(
4362 &configured,
4363 "0001_initial",
4364 Vec::new(),
4365 "class Initial: pass\n",
4366 "v0001",
4367 "define\nattribute replacement-name, value string;\n",
4368 );
4369
4370 let history = load_adoption_history_in(authority).expect("held history loads coherently");
4371 let reconstructed = reconstruct_legacy_head(&history).expect("held snapshot reconstructs");
4372 assert_eq!(reconstructed.schema_typeql(), schema);
4373 }
4374
4375 #[cfg(unix)]
4376 #[test]
4377 fn retained_publication_never_lands_in_an_ambient_replacement_root() {
4378 let parent = tempfile::tempdir().expect("legacy parent");
4379 let configured = parent.path().join("legacy");
4380 let held = parent.path().join("held");
4381 fs::create_dir(&configured).expect("legacy root");
4382 let authority = LegacyDirectoryAuthority::open_root(&configured)
4383 .expect("legacy root authority retains");
4384 fs::rename(&configured, &held).expect("original root moves");
4385 fs::create_dir(&configured).expect("replacement root");
4386
4387 authority
4388 .write_atomic_no_replace("authority.json", b"held")
4389 .expect("publication stays on the retained root");
4390
4391 assert_eq!(
4392 fs::read(held.join("authority.json")).expect("held publication reads"),
4393 b"held"
4394 );
4395 assert!(!configured.join("authority.json").exists());
4396 }
4397
4398 #[test]
4399 fn journal_swap_at_removal_is_restored_and_never_deleted() {
4400 let directory = tempfile::tempdir().expect("journal directory");
4401 let authority = LegacyDirectoryAuthority::open_root(directory.path())
4402 .expect("journal authority retains");
4403 let journal = directory.path().join(CONVERSION_JOURNAL);
4404 let held = directory.path().join("held-original.json");
4405 let expected_bytes = b"expected journal\n";
4406 let replacement_bytes = b"attacker journal\n";
4407 fs::write(&journal, expected_bytes).expect("expected journal writes");
4408 let expected = authority
4409 .inspect_relative(Path::new(CONVERSION_JOURNAL), None)
4410 .expect("journal inspection succeeds")
4411 .expect("journal exists");
4412 let hook_journal = journal.clone();
4413 let hook_held = held.clone();
4414 TEST_BEFORE_JOURNAL_QUARANTINE.with(|hook| {
4415 *hook.borrow_mut() = Some(Box::new(move || {
4416 fs::rename(&hook_journal, &hook_held).expect("expected journal moves");
4417 fs::write(&hook_journal, replacement_bytes).expect("replacement journal writes");
4418 }));
4419 });
4420
4421 let removed = authority
4422 .remove_if_matches(CONVERSION_JOURNAL, &expected, expected_bytes)
4423 .expect("mismatched quarantine restores without replacement");
4424
4425 assert!(!removed);
4426 assert_eq!(
4427 fs::read(&journal).expect("replacement remains at public name"),
4428 replacement_bytes
4429 );
4430 assert_eq!(
4431 fs::read(&held).expect("original was moved by the test attacker"),
4432 expected_bytes
4433 );
4434 assert!(
4435 fs::read_dir(directory.path())
4436 .expect("directory reads")
4437 .all(|entry| !entry
4438 .expect("entry reads")
4439 .file_name()
4440 .to_string_lossy()
4441 .starts_with(".tb-adopt-rm-"))
4442 );
4443 }
4444
4445 #[cfg(unix)]
4446 #[test]
4447 fn in_place_source_change_is_rejected_even_when_parent_directory_is_unchanged() {
4448 let directory = tempfile::tempdir().expect("legacy directory");
4449 let path = directory.path().join("0001_initial.py");
4450 fs::write(&path, b"original-bytes\n").expect("original source");
4451 let authority = LegacyDirectoryAuthority::open_root(directory.path())
4452 .expect("legacy root authority retains");
4453 let capture = authority.capture().expect("root captures");
4454 let source = capture
4455 .entries
4456 .iter()
4457 .find(|entry| entry.name == OsStr::new("0001_initial.py"))
4458 .expect("source observation");
4459 let directory_revision = authority.directory_revision().expect("root revision");
4460 fs::write(&path, b"replaced-bytes\n").expect("same-length in-place replacement");
4461 authority
4462 .require_directory_revision(&directory_revision)
4463 .expect("in-place body write does not mutate the parent directory");
4464
4465 let error = authority
4466 .read_relative_bounded(Path::new("0001_initial.py"), 1024, Some(source))
4467 .expect_err("captured file revision rejects in-place content drift");
4468 assert!(
4469 error
4470 .to_string()
4471 .contains("changed after bounded directory enumeration")
4472 );
4473 }
4474
4475 #[test]
4476 fn recognized_root_body_digest_closes_metadata_equality_seam() {
4477 let directory = tempfile::tempdir().expect("legacy directory");
4478 write_snapshot_migration(
4479 directory.path(),
4480 "0001_initial",
4481 Vec::new(),
4482 "class Initial: pass\n",
4483 "v0001",
4484 "define\nattribute root-digest, value string;\n",
4485 );
4486 let mut history = load_adoption_history(directory.path()).expect("history loads");
4487 fs::write(
4488 directory.path().join("0001_initial.py"),
4489 "class Changed: pass\n",
4490 )
4491 .expect("same-length source body changes");
4492
4493 let observed = history
4497 .directory
4498 .inspect_relative(Path::new("0001_initial.py"), None)
4499 .expect("source inspects")
4500 .expect("source remains present");
4501 let captured = history
4502 .directory_capture
4503 .entries
4504 .iter_mut()
4505 .find(|entry| entry.name == OsStr::new("0001_initial.py"))
4506 .expect("source capture exists");
4507 *captured = observed;
4508
4509 let error = history
4510 .require_unchanged()
4511 .expect_err("exact root body digest must reject aliased metadata")
4512 .to_string();
4513 assert!(
4514 error.contains("recognized legacy root file body changed"),
4515 "{error}"
4516 );
4517 }
4518
4519 #[cfg(unix)]
4520 #[test]
4521 fn recognized_root_rename_is_rejected_even_when_directory_mtime_is_restored() {
4522 use std::fs::{File, FileTimes};
4523
4524 let directory = tempfile::tempdir().expect("legacy directory");
4525 write_snapshot_migration(
4526 directory.path(),
4527 "0001_initial",
4528 Vec::new(),
4529 "class Initial: pass\n",
4530 "v0001",
4531 "define\nattribute root-membership, value string;\n",
4532 );
4533 let padding = directory.path().join("padding-file.json");
4534 fs::write(&padding, b"unrelated padding\n").expect("padding file");
4535 let history = load_adoption_history(directory.path()).expect("history loads");
4536 let modified = fs::metadata(directory.path())
4537 .expect("root metadata")
4538 .modified()
4539 .expect("root modification time");
4540
4541 fs::rename(&padding, directory.path().join("0009_deleted.json"))
4542 .expect("padding becomes a released sidecar name");
4543 File::open(directory.path())
4544 .expect("root opens")
4545 .set_times(FileTimes::new().set_modified(modified))
4546 .expect("root mtime restores");
4547
4548 let error = history
4549 .require_unchanged()
4550 .expect_err("filtered root membership must not rely on restorable mtime")
4551 .to_string();
4552 assert!(
4553 error.contains("recognized legacy migration membership changed"),
4554 "{error}"
4555 );
4556 }
4557
4558 #[cfg(unix)]
4559 #[test]
4560 fn snapshot_version_rename_is_rejected_even_when_directory_mtime_is_restored() {
4561 use std::fs::{File, FileTimes};
4562
4563 let directory = tempfile::tempdir().expect("snapshot directory");
4564 fs::create_dir(directory.path().join("x0009")).expect("padding snapshot directory");
4565 let authority = LegacyDirectoryAuthority::open_root(directory.path())
4566 .expect("snapshot authority retains");
4567 let mut capture = authority.capture().expect("snapshot root captures");
4568 capture
4569 .entries
4570 .retain(|entry| entry.name.to_str().is_some_and(is_snapshot_version));
4571 let modified = fs::metadata(directory.path())
4572 .expect("snapshot root metadata")
4573 .modified()
4574 .expect("snapshot root modification time");
4575
4576 fs::rename(
4577 directory.path().join("x0009"),
4578 directory.path().join("v0009"),
4579 )
4580 .expect("padding becomes a snapshot version");
4581 File::open(directory.path())
4582 .expect("snapshot root opens")
4583 .set_times(FileTimes::new().set_modified(modified))
4584 .expect("snapshot root mtime restores");
4585
4586 let error = authority
4587 .require_snapshot_version_capture(&capture)
4588 .expect_err("filtered snapshot membership must not rely on restorable mtime")
4589 .to_string();
4590 assert!(
4591 error.contains("snapshot version membership changed"),
4592 "{error}"
4593 );
4594 }
4595
4596 #[test]
4597 fn require_unchanged_reverifies_snapshot_file_contents() {
4598 let directory = tempfile::tempdir().expect("legacy directory");
4599 let original = "define\nattribute original-name, value string;\n";
4600 write_snapshot_migration(
4601 directory.path(),
4602 "0001_initial",
4603 Vec::new(),
4604 "class Initial: pass\n",
4605 "v0001",
4606 original,
4607 );
4608 let history = load_adoption_history(directory.path()).expect("history loads");
4609 assert_eq!(
4610 reconstruct_legacy_head(&history)
4611 .expect("initial snapshot reconstructs")
4612 .schema_typeql(),
4613 original
4614 );
4615 fs::write(
4616 directory.path().join("snapshots/v0001/schema.tql"),
4617 "define\nattribute replaced-name, value string;\n",
4618 )
4619 .expect("same-length snapshot body changes in place");
4620
4621 let error = history
4622 .require_unchanged()
4623 .expect_err("use-point validation must rehash snapshot children")
4624 .to_string();
4625 assert!(error.contains("snapshot file hash mismatch"), "{error}");
4626 }
4627
4628 #[test]
4629 fn released_snapshot_ignores_unbound_pycache_children() {
4630 let directory = tempfile::tempdir().expect("legacy directory");
4631 let schema = "define\nattribute imported-name, value string;\n";
4632 write_snapshot_migration(
4633 directory.path(),
4634 "0001_initial",
4635 Vec::new(),
4636 "class Initial: pass\n",
4637 "v0001",
4638 schema,
4639 );
4640 let cache = directory.path().join("snapshots/v0001/__pycache__");
4641 fs::create_dir(&cache).expect("import cache directory");
4642 fs::write(cache.join("entities.cpython-313.pyc"), b"ambient cache")
4643 .expect("import cache body");
4644
4645 let history = load_adoption_history(directory.path()).expect("history loads");
4646 let reconstructed =
4647 reconstruct_legacy_head(&history).expect("unbound cache is not snapshot authority");
4648
4649 assert_eq!(reconstructed.schema_typeql(), schema);
4650 }
4651
4652 #[test]
4653 fn irrelevant_snapshot_manifest_change_during_scan_fails_closed() {
4654 let directory = tempfile::tempdir().expect("legacy directory");
4655 write_snapshot_migration(
4656 directory.path(),
4657 "0001_initial",
4658 Vec::new(),
4659 "class Initial: pass\n",
4660 "v0001",
4661 "define\nattribute scan-race, value string;\n",
4662 );
4663 write_snapshot(
4664 directory.path(),
4665 "v0009",
4666 "9999_irrelevant",
4667 "define\nattribute irrelevant, value string;\n",
4668 );
4669 let history = load_adoption_history(directory.path()).expect("history loads");
4670 let irrelevant_manifest = directory.path().join("snapshots/v0009/snapshot.json");
4671 TEST_AFTER_SNAPSHOT_SCAN.with(|hook| {
4672 *hook.borrow_mut() = Some(Box::new(move || {
4673 fs::write(&irrelevant_manifest, b"{}").expect("irrelevant manifest mutates");
4674 }));
4675 });
4676
4677 let error = reconstruct_legacy_head(&history)
4678 .expect_err("every scanned manifest must stay exact through authority selection")
4679 .to_string();
4680 assert!(
4681 error.contains("scanned legacy snapshot manifest changed after bounded scan"),
4682 "{error}"
4683 );
4684 }
4685
4686 #[cfg(unix)]
4687 #[test]
4688 fn snapshot_descendant_swap_is_rejected_before_reconstruction() {
4689 let directory = tempfile::tempdir().expect("legacy directory");
4690 write_snapshot_migration(
4691 directory.path(),
4692 "0001_initial",
4693 Vec::new(),
4694 "class Initial: pass\n",
4695 "v0001",
4696 "define\nattribute original-name, value string;\n",
4697 );
4698 let history = load_adoption_history(directory.path()).expect("history loads");
4699 let version = directory.path().join("snapshots/v0001");
4700 let held = directory.path().join("snapshots/v0001-held");
4701 fs::rename(&version, &held).expect("snapshot version moves");
4702 write_snapshot(
4703 directory.path(),
4704 "v0001",
4705 "0001_initial",
4706 "define\nattribute replacement-name, value string;\n",
4707 );
4708
4709 let error = reconstruct_legacy_head(&history)
4710 .expect_err("snapshot-root revision rejects the descendant swap");
4711 assert!(
4712 error
4713 .to_string()
4714 .contains("retained entry changed after bounded capture")
4715 );
4716 }
4717
4718 #[test]
4719 fn duplicate_snapshot_source_names_across_apps_are_ambiguous() {
4720 let directory = tempfile::tempdir().expect("legacy directory");
4721 let schema = "define\nattribute shared-name, value string;\n";
4722 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
4723 write_snapshot(directory.path(), "v0001", "0001_initial", schema);
4724 let directory_authority = LegacyDirectoryAuthority::open_root(directory.path())
4725 .expect("legacy root authority retains");
4726 let directory_capture = directory_authority.capture().expect("root captures");
4727 let snapshots_entry = directory_capture
4728 .entries
4729 .iter()
4730 .find(|entry| entry.name == OsStr::new("snapshots"))
4731 .expect("snapshots observation");
4732 let snapshots = directory_authority
4733 .open_child(snapshots_entry)
4734 .expect("snapshots retain");
4735 let snapshot_capture = snapshots.capture().expect("snapshot versions capture");
4736 let migrations = ["app_a", "app_b"]
4737 .into_iter()
4738 .map(|app_label| MigrationSpec {
4739 app_label: app_label.to_owned(),
4740 name: "0001_initial".to_owned(),
4741 dependencies: Vec::new(),
4742 operations: Vec::new(),
4743 checksum: Some("0123456789abcdef".to_owned()),
4744 source_sha256: None,
4745 reversible: false,
4746 })
4747 .collect::<Vec<_>>();
4748 let snapshot_authorities = ["app_a", "app_b"]
4749 .into_iter()
4750 .map(|app_label| {
4751 (
4752 (app_label.to_owned(), "0001_initial".to_owned()),
4753 SnapshotAuthority {
4754 source: MigrationDependencySpec {
4755 app_label: app_label.to_owned(),
4756 migration_name: "0001_initial".to_owned(),
4757 },
4758 schema_hash: schema_hash.clone(),
4759 },
4760 )
4761 })
4762 .collect();
4763 let history = LegacyAdoptionHistory {
4764 graph: MigrationGraph { migrations },
4765 directory: directory_authority,
4766 directory_capture,
4767 snapshots: Some((snapshots, snapshot_capture)),
4768 snapshot_authorities,
4769 root_file_digests: BTreeMap::new(),
4770 consumed_bytes: 0,
4771 };
4772
4773 let error = reconstruct_legacy_head(&history)
4774 .expect_err("an app-less V1 snapshot cannot authorize two app owners")
4775 .to_string();
4776 assert!(error.contains("ambiguous across app labels"), "{error}");
4777 }
4778
4779 #[test]
4780 fn native_load_rejects_cross_app_claims_on_one_snapshot_source() {
4781 let temporary = tempfile::tempdir().expect("temporary root");
4782 let directory = temporary.path().join("migrations");
4783 fs::create_dir(&directory).expect("legacy directory");
4784 let schema = "define\nattribute shared-source, value string;\n";
4785 let schema_hash = hex_digest(Sha256::digest(schema.as_bytes()));
4786 write_snapshot(&directory, "v0001", "0001_shared", schema);
4787
4788 for (source_name, app_label) in [("0001_a", "app_a"), ("0001_b", "app_b")] {
4789 let source = "raise RuntimeError('released sidecar wins')\n";
4790 let sidecar = sidecar_spec(app_label, "0001_shared", Vec::new(), source, Vec::new());
4791 let sidecar_bytes = serde_json::to_vec(&sidecar).expect("sidecar JSON");
4792 let archive = LegacySidecarAdoptionMetadata::new(
4793 source_name,
4794 app_label,
4795 "0001_shared",
4796 Vec::new(),
4797 migration_file_checksum(source),
4798 sidecar.checksum.clone(),
4799 hex_digest(Sha256::digest(source.as_bytes())),
4800 hex_digest(Sha256::digest(&sidecar_bytes)),
4801 LegacySchemaEffect::Snapshot,
4802 MigrationDependencySpec {
4803 app_label: app_label.to_owned(),
4804 migration_name: "0001_shared".to_owned(),
4805 },
4806 &schema_hash,
4807 )
4808 .expect("cross-app sidecar archive");
4809 write_sidecar_archive(&directory, source_name, source, &sidecar, &archive);
4810 }
4811
4812 let error = load_adoption_history(&directory)
4813 .expect_err("native load must reject app-less snapshot owner ambiguity")
4814 .to_string();
4815 assert!(error.contains("ambiguous across app labels"), "{error}");
4816 }
4817
4818 #[test]
4819 fn irrelevant_snapshot_children_are_not_recursively_enumerated() {
4820 let directory = tempfile::tempdir().expect("legacy directory");
4821 let schema = "define\nattribute relevant-name, value string;\n";
4822 write_snapshot_migration(
4823 directory.path(),
4824 "0001_initial",
4825 Vec::new(),
4826 "class Initial: pass\n",
4827 "v0001",
4828 schema,
4829 );
4830 for index in 1000..1128 {
4831 let version = format!("v{index:04}");
4832 write_snapshot(
4833 directory.path(),
4834 &version,
4835 "9999_irrelevant",
4836 "define\nattribute irrelevant-name, value string;\n",
4837 );
4838 let snapshot = directory.path().join("snapshots").join(version);
4839 for extra in 0..32 {
4840 fs::write(snapshot.join(format!("extra-{extra:02}.txt")), b"ignored")
4841 .expect("irrelevant extra writes");
4842 }
4843 }
4844 let history = load_adoption_history(directory.path()).expect("history loads");
4845 let top_level_revalidation_entries = history.directory_capture.entries.len()
4846 + history
4847 .snapshots
4848 .as_ref()
4849 .expect("snapshot authority")
4850 .1
4851 .entries
4852 .len();
4853 reset_captured_entry_count();
4854 let started = std::time::Instant::now();
4855
4856 let reconstructed = reconstruct_legacy_head(&history).expect("relevant snapshot verifies");
4857
4858 assert_eq!(reconstructed.schema_typeql(), schema);
4859 assert_eq!(
4860 captured_entry_count(),
4861 3 * top_level_revalidation_entries + 2,
4862 "only exact root/version revalidation and the relevant snapshot children may be enumerated"
4863 );
4864 assert!(started.elapsed() < std::time::Duration::from_secs(10));
4865 }
4866
4867 #[test]
4868 fn ceiling_scale_reversed_chain_validates_resolves_and_finds_head_within_budget() {
4869 const NODE_COUNT: usize = 65_536;
4870 let schema_hash = "a".repeat(64);
4871 let root_authority = SnapshotAuthority {
4872 source: MigrationDependencySpec {
4873 app_label: "app".to_owned(),
4874 migration_name: "node_00000".to_owned(),
4875 },
4876 schema_hash,
4877 };
4878 let mut migrations = Vec::with_capacity(NODE_COUNT);
4879 let mut bindings = BTreeMap::new();
4880 for index in (0..NODE_COUNT).rev() {
4881 let name = format!("node_{index:05}");
4882 let dependencies = if index == 0 {
4883 Vec::new()
4884 } else {
4885 vec![MigrationDependencySpec {
4886 app_label: "app".to_owned(),
4887 migration_name: format!("node_{:05}", index - 1),
4888 }]
4889 };
4890 migrations.push(MigrationSpec {
4891 app_label: "app".to_owned(),
4892 name: name.clone(),
4893 dependencies,
4894 operations: Vec::new(),
4895 checksum: Some(format!("checksum-{index}")),
4896 source_sha256: None,
4897 reversible: false,
4898 });
4899 bindings.insert(
4900 ("app".to_owned(), name),
4901 SchemaBinding {
4902 effect: if index == 0 {
4903 LegacySchemaEffect::Snapshot
4904 } else {
4905 LegacySchemaEffect::UnchangedNoop
4906 },
4907 authority: root_authority.clone(),
4908 },
4909 );
4910 }
4911 let graph = MigrationGraph { migrations };
4912 let started = std::time::Instant::now();
4913 assert!(validate_graph(&graph, &[]).is_empty());
4914 let resolved = resolve_schema_bindings(&graph, &bindings).expect("chain resolves");
4915 let directory = tempfile::tempdir().expect("authority directory");
4916 let directory_authority =
4917 LegacyDirectoryAuthority::open_root(directory.path()).expect("directory authority");
4918 let directory_capture = directory_authority.capture().expect("directory captures");
4919 let history = LegacyAdoptionHistory {
4920 graph,
4921 directory: directory_authority,
4922 directory_capture,
4923 snapshots: None,
4924 snapshot_authorities: resolved,
4925 root_file_digests: BTreeMap::new(),
4926 consumed_bytes: 0,
4927 };
4928 let heads = history.heads().expect("head resolves");
4929
4930 assert_eq!(heads.len(), 1);
4931 assert_eq!(heads[0].name, "node_65535");
4932 assert!(started.elapsed() < std::time::Duration::from_secs(10));
4933 }
4934
4935 #[test]
4936 fn executable_sidecar_alone_is_not_adoption_graph_authority() {
4937 let directory = tempfile::tempdir().expect("legacy directory");
4938 let source = "class Migration: pass\n";
4939 fs::write(directory.path().join("0001_initial.py"), source).expect("Python source");
4940 let spec = MigrationSpec {
4941 app_label: "example".to_owned(),
4942 name: "0001_initial".to_owned(),
4943 dependencies: Vec::new(),
4944 operations: Vec::new(),
4945 checksum: Some(migration_file_checksum(source)),
4946 source_sha256: None,
4947 reversible: true,
4948 };
4949 fs::write(
4950 directory.path().join("0001_initial.json"),
4951 serde_json::to_vec(&spec).expect("sidecar JSON"),
4952 )
4953 .expect("sidecar writes");
4954
4955 let error = load_adoption_history(directory.path())
4956 .expect_err("independently editable sidecar dependencies are insufficient");
4957 assert!(error.to_string().contains("adoption metadata"));
4958 }
4959}