Skip to main content

clawft_kernel/
chain_migrate.rs

1//! `weaver migrate user-chain`: copy the legacy `~/.clawft` chain to the
2//! Phase 1 user chain location (`~/.weftos/chain`), verified, source untouched.
3//!
4//! Safety sequence (ADR-103 Phase 1, plan package E):
5//!
6//! 1. refuse when the destination already holds something that is not this
7//!    migration (identical or already-migrated is a no-op);
8//! 2. refuse when a kernel holds the source `chain.lock`, or when the
9//!    mixed-version guard says a lock-unaware writer touched it recently;
10//! 3. take the source `chain.lock` ourselves for the whole run;
11//! 4. copy to a temp dir beside the destination, fsync every file;
12//! 5. re-hash the source: any change since the plan aborts;
13//! 6. load the copy with the kernel's own restore path, check byte hashes,
14//!    event count, head hash, integrity and signature against the source;
15//! 7. write `MIGRATED_FROM.json`, atomically rename into place, then write
16//!    the marker beside the source.
17//!
18//! Any failure before step 7's rename removes the temp dir. The source chain
19//! files are never modified, moved or deleted; the only writes in the source
20//! directory are the transient `chain.lock` and the final marker.
21
22use std::io::Write;
23#[cfg(unix)]
24use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
25use std::path::{Path, PathBuf};
26use std::time::SystemTime;
27
28use clawft_types::runtime_paths::{
29    LEGACY_MIGRATED_MARKER, MIGRATED_FROM_FILE, RuntimePaths,
30};
31use ed25519_dalek::SigningKey;
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34
35use crate::chain::ChainManager;
36use crate::chain_storage::{ChainLock, lock_unaware_writer_age};
37
38/// Chain files, relative to a runtime root, in copy order (key last).
39const SET: [&str; 5] = [
40    "chain.rvf",
41    "chain.json",
42    "chain.tree.json",
43    "chain/anchors.jsonl",
44    "chain.key",
45];
46
47/// Why a migration did not happen.
48#[derive(Debug)]
49pub enum MigrateError {
50    /// A safety rule said no; nothing was changed.
51    Refused(String),
52    /// The source has no chain.
53    NothingToMigrate(String),
54    /// The copy did not verify; it was removed.
55    VerifyFailed(String),
56    /// An I/O step failed; partial output was removed.
57    Io(String),
58}
59
60impl std::fmt::Display for MigrateError {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Self::Refused(m) => write!(f, "refused: {m}"),
64            Self::NothingToMigrate(m) => write!(f, "nothing to migrate: {m}"),
65            Self::VerifyFailed(m) => write!(f, "verification failed (copy removed): {m}"),
66            Self::Io(m) => write!(f, "{m}"),
67        }
68    }
69}
70
71impl std::error::Error for MigrateError {}
72
73fn io<T>(ctx: &str, r: std::io::Result<T>) -> Result<T, MigrateError> {
74    r.map_err(|e| MigrateError::Io(format!("{ctx}: {e}")))
75}
76
77/// Inputs. Paths are injected; the CLI resolves `$HOME`.
78pub struct MigrateOptions<'a> {
79    /// Legacy runtime root holding `chain.rvf` / `chain.json` (`~/.clawft`).
80    pub from: &'a Path,
81    /// Destination chain directory (`~/.weftos/chain`).
82    pub to: &'a Path,
83    /// Plan and verify the source only; write nothing.
84    pub dry_run: bool,
85    /// Clock for the mixed-version guard.
86    pub now: SystemTime,
87    /// Recorded in the marker (`weaver` version).
88    pub tool_version: &'a str,
89    /// Migrate even when `chain.key` is missing or the RVF signature cannot
90    /// be verified against it.
91    pub allow_unsigned: bool,
92}
93
94/// One file in the set.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct FileEntry {
97    /// Path relative to the chain root.
98    pub name: String,
99    /// Size in bytes.
100    pub bytes: u64,
101    /// Hex SHA-256 of the contents.
102    pub sha256: String,
103}
104
105/// What restoring the chain yields.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Head {
108    /// Events in the chain.
109    pub events: usize,
110    /// Head sequence number.
111    pub sequence: u64,
112    /// Hex head hash.
113    pub hash: String,
114    /// `verified`, or why the RVF signature could not be checked.
115    pub signature: String,
116}
117
118/// What a migration would copy, from the source.
119#[derive(Debug, Clone)]
120pub struct Plan {
121    /// Source root.
122    pub from: PathBuf,
123    /// Destination chain directory.
124    pub to: PathBuf,
125    /// Files that exist in the source.
126    pub files: Vec<FileEntry>,
127    /// Source head.
128    pub head: Head,
129}
130
131/// `MIGRATED_FROM.json`.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct Marker {
134    /// Source root.
135    pub source: String,
136    /// Copied files with hashes.
137    pub files: Vec<FileEntry>,
138    /// Head hash at migration.
139    pub head_hash: String,
140    /// Head sequence at migration.
141    pub sequence: u64,
142    /// Event count at migration.
143    pub events: usize,
144    /// RVF signature state at migration.
145    pub signature: String,
146    /// RFC 3339 time.
147    pub at: String,
148    /// `weaver` version.
149    pub weaver_version: String,
150}
151
152/// Result of a run.
153#[derive(Debug)]
154pub enum Outcome {
155    /// `--dry-run`: the plan, nothing written.
156    DryRun(Plan),
157    /// Copied, verified, in place.
158    Migrated(Plan),
159    /// The destination already holds this migration (or identical bytes).
160    /// The flag is true when this run had to (re)write the source marker.
161    AlreadyMigrated(Option<Marker>, bool),
162}
163
164fn sha_hex(p: &Path) -> std::io::Result<(u64, String)> {
165    let mut f = std::fs::File::open(p)?;
166    let mut h = Sha256::new();
167    let n = std::io::copy(&mut f, &mut h)?;
168    Ok((n, h.finalize().iter().map(|b| format!("{b:02x}")).collect()))
169}
170
171fn inventory(root: &Path) -> Result<Vec<FileEntry>, MigrateError> {
172    let mut out = Vec::new();
173    for name in SET {
174        let p = root.join(name);
175        if p.is_file() {
176            let (bytes, sha256) = io(&format!("hash {}", p.display()), sha_hex(&p))?;
177            out.push(FileEntry {
178                name: name.into(),
179                bytes,
180                sha256,
181            });
182        }
183    }
184    Ok(out)
185}
186
187/// Restore the chain the way boot does (RVF preferred, else JSON), verify
188/// integrity and, when both exist, the RVF signature against `chain.key`.
189fn load_head(root: &Path) -> Result<Head, MigrateError> {
190    let p = RuntimePaths::at(root);
191    let rvf = p.chain_rvf();
192    let loaded = if rvf.exists() {
193        ChainManager::load_from_rvf(&rvf, 1000)
194    } else {
195        ChainManager::load_from_file(&p.chain_checkpoint(), 1000)
196    };
197    let mgr = loaded.map_err(|e| {
198        MigrateError::VerifyFailed(format!("chain in {} does not restore: {e}", root.display()))
199    })?;
200    let v = mgr.verify_integrity();
201    if !v.valid {
202        return Err(MigrateError::VerifyFailed(format!(
203            "chain in {} fails integrity ({} errors, first: {})",
204            root.display(),
205            v.errors.len(),
206            v.errors.first().map_or("", String::as_str)
207        )));
208    }
209    Ok(Head {
210        events: mgr.len(),
211        sequence: mgr.sequence(),
212        hash: mgr.head_hash().iter().map(|b| format!("{b:02x}")).collect(),
213        signature: signature_state(&p)?,
214    })
215}
216
217fn signature_state(p: &RuntimePaths) -> Result<String, MigrateError> {
218    let (rvf, key) = (p.chain_rvf(), p.chain_key());
219    if !rvf.exists() {
220        return Ok("no-rvf".into());
221    }
222    if !key.exists() {
223        return Ok("no-key".into());
224    }
225    let bytes = io("read chain.key", std::fs::read(&key))?;
226    let seed: [u8; 32] = bytes.try_into().map_err(|_| {
227        MigrateError::VerifyFailed("chain.key is not 32 bytes".into())
228    })?;
229    let vk = SigningKey::from_bytes(&seed).verifying_key();
230    match ChainManager::verify_rvf_signature(&rvf, &vk) {
231        Ok(true) => Ok("verified".into()),
232        Ok(false) => Err(MigrateError::VerifyFailed(
233            "chain.rvf signature does not verify against chain.key".into(),
234        )),
235        Err(e) => Ok(format!("unverifiable ({e})")),
236    }
237}
238
239/// The destination chain restores and still contains the marker's head event
240/// (same sequence and hash), so a foreign chain with a higher sequence is not
241/// mistaken for this migration moving forward.
242fn holds_marker_head(to: &Path, m: &Marker) -> bool {
243    let p = RuntimePaths::at(to);
244    let rvf = p.chain_rvf();
245    let loaded = if rvf.exists() {
246        ChainManager::load_from_rvf(&rvf, 1000)
247    } else {
248        ChainManager::load_from_file(&p.chain_checkpoint(), 1000)
249    };
250    let Ok(mgr) = loaded else { return false };
251    mgr.verify_integrity().valid
252        && mgr.tail(mgr.len()).iter().any(|e| {
253            e.sequence == m.sequence
254                && e.hash.iter().map(|b| format!("{b:02x}")).collect::<String>() == m.head_hash
255        })
256}
257
258fn check_destination(
259    from: &Path,
260    to: &Path,
261    src: &[FileEntry],
262) -> Result<Option<Outcome>, MigrateError> {
263    if !to.exists() {
264        return Ok(None);
265    }
266    let marker_path = to.join(MIGRATED_FROM_FILE);
267    if marker_path.is_file() {
268        let text = io("read marker", std::fs::read_to_string(&marker_path))?;
269        let m: Marker = serde_json::from_str(&text).map_err(|e| {
270            MigrateError::Refused(format!("{} is unreadable: {e}", marker_path.display()))
271        })?;
272        let canon = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
273        if canon(Path::new(&m.source)) != canon(from) {
274            return Err(MigrateError::Refused(format!(
275                "{} already holds a chain migrated from {}",
276                to.display(),
277                m.source
278            )));
279        }
280        if m.files != src {
281            return Err(MigrateError::Refused(format!(
282                "the source chain changed after it was migrated to {} (something wrote to \
283                 {} since); the two chains have diverged. Resolve by hand: nothing was changed",
284                to.display(),
285                from.display()
286            )));
287        }
288        // Do not trust the marker alone: the destination must still hold the
289        // recorded bytes, or a chain that restores and has only moved forward.
290        if inventory(to)? != m.files {
291            let ok = holds_marker_head(to, &m);
292            if !ok {
293                return Err(MigrateError::Refused(format!(
294                    "{} has a migration marker but its chain no longer matches it (files \
295                     changed, missing or not restorable); not trusting it",
296                    to.display()
297                )));
298            }
299        }
300        return Ok(Some(Outcome::AlreadyMigrated(Some(m), false)));
301    }
302    let dst = inventory(to)?;
303    if dst.is_empty() {
304        let empty = io("list destination", std::fs::read_dir(to))?.next().is_none();
305        return if empty {
306            Ok(None)
307        } else {
308            Err(MigrateError::Refused(format!(
309                "{} exists and is not empty (no chain in it)",
310                to.display()
311            )))
312        };
313    }
314    if dst == src {
315        return Ok(Some(Outcome::AlreadyMigrated(None, false)));
316    }
317    Err(MigrateError::Refused(format!(
318        "{} already holds a chain that differs from {}; it will not be overwritten",
319        to.display(),
320        from.display()
321    )))
322}
323
324fn guards(from: &Path, now: SystemTime) -> Result<(), MigrateError> {
325    let ckpt = RuntimePaths::at(from).chain_checkpoint();
326    ChainLock::probe(&ckpt).map_err(MigrateError::Refused)?;
327    if let Some(age) = lock_unaware_writer_age(&ckpt, now) {
328        return Err(MigrateError::Refused(format!(
329            "the chain in {} was modified {age}s ago and has no chain.lock: an older kernel \
330             may still be writing it. Stop every weaver daemon and retry",
331            from.display()
332        )));
333    }
334    Ok(())
335}
336
337/// Migrate. See the module docs for the sequence.
338pub fn migrate_user_chain(opts: &MigrateOptions<'_>) -> Result<Outcome, MigrateError> {
339    migrate_with_hook(opts, &mut |_, _| {})
340}
341
342/// [`migrate_user_chain`] with a hook called after the copy, before the
343/// source re-check and verification, with `(source, temp copy)`. Tests use it
344/// to corrupt the copy or mutate the source.
345#[doc(hidden)]
346pub fn migrate_with_hook(
347    opts: &MigrateOptions<'_>,
348    after_copy: &mut dyn FnMut(&Path, &Path),
349) -> Result<Outcome, MigrateError> {
350    let (from, to) = (opts.from, opts.to);
351    let files = inventory(from)?;
352    if !files.iter().any(|f| f.name == "chain.rvf" || f.name == "chain.json") {
353        return Err(MigrateError::NothingToMigrate(format!(
354            "no chain.rvf or chain.json in {}",
355            from.display()
356        )));
357    }
358    if let Some(done) = check_destination(from, to, &files)? {
359        return finish_already(opts, done);
360    }
361    guards(from, opts.now)?;
362    check_key(from, &files, opts.allow_unsigned)?;
363    let head = load_head(from)?;
364    if !opts.allow_unsigned && head.signature != "verified" && head.signature != "no-rvf" {
365        return Err(MigrateError::Refused(format!(
366            "the chain signature cannot be verified against chain.key ({}); pass \
367             --allow-unsigned to migrate it anyway",
368            head.signature
369        )));
370    }
371    let plan = Plan {
372        from: from.into(),
373        to: to.into(),
374        files,
375        head,
376    };
377    if opts.dry_run {
378        return Ok(Outcome::DryRun(plan));
379    }
380    execute(opts, plan, after_copy)
381}
382
383fn check_key(from: &Path, files: &[FileEntry], allow: bool) -> Result<(), MigrateError> {
384    let key = from.join("chain.key");
385    let regular = std::fs::symlink_metadata(&key).is_ok_and(|m| m.is_file());
386    if allow || (regular && files.iter().any(|f| f.name == "chain.key")) {
387        return Ok(());
388    }
389    Err(MigrateError::Refused(format!(
390        "{} is missing or not a regular file: the migrated chain would not verify against its \
391         key. Pass --allow-unsigned to migrate it anyway",
392        key.display()
393    )))
394}
395
396/// Already migrated: make sure the fork-guard marker beside the source exists
397/// (a crash between the rename and the marker write leaves it missing).
398fn finish_already(opts: &MigrateOptions<'_>, done: Outcome) -> Result<Outcome, MigrateError> {
399    let Outcome::AlreadyMigrated(marker, _) = done else {
400        return Ok(done);
401    };
402    let present = opts.from.join(LEGACY_MIGRATED_MARKER).is_file();
403    if opts.dry_run || present {
404        return Ok(Outcome::AlreadyMigrated(marker, false));
405    }
406    let (seq, hash) = match &marker {
407        Some(m) => (m.sequence, m.head_hash.clone()),
408        None => {
409            let h = load_head(opts.from)?;
410            (h.sequence, h.hash)
411        }
412    };
413    write_source_marker(opts.from, opts.to, seq, &hash)?;
414    Ok(Outcome::AlreadyMigrated(marker, true))
415}
416
417fn execute(
418    opts: &MigrateOptions<'_>,
419    plan: Plan,
420    after_copy: &mut dyn FnMut(&Path, &Path),
421) -> Result<Outcome, MigrateError> {
422    let (from, to) = (opts.from, opts.to);
423    let ckpt = RuntimePaths::at(from).chain_checkpoint();
424    let lock_existed = ChainLock::lock_path(&ckpt).exists();
425    let lock = ChainLock::acquire(&ckpt).map_err(MigrateError::Refused)?;
426    let result = copy_verify_place(opts, &plan, after_copy);
427    let marked = match result {
428        Ok(()) => write_source_marker(from, to, plan.head.sequence, &plan.head.hash),
429        Err(e) => Err(e),
430    };
431    if !lock_existed {
432        // Leave the source as found: our lock must not make a never-locked
433        // chain look lock-aware to the first-adoption guard. Unlink while
434        // still holding the flock, so no kernel can lock the doomed inode.
435        let _ = std::fs::remove_file(ChainLock::lock_path(&ckpt));
436    }
437    drop(lock);
438    marked?;
439    Ok(Outcome::Migrated(plan))
440}
441
442fn copy_verify_place(
443    opts: &MigrateOptions<'_>,
444    plan: &Plan,
445    after_copy: &mut dyn FnMut(&Path, &Path),
446) -> Result<(), MigrateError> {
447    let (from, to) = (opts.from, opts.to);
448    // The lock is ours: re-plan from the locked state.
449    if inventory(from)? != plan.files {
450        return Err(MigrateError::Refused(
451            "the source chain changed while taking the lock; retry".into(),
452        ));
453    }
454    let parent = to.parent().unwrap_or(Path::new("."));
455    io("create destination parent", mkdir_private(parent))?;
456    let name = to.file_name().map_or("chain".into(), |n| n.to_string_lossy());
457    let tmp = parent.join(format!(".{name}.migrating-{}", std::process::id()));
458    sweep_stale(parent, &format!(".{name}.migrating-"));
459    let _ = std::fs::remove_dir_all(&tmp);
460    let r = build_temp(opts, plan, &tmp, after_copy);
461    if r.is_err() {
462        let _ = std::fs::remove_dir_all(&tmp);
463        return r;
464    }
465    // An empty pre-existing destination dir is replaced; anything else was
466    // refused earlier, and rename fails rather than overwrite.
467    let _ = std::fs::remove_dir(to);
468    let placed = io("rename into place", std::fs::rename(&tmp, to));
469    if placed.is_err() {
470        let _ = std::fs::remove_dir_all(&tmp);
471    }
472    placed?;
473    sync_dir(parent);
474    Ok(())
475}
476
477fn build_temp(
478    opts: &MigrateOptions<'_>,
479    plan: &Plan,
480    tmp: &Path,
481    after_copy: &mut dyn FnMut(&Path, &Path),
482) -> Result<(), MigrateError> {
483    io("create temp dir", mkdir_private(&tmp.join("chain")))?;
484    for f in &plan.files {
485        copy_fsync(&opts.from.join(&f.name), &tmp.join(&f.name))?;
486    }
487    sync_dir(&tmp.join("chain"));
488    sync_dir(tmp);
489    after_copy(opts.from, tmp);
490    // Source unchanged since the plan?
491    if inventory(opts.from)? != plan.files {
492        return Err(MigrateError::Refused(
493            "the source chain changed during the copy; nothing was migrated".into(),
494        ));
495    }
496    // Bytes identical, then restore + compare head/integrity/signature.
497    if inventory(tmp)? != plan.files {
498        return Err(MigrateError::VerifyFailed(
499            "copied files differ from the source (hash mismatch)".into(),
500        ));
501    }
502    let copy = load_head(tmp)?;
503    if copy != plan.head {
504        return Err(MigrateError::VerifyFailed(format!(
505            "head mismatch: source {:?}, copy {:?}",
506            plan.head, copy
507        )));
508    }
509    let marker = Marker {
510        source: opts.from.to_string_lossy().into_owned(),
511        files: plan.files.clone(),
512        head_hash: plan.head.hash.clone(),
513        sequence: plan.head.sequence,
514        events: plan.head.events,
515        signature: plan.head.signature.clone(),
516        at: chrono::Utc::now().to_rfc3339(),
517        weaver_version: opts.tool_version.into(),
518    };
519    let json = serde_json::to_vec_pretty(&marker)
520        .map_err(|e| MigrateError::Io(format!("encode marker: {e}")))?;
521    let mp = tmp.join(MIGRATED_FROM_FILE);
522    let mut mf = io("write marker", private_file(&mp))?;
523    io("write marker", mf.write_all(&json))?;
524    io("sync marker", mf.sync_all())?;
525    sync_dir(tmp);
526    Ok(())
527}
528
529/// New file, mode 0600 on unix (chain.key must never be group/world readable).
530fn private_file(path: &Path) -> std::io::Result<std::fs::File> {
531    let mut o = std::fs::OpenOptions::new();
532    o.write(true).create_new(true);
533    #[cfg(unix)]
534    o.mode(0o600);
535    o.open(path)
536}
537
538/// `create_dir_all` with mode 0700 on unix for every directory it creates.
539fn mkdir_private(dir: &Path) -> std::io::Result<()> {
540    let mut b = std::fs::DirBuilder::new();
541    b.recursive(true);
542    #[cfg(unix)]
543    b.mode(0o700);
544    b.create(dir)
545}
546
547/// Remove leftover temp dirs from an earlier crashed run (they may hold a
548/// copy of `chain.key`). Called with the source lock held.
549fn sweep_stale(parent: &Path, prefix: &str) {
550    let Ok(rd) = std::fs::read_dir(parent) else {
551        return;
552    };
553    for e in rd.flatten() {
554        let name = e.file_name().to_string_lossy().into_owned();
555        let Some(pid) = name.strip_prefix(prefix) else {
556            continue;
557        };
558        // A live owner (not us) may still be using it: leave it alone.
559        let live = pid
560            .parse::<u32>()
561            .is_ok_and(|p| p != std::process::id() && pid_alive(p));
562        if !live {
563            let _ = std::fs::remove_dir_all(e.path());
564        }
565    }
566}
567
568/// Whether `pid` is a running process, without signalling it.
569fn pid_alive(pid: u32) -> bool {
570    std::process::Command::new("ps")
571        .args(["-p", &pid.to_string()])
572        .stdout(std::process::Stdio::null())
573        .stderr(std::process::Stdio::null())
574        .status()
575        .is_ok_and(|s| s.success())
576}
577
578fn copy_fsync(src: &Path, dst: &Path) -> Result<(), MigrateError> {
579    let mut input = io(&format!("open {}", src.display()), std::fs::File::open(src))?;
580    let mut out = io(&format!("create {}", dst.display()), private_file(dst))?;
581    io("copy", std::io::copy(&mut input, &mut out))?;
582    io("fsync", out.sync_all())
583}
584
585fn sync_dir(dir: &Path) {
586    if let Ok(d) = std::fs::File::open(dir) {
587        let _ = d.sync_all();
588    }
589}
590
591/// Write the fork-guard marker beside the source (temp file, then rename).
592/// A failure is an error: without the marker a daemon rooted at the legacy
593/// directory would not be stopped from forking the chain. The destination is
594/// already in place, so re-running completes just this step.
595fn write_source_marker(
596    from: &Path,
597    to: &Path,
598    seq: u64,
599    hash: &str,
600) -> Result<(), MigrateError> {
601    let text = format!(
602        "This chain was migrated to the WeftOS user chain.\n\
603         migrated-to: {}\n\
604         head-seq: {seq}\n\
605         head-hash: {hash}\n\
606         migrated-at: {}\n\
607         The files here were copied, not moved or changed. Do not start a kernel on this \
608         chain: it would fork history from {}. To override knowingly, pass \
609         --adopt-legacy-chain or isolate the run with WEFTOS_RUNTIME_DIR.\n",
610        to.display(),
611        chrono::Utc::now().to_rfc3339(),
612        to.display()
613    );
614    let fail = |e: std::io::Error| {
615        MigrateError::Io(format!(
616            "the chain was migrated to {} but the marker {} could not be written ({e}); \
617             a daemon rooted at {} is NOT yet blocked from forking it. Fix the permissions \
618             and re-run the same command to finish",
619            to.display(),
620            from.join(LEGACY_MIGRATED_MARKER).display(),
621            from.display()
622        ))
623    };
624    let tmp = from.join(format!(".{LEGACY_MIGRATED_MARKER}.tmp"));
625    let _ = std::fs::remove_file(&tmp);
626    let mut f = private_file(&tmp).map_err(fail)?;
627    f.write_all(text.as_bytes()).map_err(fail)?;
628    f.sync_all().map_err(fail)?;
629    std::fs::rename(&tmp, from.join(LEGACY_MIGRATED_MARKER)).map_err(fail)?;
630    sync_dir(from);
631    Ok(())
632}