1use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use super::{
11 canonicalize_existing_prefix, resolve_write_target, write_atomic,
12 write_toml_preserving_minimal_locked,
13};
14
15pub(super) const CONFIG_MIGRATION_RECEIPT_VERSION: u32 = 1;
16
17#[derive(Debug, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub(super) struct ConfigMigrationReceipt {
20 pub(super) version: u32,
21 pub(super) source_file: String,
22 pub(super) backup_file: String,
23 pub(super) source_sha256: String,
24 pub(super) output_sha256: String,
25}
26
27pub(super) fn sha256_hex(bytes: &[u8]) -> String {
28 crate::core::agent_identity::hex_encode(&Sha256::digest(bytes))
29}
30
31pub(super) fn config_migration_paths(path: &Path) -> Result<(PathBuf, PathBuf), String> {
32 let filename = path
33 .file_name()
34 .and_then(|name| name.to_str())
35 .ok_or_else(|| "config path must end in a UTF-8 filename".to_string())?;
36 Ok((
37 path.with_file_name(format!("{filename}.v4-migration.bak")),
38 path.with_file_name(format!("{filename}.v4-migration.json")),
39 ))
40}
41
42pub(super) fn lock_config_migration(
43 path: &Path,
44) -> Result<crate::core::migration_lock::MigrationLock, String> {
45 crate::core::migration_lock::acquire(&config_migration_lock_path(path)?)
46}
47
48pub(crate) struct ConfigWriteGuard {
70 _lock: crate::core::migration_lock::MigrationLock,
72 path: PathBuf,
75}
76
77impl ConfigWriteGuard {
78 pub(crate) fn acquire(path: &Path) -> Result<Self, String> {
80 let resolved = canonicalize_existing_prefix(&resolve_write_target(path)?)?;
81 let lock = lock_config_migration(&resolved)?;
82 Ok(Self {
83 _lock: lock,
84 path: resolved,
85 })
86 }
87
88 pub(crate) fn path(&self) -> &Path {
91 &self.path
92 }
93
94 pub(crate) fn write_toml_preserving_minimal(
96 &self,
97 new_content: &str,
98 default_content: &str,
99 ) -> Result<(), String> {
100 write_toml_preserving_minimal_locked(&self.path, new_content, default_content)
101 }
102}
103
104pub(crate) fn config_migration_lock_path(path: &Path) -> Result<PathBuf, String> {
110 let target = canonicalize_existing_prefix(&resolve_write_target(path)?)?;
111 let filename = target
112 .file_name()
113 .and_then(|name| name.to_str())
114 .ok_or_else(|| "config path must end in a UTF-8 filename".to_string())?;
115 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
116 let key = blake3::hash(target.to_string_lossy().as_bytes()).to_hex();
117 return Ok(data_dir
118 .join("locks")
119 .join(format!("{}.{filename}.v4-migration.lock", &key[..32])));
120 }
121 Ok(target.with_file_name(format!("{filename}.v4-migration.lock")))
122}
123
124pub fn write_atomic_config_migration(path: &Path, content: &str) -> Result<(), String> {
137 write_atomic_config_migration_checked(path, content, None)
138}
139
140pub fn write_atomic_config_migration_checked(
141 path: &Path,
142 content: &str,
143 expected_source: Option<&[u8]>,
144) -> Result<(), String> {
145 let resolved = canonicalize_existing_prefix(&resolve_write_target(path)?)?;
146 let path = resolved.as_path();
147 let _lock = lock_config_migration(path)?;
148 let source = std::fs::read(path).map_err(|error| format!("read config: {error}"))?;
149 if expected_source.is_some_and(|expected| expected != source) {
150 return Err("config changed before migration; refusing stale rewrite".into());
151 }
152 if source == content.as_bytes() {
153 return Ok(());
154 }
155 let (backup, receipt_path) = config_migration_paths(path)?;
156 let next_path = config_migration_next_receipt_path(path)?;
157 let output_sha256 = sha256_hex(content.as_bytes());
158 let (journal, fixes) =
159 classify_config_migration_journal(path, &backup, &receipt_path, &next_path, &source)
160 .map_err(|refusal| refusal.detail)?;
161 apply_config_migration_journal_fixes(&backup, &receipt_path, &next_path, fixes)?;
162 match journal {
163 MigrationJournal::Absent => {}
164 MigrationJournal::Interrupted(mut receipt) => {
165 std::str::from_utf8(&source).map_err(|_| "config is not UTF-8")?;
170 if receipt.output_sha256 != output_sha256 {
171 receipt.output_sha256 = output_sha256;
172 replace_config_migration_receipt(&receipt_path, &receipt)?;
173 }
174 return write_atomic(path, content);
175 }
176 MigrationJournal::Completed(receipt) => {
177 return extend_completed_config_migration(
178 path,
179 content,
180 &receipt_path,
181 &next_path,
182 receipt,
183 output_sha256,
184 );
185 }
186 MigrationJournal::Superseded(receipt) => {
187 let archived = archive_config_migration_journal(&backup, &receipt_path, &receipt)?;
192 tracing::warn!(
193 "{}",
194 superseded_config_migration_message(path, &archived, &backup)
195 );
196 }
197 }
198 let source_file = path
199 .file_name()
200 .and_then(|name| name.to_str())
201 .ok_or_else(|| "invalid config filename".to_string())?;
202 let backup_file = backup
203 .file_name()
204 .and_then(|name| name.to_str())
205 .ok_or_else(|| "invalid backup filename".to_string())?;
206 let receipt = ConfigMigrationReceipt {
207 version: CONFIG_MIGRATION_RECEIPT_VERSION,
208 source_file: source_file.to_string(),
209 backup_file: backup_file.to_string(),
210 source_sha256: sha256_hex(&source),
211 output_sha256,
212 };
213 std::str::from_utf8(&source).map_err(|_| "config is not UTF-8")?;
214 crate::core::migration_lock::atomic_create_without_overwrite(&backup, &source)?;
215 crate::core::migration_lock::atomic_create_without_overwrite(
216 &receipt_path,
217 config_migration_receipt_json(&receipt)?.as_bytes(),
218 )?;
219 write_atomic(path, content)?;
220 Ok(())
221}
222
223pub(super) fn extend_completed_config_migration(
238 path: &Path,
239 content: &str,
240 receipt_path: &Path,
241 next_path: &Path,
242 mut receipt: ConfigMigrationReceipt,
243 output_sha256: String,
244) -> Result<(), String> {
245 receipt.output_sha256 = output_sha256;
246 crate::core::migration_lock::atomic_create_without_overwrite(
247 next_path,
248 config_migration_receipt_json(&receipt)?.as_bytes(),
249 )?;
250 journal_fault(JournalFault::CrashAfterNextReceipt)?;
251 let replaced =
252 journal_fault(JournalFault::FailConfigReplace).and_then(|()| write_atomic(path, content));
253 if let Err(error) = replaced {
254 if let Err(cleanup) = std::fs::remove_file(next_path) {
255 tracing::debug!("config migration receipt update kept for the next run: {cleanup}");
257 }
258 return Err(error);
259 }
260 journal_fault(JournalFault::CrashAfterConfigReplace)?;
261 if let Err(error) = promote_config_migration_next_receipt(receipt_path, next_path, &receipt) {
262 tracing::debug!("config migration receipt update left for the next run: {error}");
264 }
265 Ok(())
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub(super) enum JournalFault {
271 CrashAfterNextReceipt,
273 FailConfigReplace,
275 CrashAfterConfigReplace,
277}
278
279#[cfg(test)]
280thread_local! {
281 pub(super) static JOURNAL_FAULT: std::cell::Cell<Option<JournalFault>> =
282 const { std::cell::Cell::new(None) };
283}
284
285pub(super) fn journal_fault(point: JournalFault) -> Result<(), String> {
286 #[cfg(test)]
287 if JOURNAL_FAULT.with(std::cell::Cell::get) == Some(point) {
288 return Err(format!("injected journal fault: {point:?}"));
289 }
290 #[cfg(not(test))]
291 let _ = point;
292 Ok(())
293}
294
295pub(super) fn config_migration_receipt_json(
296 receipt: &ConfigMigrationReceipt,
297) -> Result<String, String> {
298 let json = serde_json::to_string_pretty(receipt)
299 .map_err(|error| format!("serialize config migration receipt: {error}"))?;
300 Ok(format!("{json}\n"))
301}
302
303pub(super) fn promote_config_migration_next_receipt(
305 receipt_path: &Path,
306 next_path: &Path,
307 next: &ConfigMigrationReceipt,
308) -> Result<(), String> {
309 replace_config_migration_receipt(receipt_path, next)?;
310 std::fs::remove_file(next_path)
311 .map_err(|error| format!("remove committed config migration receipt update: {error}"))
312}
313
314pub(super) fn config_migration_next_receipt_path(path: &Path) -> Result<PathBuf, String> {
317 let filename = path
318 .file_name()
319 .and_then(|name| name.to_str())
320 .ok_or_else(|| "config path must end in a UTF-8 filename".to_string())?;
321 Ok(path.with_file_name(format!("{filename}.v4-migration.next.json")))
322}
323
324pub(super) enum MigrationJournal {
334 Absent,
336 Interrupted(ConfigMigrationReceipt),
340 Completed(ConfigMigrationReceipt),
342 Superseded(ConfigMigrationReceipt),
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub enum ConfigMigrationJournalRefusal {
352 Symlink,
354 Unreadable,
356 ReceiptUnverifiable,
358 BackupMismatch,
360 OrphanBackup,
362 OrphanReceipt,
364 NextReceiptUnverifiable,
367}
368
369impl ConfigMigrationJournalRefusal {
370 pub fn label(self) -> &'static str {
371 match self {
372 Self::Symlink => "journal-symlink",
373 Self::Unreadable => "journal-unreadable",
374 Self::ReceiptUnverifiable => "receipt-unverifiable",
375 Self::BackupMismatch => "backup-mismatch",
376 Self::OrphanBackup => "orphan-backup",
377 Self::OrphanReceipt => "orphan-receipt",
378 Self::NextReceiptUnverifiable => "next-receipt-unverifiable",
379 }
380 }
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub enum ConfigMigrationJournalState {
386 Absent,
387 Interrupted,
388 Completed,
389 Superseded,
390 Refused(ConfigMigrationJournalRefusal),
391}
392
393impl ConfigMigrationJournalState {
394 pub fn rollback_available(self) -> bool {
396 matches!(self, Self::Interrupted | Self::Completed)
397 }
398}
399
400pub(super) struct JournalRefused {
401 refusal: ConfigMigrationJournalRefusal,
402 detail: String,
403}
404
405pub(super) fn refused(
406 refusal: ConfigMigrationJournalRefusal,
407 detail: impl Into<String>,
408) -> JournalRefused {
409 JournalRefused {
410 refusal,
411 detail: detail.into(),
412 }
413}
414
415pub(super) enum JournalFix {
417 RemoveBackup,
418 RemoveReceipt,
419 RemoveNextReceipt,
420 PromoteNextReceipt(ConfigMigrationReceipt),
421}
422
423pub(super) fn read_config_migration_receipt(
426 path: &Path,
427 backup: &Path,
428 receipt_path: &Path,
429) -> Result<(ConfigMigrationReceipt, Vec<u8>), String> {
430 let receipt_bytes = std::fs::read(receipt_path)
431 .map_err(|error| format!("read config migration receipt: {error}"))?;
432 let receipt: ConfigMigrationReceipt = serde_json::from_slice(&receipt_bytes)
433 .map_err(|error| format!("parse config migration receipt: {error}"))?;
434 if receipt.version != CONFIG_MIGRATION_RECEIPT_VERSION {
435 return Err(format!(
436 "unsupported config migration receipt version {}",
437 receipt.version
438 ));
439 }
440 let expected_source = path.file_name().and_then(|name| name.to_str());
441 let expected_backup = backup.file_name().and_then(|name| name.to_str());
442 if Some(receipt.source_file.as_str()) != expected_source
443 || Some(receipt.backup_file.as_str()) != expected_backup
444 {
445 return Err("config migration receipt references unexpected files".to_string());
446 }
447 Ok((receipt, receipt_bytes))
448}
449
450pub(super) struct ArchivedBackup {
452 pub(super) path: PathBuf,
453 pub(super) key: String,
454 pub(super) original: bool,
457}
458
459pub(super) fn archived_config_migration_backups(backup: &Path) -> Vec<ArchivedBackup> {
461 let (Some(parent), Some(backup_name)) = (
462 backup.parent(),
463 backup.file_name().and_then(|name| name.to_str()),
464 ) else {
465 return Vec::new();
466 };
467 let stem = backup_name.strip_suffix(".bak").unwrap_or(backup_name);
468 let prefix = format!("{stem}.");
469 let Ok(entries) = std::fs::read_dir(parent) else {
470 return Vec::new();
471 };
472 let mut archived: Vec<ArchivedBackup> = entries
473 .filter_map(|entry| {
474 let name = entry.ok()?.file_name().into_string().ok()?;
475 let rest = name.strip_prefix(&prefix)?.strip_suffix(".bak")?;
476 let (original, key) = match rest.strip_prefix("original.") {
477 Some(key) => (true, key),
478 None => (false, rest),
479 };
480 (key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())).then(|| {
481 ArchivedBackup {
482 path: parent.join(&name),
483 key: key.to_string(),
484 original,
485 }
486 })
487 })
488 .collect();
489 archived.sort_by(|left, right| left.path.cmp(&right.path));
490 archived
491}
492
493pub(super) fn archived_config_migration_paths(
500 backup: &Path,
501 source_sha256: &str,
502 receipt_bytes: &[u8],
503 original: bool,
504) -> (PathBuf, PathBuf) {
505 let key = &source_sha256[..source_sha256.len().min(16)];
506 let tag = if original {
507 format!("original.{key}")
508 } else {
509 key.to_string()
510 };
511 let receipt_sha256 = sha256_hex(receipt_bytes);
512 let backup_name = backup
513 .file_name()
514 .and_then(|name| name.to_str())
515 .unwrap_or("config.toml.v4-migration.bak");
516 let stem = backup_name.strip_suffix(".bak").unwrap_or(backup_name);
517 (
518 backup.with_file_name(format!("{stem}.{tag}.bak")),
519 backup.with_file_name(format!("{stem}.{tag}.{}.json", &receipt_sha256[..8])),
520 )
521}
522
523pub(super) fn create_or_verify(target: &Path, bytes: &[u8]) -> Result<(), String> {
525 if let Ok(meta) = std::fs::symlink_metadata(target) {
526 if meta.file_type().is_symlink() {
527 return Err(format!("{} must not be a symlink", target.display()));
528 }
529 let existing =
530 std::fs::read(target).map_err(|error| format!("read {}: {error}", target.display()))?;
531 return if existing == bytes {
532 Ok(())
533 } else {
534 Err(format!(
535 "{} exists with different content",
536 target.display()
537 ))
538 };
539 }
540 crate::core::migration_lock::atomic_create_without_overwrite(target, bytes)
541}
542
543pub(super) fn archive_config_migration_journal(
551 backup: &Path,
552 receipt_path: &Path,
553 receipt: &ConfigMigrationReceipt,
554) -> Result<PathBuf, String> {
555 let backup_bytes =
556 std::fs::read(backup).map_err(|error| format!("read config migration backup: {error}"))?;
557 let receipt_bytes = std::fs::read(receipt_path)
558 .map_err(|error| format!("read config migration receipt: {error}"))?;
559 let key = &receipt.source_sha256[..receipt.source_sha256.len().min(16)];
560 let existing = archived_config_migration_backups(backup);
561 let original = existing
562 .iter()
563 .find(|archived| archived.key == key)
564 .map_or(existing.is_empty(), |archived| archived.original);
565 let (archived_backup, archived_receipt) =
566 archived_config_migration_paths(backup, &receipt.source_sha256, &receipt_bytes, original);
567 create_or_verify(&archived_backup, &backup_bytes)?;
568 create_or_verify(&archived_receipt, &receipt_bytes)?;
569 std::fs::remove_file(receipt_path)
570 .map_err(|error| format!("retire config migration receipt: {error}"))?;
571 std::fs::remove_file(backup)
572 .map_err(|error| format!("retire config migration backup: {error}"))?;
573 Ok(archived_backup)
574}
575
576pub(super) fn superseded_config_migration_message(
579 path: &Path,
580 archived: &Path,
581 backup: &Path,
582) -> String {
583 let mut message = format!(
584 "config migration journal for {} no longer matched the config (it was edited after \
585 the recorded migration) and was archived as {}; `lean-ctx migrate config --rollback` \
586 now undoes only the newest migration.",
587 path.display(),
588 archived.display()
589 );
590 if let Some(original) = original_backup_beside(backup) {
591 message.push_str(&original_config_sentence(&original));
592 }
593 message
594}
595
596pub fn original_config_sentence(original: &Path) -> String {
598 format!(
599 " The original pre-migration config (from before the first recorded migration) is kept \
600 in {}.",
601 original.display()
602 )
603}
604
605pub(super) fn original_backup_beside(backup: &Path) -> Option<PathBuf> {
606 archived_config_migration_backups(backup)
607 .into_iter()
608 .find(|archived| archived.original)
609 .map(|archived| archived.path)
610}
611
612pub fn original_config_migration_backup(path: &Path) -> Option<PathBuf> {
617 let resolved = canonicalize_existing_prefix(&resolve_write_target(path).ok()?).ok()?;
618 let (backup, _) = config_migration_paths(&resolved).ok()?;
619 original_backup_beside(&backup)
620}
621
622pub(super) fn replace_config_migration_receipt(
624 receipt_path: &Path,
625 receipt: &ConfigMigrationReceipt,
626) -> Result<(), String> {
627 write_atomic(receipt_path, &config_migration_receipt_json(receipt)?)
628}
629
630pub(super) fn settle_config_migration_next_receipt(
633 path: &Path,
634 backup: &Path,
635 next_path: &Path,
636 receipt: &ConfigMigrationReceipt,
637 current_sha256: &str,
638) -> Result<JournalFix, JournalRefused> {
639 let next = read_config_migration_receipt(path, backup, next_path)
640 .ok()
641 .map(|(next, _)| next)
642 .filter(|next| next.source_sha256 == receipt.source_sha256);
643 match next {
644 Some(next)
646 if current_sha256 == next.output_sha256 && current_sha256 != receipt.output_sha256 =>
647 {
648 Ok(JournalFix::PromoteNextReceipt(next))
649 }
650 Some(_) => Ok(JournalFix::RemoveNextReceipt),
653 None if current_sha256 == receipt.source_sha256
654 || current_sha256 == receipt.output_sha256 =>
655 {
656 Ok(JournalFix::RemoveNextReceipt)
657 }
658 None => Err(refused(
659 ConfigMigrationJournalRefusal::NextReceiptUnverifiable,
660 "unfinished config migration receipt update cannot be verified",
661 )),
662 }
663}
664
665pub(super) fn classify_config_migration_journal(
671 path: &Path,
672 backup: &Path,
673 receipt_path: &Path,
674 next_path: &Path,
675 current: &[u8],
676) -> Result<(MigrationJournal, Vec<JournalFix>), JournalRefused> {
677 use ConfigMigrationJournalRefusal as Refusal;
678 for artifact in [backup, receipt_path, next_path] {
679 if std::fs::symlink_metadata(artifact).is_ok_and(|meta| meta.file_type().is_symlink()) {
680 return Err(refused(
681 Refusal::Symlink,
682 "config migration journal must not be a symlink",
683 ));
684 }
685 }
686 let has_backup = std::fs::symlink_metadata(backup).is_ok();
687 let has_receipt = std::fs::symlink_metadata(receipt_path).is_ok();
688 let has_next = std::fs::symlink_metadata(next_path).is_ok();
689 let current_sha256 = sha256_hex(current);
690 let mut fixes = Vec::new();
691 let journal = match (has_backup, has_receipt) {
692 (false, false) => MigrationJournal::Absent,
693 (true, false) => {
694 let orphan = std::fs::read(backup).map_err(|error| {
695 refused(
696 Refusal::Unreadable,
697 format!("read orphan config migration backup: {error}"),
698 )
699 })?;
700 let orphan_sha256 = sha256_hex(&orphan);
703 let already_archived =
704 archived_config_migration_backups(backup)
705 .iter()
706 .any(|archived| {
707 orphan_sha256.starts_with(&archived.key)
708 && std::fs::read(&archived.path).is_ok_and(|bytes| bytes == orphan)
709 });
710 if orphan != current && !already_archived {
711 return Err(refused(
712 Refusal::OrphanBackup,
713 "config migration backup has no receipt and does not match the config",
714 ));
715 }
716 fixes.push(JournalFix::RemoveBackup);
717 MigrationJournal::Absent
718 }
719 (false, true) => {
720 let (receipt, _) = read_config_migration_receipt(path, backup, receipt_path)
723 .map_err(|detail| refused(Refusal::ReceiptUnverifiable, detail))?;
724 if current_sha256 != receipt.source_sha256 {
725 return Err(refused(
726 Refusal::OrphanReceipt,
727 "config migration receipt has no backup and the config is not its source",
728 ));
729 }
730 fixes.push(JournalFix::RemoveReceipt);
731 MigrationJournal::Absent
732 }
733 (true, true) => {
734 let (mut receipt, _) = read_config_migration_receipt(path, backup, receipt_path)
735 .map_err(|detail| refused(Refusal::ReceiptUnverifiable, detail))?;
736 let backup_bytes = std::fs::read(backup).map_err(|error| {
737 refused(
738 Refusal::Unreadable,
739 format!("read config migration backup: {error}"),
740 )
741 })?;
742 if sha256_hex(&backup_bytes) != receipt.source_sha256 {
743 return Err(refused(
744 Refusal::BackupMismatch,
745 "config migration backup does not match its receipt",
746 ));
747 }
748 if has_next {
749 let fix = settle_config_migration_next_receipt(
750 path,
751 backup,
752 next_path,
753 &receipt,
754 ¤t_sha256,
755 )?;
756 if let JournalFix::PromoteNextReceipt(next) = &fix {
757 receipt.output_sha256.clone_from(&next.output_sha256);
758 }
759 fixes.push(fix);
760 }
761 if current_sha256 == receipt.source_sha256 {
762 MigrationJournal::Interrupted(receipt)
763 } else if current_sha256 == receipt.output_sha256 {
764 MigrationJournal::Completed(receipt)
765 } else {
766 MigrationJournal::Superseded(receipt)
767 }
768 }
769 };
770 if has_next && !(has_backup && has_receipt) {
772 fixes.push(JournalFix::RemoveNextReceipt);
773 }
774 Ok((journal, fixes))
775}
776
777pub(super) fn apply_config_migration_journal_fixes(
778 backup: &Path,
779 receipt_path: &Path,
780 next_path: &Path,
781 fixes: Vec<JournalFix>,
782) -> Result<(), String> {
783 for fix in fixes {
784 match fix {
785 JournalFix::RemoveBackup => std::fs::remove_file(backup)
786 .map_err(|error| format!("remove recoverable config migration backup: {error}"))?,
787 JournalFix::RemoveReceipt => std::fs::remove_file(receipt_path)
788 .map_err(|error| format!("remove completed config migration receipt: {error}"))?,
789 JournalFix::RemoveNextReceipt => std::fs::remove_file(next_path).map_err(|error| {
790 format!("remove stale config migration receipt update: {error}")
791 })?,
792 JournalFix::PromoteNextReceipt(next) => {
793 promote_config_migration_next_receipt(receipt_path, next_path, &next)?;
794 }
795 }
796 }
797 Ok(())
798}
799
800pub fn config_migration_journal_state(path: &Path) -> ConfigMigrationJournalState {
803 let Some(resolved) = resolve_write_target(path)
804 .ok()
805 .and_then(|target| canonicalize_existing_prefix(&target).ok())
806 else {
807 return ConfigMigrationJournalState::Absent;
808 };
809 let (Ok((backup, receipt_path)), Ok(next_path)) = (
810 config_migration_paths(&resolved),
811 config_migration_next_receipt_path(&resolved),
812 ) else {
813 return ConfigMigrationJournalState::Absent;
814 };
815 let current = std::fs::read(&resolved).unwrap_or_default();
816 match classify_config_migration_journal(&resolved, &backup, &receipt_path, &next_path, ¤t)
817 {
818 Ok((MigrationJournal::Absent, _)) => ConfigMigrationJournalState::Absent,
819 Ok((MigrationJournal::Interrupted(_), _)) => ConfigMigrationJournalState::Interrupted,
820 Ok((MigrationJournal::Completed(_), _)) => ConfigMigrationJournalState::Completed,
821 Ok((MigrationJournal::Superseded(_), _)) => ConfigMigrationJournalState::Superseded,
822 Err(refusal) => ConfigMigrationJournalState::Refused(refusal.refusal),
823 }
824}
825
826pub fn config_migration_journal_files(path: &Path) -> Option<(PathBuf, PathBuf)> {
829 let resolved = config_migration_target(path)?;
830 config_migration_paths(&resolved).ok()
831}
832
833pub fn config_migration_next_receipt_file(path: &Path) -> Option<PathBuf> {
835 config_migration_next_receipt_path(&config_migration_target(path)?).ok()
836}
837
838pub fn config_migration_target(path: &Path) -> Option<PathBuf> {
840 canonicalize_existing_prefix(&resolve_write_target(path).ok()?).ok()
841}
842
843pub fn rollback_config_migration(path: &Path) -> Result<(), String> {
852 let resolved = canonicalize_existing_prefix(&resolve_write_target(path)?)?;
853 let path = resolved.as_path();
854 let _lock = lock_config_migration(path)?;
855 let (backup, receipt_path) = config_migration_paths(path)?;
856 let next_path = config_migration_next_receipt_path(path)?;
857 if std::fs::symlink_metadata(&receipt_path)
858 .map_err(|error| format!("stat config migration receipt: {error}"))?
859 .file_type()
860 .is_symlink()
861 {
862 return Err("config migration rollback refuses symlinks".to_string());
863 }
864 let (mut receipt, _) = read_config_migration_receipt(path, &backup, &receipt_path)?;
865 if std::fs::symlink_metadata(path)
866 .map_err(|error| format!("stat config: {error}"))?
867 .file_type()
868 .is_symlink()
869 || [&backup, &next_path].iter().any(|artifact| {
870 std::fs::symlink_metadata(artifact)
871 .is_ok_and(|metadata| metadata.file_type().is_symlink())
872 })
873 {
874 return Err("config migration rollback refuses symlinks".to_string());
875 }
876 let current = std::fs::read(path).map_err(|error| format!("read config: {error}"))?;
877 if std::fs::symlink_metadata(&next_path).is_ok() {
878 let fix = settle_config_migration_next_receipt(
879 path,
880 &backup,
881 &next_path,
882 &receipt,
883 &sha256_hex(¤t),
884 )
885 .map_err(|refusal| refusal.detail)?;
886 if let JournalFix::PromoteNextReceipt(next) = &fix {
887 receipt.output_sha256.clone_from(&next.output_sha256);
888 }
889 apply_config_migration_journal_fixes(&backup, &receipt_path, &next_path, vec![fix])?;
890 }
891 if !backup.exists() {
892 if sha256_hex(¤t) != receipt.source_sha256 {
893 return Err("config migration backup missing before rollback completed".to_string());
894 }
895 std::fs::remove_file(&receipt_path)
896 .map_err(|error| format!("remove completed config migration receipt: {error}"))?;
897 return Ok(());
898 }
899 let source =
900 std::fs::read(&backup).map_err(|error| format!("read config migration backup: {error}"))?;
901 let current_sha256 = sha256_hex(¤t);
902 if sha256_hex(&source) != receipt.source_sha256 {
903 return Err("config migration state changed; refusing rollback".to_string());
904 }
905 if current_sha256 == receipt.output_sha256 {
906 let source = std::str::from_utf8(&source).map_err(|_| "config backup is not UTF-8")?;
907 write_atomic(path, source)?;
908 } else if current_sha256 != receipt.source_sha256 {
909 return Err("config migration state changed; refusing rollback".to_string());
910 }
911 std::fs::remove_file(&backup)
912 .map_err(|error| format!("remove config migration backup: {error}"))?;
913 std::fs::remove_file(&receipt_path)
914 .map_err(|error| format!("remove config migration receipt: {error}"))?;
915 #[cfg(unix)]
916 if let Some(parent) = path.parent() {
917 std::fs::File::open(parent)
918 .and_then(|directory| directory.sync_all())
919 .map_err(|error| format!("sync config directory after rollback: {error}"))?;
920 }
921 Ok(())
922}