Skip to main content

lean_ctx/config_io/
migration.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The v4 config migration journal (#2175): receipt, backup, archive,
3//! resume/extend/supersede classification and rollback.
4
5use 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
48/// Exclusive hold on the `.v4-migration.lock` for one config file, bound to
49/// that file's canonical resolved path.
50///
51/// Existing lock, existing order: this reuses `lock_config_migration` and adds
52/// no second authority. What LR-TEL-01 changed is the *span* — a holder can
53/// keep the lock across a whole read-modify-write instead of only across the
54/// byte write, so a load → mutate → save sequence is atomic against other
55/// holders.
56///
57/// Scope, stated precisely: this guards holders of *this* lock file. It is not
58/// a universal config-write authority — `write_toml_document` /
59/// `load_toml_document` callers (`core::tool_profiles`,
60/// `core::update_scheduler`) still read unlocked and take the lock only for
61/// their write, and the same `write_atomic_with_backup` writer is used for many
62/// non-config files.
63///
64/// Both fields are private on purpose: the lock cannot be separated from the
65/// path it was taken for, so a guarded write cannot be aimed at a different
66/// file than the one being excluded. The lock is **not reentrant** (an
67/// exclusive `flock` on a fresh descriptor), so a holder must use the guard's
68/// methods rather than the public, self-locking writers.
69pub(crate) struct ConfigWriteGuard {
70    /// Released on drop. Never handed out.
71    _lock: crate::core::migration_lock::MigrationLock,
72    /// Canonical resolved target, computed the same way the migration writer
73    /// computes it, so both bind to one identity for a symlinked config.
74    path: PathBuf,
75}
76
77impl ConfigWriteGuard {
78    /// Takes the lock for `path` and binds to its canonical resolved target.
79    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    /// The canonical resolved path this guard excludes writes to. Read and
89    /// write inside the critical section must both use this.
90    pub(crate) fn path(&self) -> &Path {
91        &self.path
92    }
93
94    /// Minimal-preserving config write, bound to this guard's path.
95    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
104/// The lock file for one config target. It lives in the lean-ctx state dir
105/// (`<data>/locks/`), keyed by the canonical target path, never beside the
106/// target: the same writer serves project files (`CLAUDE.md`, `.mcp.json` …),
107/// and a sibling lock left a `*.v4-migration.lock` in every user's git status.
108/// Only when no data dir resolves does it fall back to the sibling file.
109pub(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
124/// Atomically prepares a recoverable v3→v4 config journal before replacing the
125/// config. A pre-existing journal is never silently overwritten: an
126/// interrupted one is resumed, a completed one is extended (its backup stays
127/// the rollback target), a superseded one is archived byte-exact under a
128/// content-addressed name, and an unverifiable one refuses (#2175).
129///
130/// The empty `<config>.v4-migration.lock` that 3.11.x left beside the config
131/// is deliberately left alone. A lock file cannot be deleted safely while an
132/// older process may still open it: on Unix that process would lock the
133/// unlinked inode while the next one creates and locks a fresh file, splitting
134/// mutual exclusion. The empty file is harmless; the #2175 symptom was the
135/// warning, not the file.
136pub 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            // The config still holds the journaled source and the backup holds
166            // the same bytes: finish the replace. The receipt is updated first;
167            // a crash or a refused replace leaves the config at the source, so
168            // the next run classifies this same resumable state.
169            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            // Normal saves changed the config after the journaled migration,
188            // so that journal can no longer roll back (rollback refuses a
189            // changed post-state). Archive it under its content address and
190            // open a fresh journal for the current bytes below.
191            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
223/// Extends a completed journal to a later migration step in two phases, so
224/// the live receipt never names bytes the config does not hold (#2175 review):
225///
226/// 1. the extended receipt is written beside the live one as
227///    `<config>.v4-migration.next.json`;
228/// 2. the config is replaced;
229/// 3. the extended receipt replaces the live one and the sidecar goes.
230///
231/// A refused replace removes the sidecar again. A crash at any point is
232/// settled by the next run (or by rollback): before step 2 the config still
233/// equals the live receipt's output and the sidecar is dropped; after it the
234/// config equals the sidecar's output and the sidecar is promoted. Either way
235/// the journal stays Completed and its backup — the original pre-v4 bytes —
236/// stays the rollback target, instead of looking Superseded and being archived.
237pub(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            // The next run drops it: the config still equals the live output.
256            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        // The config is migrated; the next run or rollback promotes the sidecar.
263        tracing::debug!("config migration receipt update left for the next run: {error}");
264    }
265    Ok(())
266}
267
268/// Test-only fault points inside the two-phase journal extension.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub(super) enum JournalFault {
271    /// Process dies after the sidecar receipt was written.
272    CrashAfterNextReceipt,
273    /// The config replace is refused (Windows: file held without share-delete).
274    FailConfigReplace,
275    /// Process dies after the config replace, before the receipt commit.
276    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
303/// Commits the sidecar receipt as the live receipt and removes the sidecar.
304pub(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
314/// The sidecar of a journal extension in progress (see
315/// [`extend_completed_config_migration`]).
316pub(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
324/// How an existing `.v4-migration` journal relates to the current config bytes.
325///
326/// A journal is the pair `<config>.v4-migration.bak` (pre-migration bytes) and
327/// `<config>.v4-migration.json` (receipt with the SHA-256 of those bytes and of
328/// the migrated output). It deliberately outlives a successful migration so
329/// `lean-ctx migrate config --rollback` can restore the pre-v4 config. A later
330/// release that migrates the same file again (e.g. 3.12 retiring the
331/// `[cloud]` table that 3.11's migration wrote, #2175) must therefore classify
332/// the journal instead of refusing forever.
333pub(super) enum MigrationJournal {
334    /// No journal: the migration opens a fresh one.
335    Absent,
336    /// Backup == source and the config still holds the source: a migration
337    /// stopped after writing its journal but before replacing the config (a
338    /// crash, or a replace refused because another process held the file).
339    Interrupted(ConfigMigrationReceipt),
340    /// Backup == source and the config is exactly the journaled output.
341    Completed(ConfigMigrationReceipt),
342    /// Backup == source, but normal saves changed the config since; the
343    /// journal can no longer roll back and is archived before a new one opens.
344    Superseded(ConfigMigrationReceipt),
345}
346
347/// Why a journal cannot be continued automatically. Stable: the labels feed
348/// the once-per-state warning fingerprint and `lean-ctx doctor`, so they
349/// never carry volatile text such as temporary file names.
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub enum ConfigMigrationJournalRefusal {
352    /// A journal file is a symbolic link.
353    Symlink,
354    /// A journal file exists but cannot be read.
355    Unreadable,
356    /// The receipt does not parse, has an unknown version or names other files.
357    ReceiptUnverifiable,
358    /// The backup bytes do not match the receipt's source hash.
359    BackupMismatch,
360    /// A backup without receipt that matches neither the config nor an archive.
361    OrphanBackup,
362    /// A receipt without backup whose source is not the current config.
363    OrphanReceipt,
364    /// An unfinished receipt update that cannot be verified while the config
365    /// matches neither recorded state.
366    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/// Read-only view of the journal beside a config, for diagnostics.
384#[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    /// Whether `lean-ctx migrate config --rollback` can act in this state.
395    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
415/// Recoverable leftovers the classification found; applied under the lock.
416pub(super) enum JournalFix {
417    RemoveBackup,
418    RemoveReceipt,
419    RemoveNextReceipt,
420    PromoteNextReceipt(ConfigMigrationReceipt),
421}
422
423/// Reads and validates the receipt beside `path`; the bytes are returned for
424/// byte-exact archival.
425pub(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
450/// An archived journal backup beside the config.
451pub(super) struct ArchivedBackup {
452    pub(super) path: PathBuf,
453    pub(super) key: String,
454    /// The first journal ever archived beside this config: its backup is the
455    /// config from before the first recorded migration (the pre-v4 original).
456    pub(super) original: bool,
457}
458
459/// Archived backups beside `backup`, sorted by name (deterministic).
460pub(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
493/// Content-addressed archive names for a retired journal. The backup is keyed
494/// by its own SHA-256 (= the receipt's `source_sha256`), the receipt by that
495/// key plus its own digest, so re-archiving the same journal is idempotent and
496/// two different journals never collide. The first journal archived beside a
497/// config carries an `original.` tag: it holds the pre-v4 original. Names
498/// carry no time or counter.
499pub(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
523/// Creates `target` with `bytes`, or accepts an existing byte-identical file.
524pub(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
543/// Moves a superseded journal aside, byte-exact, under its content address,
544/// and returns the archived backup. Order matters for crash safety: the
545/// archive copies exist before anything is removed, and the receipt goes
546/// before the backup, so an interruption leaves either the full journal or an
547/// orphan backup that [`classify_config_migration_journal`] recognizes as
548/// already archived. Re-archiving after a crash reuses the `original.` tag the
549/// first attempt chose, because the archive with this key already exists.
550pub(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
576/// One-time notice when a superseded journal was archived; names the archive
577/// that keeps the original pre-v4 config.
578pub(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
596/// The sentence every journal message uses to name the original pre-v4 config.
597pub 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
612/// The archive that keeps the config from before the first recorded
613/// migration (the pre-v4 original), when a journal was ever archived beside
614/// `path`. `None` while the live journal is still the first one: then its
615/// backup is the original and `migrate config --rollback` restores it.
616pub 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
622/// Atomically replaces the live receipt (temp file + rename).
623pub(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
630/// Decides what to do with a `.next.json` sidecar left by an interrupted
631/// journal extension, given the live receipt and the current config hash.
632pub(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        // The replace happened, the receipt commit did not.
645        Some(next)
646            if current_sha256 == next.output_sha256 && current_sha256 != receipt.output_sha256 =>
647        {
648            Ok(JournalFix::PromoteNextReceipt(next))
649        }
650        // The replace never happened (or later saves changed the config;
651        // then the journal is superseded either way).
652        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
665/// Classifies the journal beside `path` against the current config bytes,
666/// without touching the disk. Recoverable leftovers of an interrupted
667/// rollback, archival or extension come back as fixes for the caller to apply
668/// under the lock; anything that cannot be verified is a refusal (the
669/// migration pauses).
670pub(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            // Either the pre-journal step of a migration (backup == config) or
701            // the last step of an archival (the archive holds these bytes).
702            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            // A rollback that restored the config and removed the backup, then
721            // stopped before removing the receipt.
722            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                    &current_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    // A sidecar without a live journal holds only a hash, never config bytes.
771    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
800/// Read-only classification of the journal beside `path` (no lock, no
801/// cleanup), for warnings and `lean-ctx doctor`.
802pub 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, &current)
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
826/// Paths of the live journal beside `path` (for messages), after resolving
827/// the config's write target the same way the writer does.
828pub 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
833/// The sidecar of an unfinished journal extension beside `path`.
834pub fn config_migration_next_receipt_file(path: &Path) -> Option<PathBuf> {
835    config_migration_next_receipt_path(&config_migration_target(path)?).ok()
836}
837
838/// The canonical write target the migration journal is bound to.
839pub fn config_migration_target(path: &Path) -> Option<PathBuf> {
840    canonicalize_existing_prefix(&resolve_write_target(path).ok()?).ok()
841}
842
843/// Restores exact pre-migration bytes only while both journal and current
844/// post-state still match their recorded SHA-256 identities. After a journal
845/// was extended by a later release's migration step (#2175) this still
846/// restores the original pre-v4 bytes; an unfinished extension
847/// (`.v4-migration.next.json`) is settled first. Archived journals
848/// (`*.v4-migration.[original.]<sha16>.{bak,json}`) are kept for manual
849/// recovery only; they live beside the config, so `lean-ctx uninstall`
850/// removes them together with the config directory and the live journal.
851pub 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(&current),
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(&current) != 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(&current);
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}