1use 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
38const SET: [&str; 5] = [
40 "chain.rvf",
41 "chain.json",
42 "chain.tree.json",
43 "chain/anchors.jsonl",
44 "chain.key",
45];
46
47#[derive(Debug)]
49pub enum MigrateError {
50 Refused(String),
52 NothingToMigrate(String),
54 VerifyFailed(String),
56 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
77pub struct MigrateOptions<'a> {
79 pub from: &'a Path,
81 pub to: &'a Path,
83 pub dry_run: bool,
85 pub now: SystemTime,
87 pub tool_version: &'a str,
89 pub allow_unsigned: bool,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct FileEntry {
97 pub name: String,
99 pub bytes: u64,
101 pub sha256: String,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Head {
108 pub events: usize,
110 pub sequence: u64,
112 pub hash: String,
114 pub signature: String,
116}
117
118#[derive(Debug, Clone)]
120pub struct Plan {
121 pub from: PathBuf,
123 pub to: PathBuf,
125 pub files: Vec<FileEntry>,
127 pub head: Head,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct Marker {
134 pub source: String,
136 pub files: Vec<FileEntry>,
138 pub head_hash: String,
140 pub sequence: u64,
142 pub events: usize,
144 pub signature: String,
146 pub at: String,
148 pub weaver_version: String,
150}
151
152#[derive(Debug)]
154pub enum Outcome {
155 DryRun(Plan),
157 Migrated(Plan),
159 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
187fn 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
239fn 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 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
337pub fn migrate_user_chain(opts: &MigrateOptions<'_>) -> Result<Outcome, MigrateError> {
339 migrate_with_hook(opts, &mut |_, _| {})
340}
341
342#[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
396fn 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 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 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 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 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 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
529fn 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
538fn 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
547fn 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 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
568fn 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
591fn 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}