|
|
|
@@ -0,0 +1,489 @@
|
|
|
|
|
//! migrate: transfer the installation and state from machine A to machine B
|
|
|
|
|
//! (issue #68). `am migrate export` bundles config, state, journal, history,
|
|
|
|
|
//! custom catalogs and backups into a single .amx file with a manifest of
|
|
|
|
|
//! hashes; `am migrate import` restores it and runs `am doctor` afterwards.
|
|
|
|
|
//! Absolute paths are neutralized ({{STATE_DIR}} / {{HOME}} placeholders) so
|
|
|
|
|
//! the bundle is portable. No runtime dependency: the bundle format is a
|
|
|
|
|
//! JSON manifest line followed by concatenated blobs.
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
use anyhow::{anyhow, Context, Result};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
use std::io::{Read, Write};
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
pub const BUNDLE_FORMAT: &str = "am-migrate-bundle";
|
|
|
|
|
pub const BUNDLE_VERSION: u32 = 1;
|
|
|
|
|
|
|
|
|
|
/// One file inside the bundle.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct BundleFile {
|
|
|
|
|
/// Relative path inside the state layout ("state.json", "events/...", ...).
|
|
|
|
|
pub path: String,
|
|
|
|
|
pub size: u64,
|
|
|
|
|
pub sha256: String,
|
|
|
|
|
/// Byte offset of the blob inside the bundle.
|
|
|
|
|
pub offset: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bundle header (first line of the file, then the blobs).
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct BundleManifest {
|
|
|
|
|
pub format: String,
|
|
|
|
|
pub version: u32,
|
|
|
|
|
pub created: String,
|
|
|
|
|
pub am_version: String,
|
|
|
|
|
pub files: Vec<BundleFile>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// sha256 hex of a byte slice.
|
|
|
|
|
pub fn sha256_hex(data: &[u8]) -> String {
|
|
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
|
let mut h = Sha256::new();
|
|
|
|
|
h.update(data);
|
|
|
|
|
let out = h.finalize();
|
|
|
|
|
out.iter().map(|b| format!("{b:02x}")).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Neutralize absolute paths of machine A into portable placeholders.
|
|
|
|
|
/// The source paths are replaced only when they match the current machine.
|
|
|
|
|
pub fn neutralize(text: &str, state_dir: &Path, home: &Path) -> String {
|
|
|
|
|
let mut out = text.to_string();
|
|
|
|
|
let sd = state_dir.to_string_lossy().to_string();
|
|
|
|
|
let h = home.to_string_lossy().to_string();
|
|
|
|
|
out = out.replace(&sd, "{{STATE_DIR}}");
|
|
|
|
|
out = out.replace(&h, "{{HOME}}");
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Restore the placeholders with machine B paths.
|
|
|
|
|
pub fn deneutralize(text: &str, state_dir: &Path, home: &Path) -> String {
|
|
|
|
|
let mut out = text.to_string();
|
|
|
|
|
out = out.replace("{{STATE_DIR}}", &state_dir.to_string_lossy());
|
|
|
|
|
out = out.replace("{{HOME}}", &home.to_string_lossy());
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Collect the files to bundle: config, state, journal, history, catalog
|
|
|
|
|
/// cache, backups. Returns (relative path, absolute path, needs path
|
|
|
|
|
/// neutralization).
|
|
|
|
|
fn collect_files(app: &App) -> Vec<(String, PathBuf, bool)> {
|
|
|
|
|
let state = app.events_dir();
|
|
|
|
|
let mut out: Vec<(String, PathBuf, bool)> = Vec::new();
|
|
|
|
|
|
|
|
|
|
// Active config file (when it exists on disk).
|
|
|
|
|
if let Some(dir) = &app.paths.config_dir {
|
|
|
|
|
let cfg = dir.join(crate::config::CONFIG_FILE_NAME);
|
|
|
|
|
if cfg.exists() {
|
|
|
|
|
out.push(("config.yaml".to_string(), cfg, true));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// State file.
|
|
|
|
|
let sf = app.paths.state_file.clone();
|
|
|
|
|
if sf.exists() {
|
|
|
|
|
out.push(("state.json".to_string(), sf, true));
|
|
|
|
|
}
|
|
|
|
|
// Journal events.
|
|
|
|
|
let events = state.join("events");
|
|
|
|
|
if let Ok(entries) = std::fs::read_dir(&events) {
|
|
|
|
|
for e in entries.flatten() {
|
|
|
|
|
let p = e.path();
|
|
|
|
|
if p.extension().and_then(|x| x.to_str()) == Some("jsonl") {
|
|
|
|
|
let rel = format!(
|
|
|
|
|
"events/{}",
|
|
|
|
|
p.file_name().unwrap_or_default().to_string_lossy()
|
|
|
|
|
);
|
|
|
|
|
out.push((rel, p, true));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if let Ok(entries) = std::fs::read_dir(&state) {
|
|
|
|
|
// Older layout: journal files sit directly in the state directory.
|
|
|
|
|
for e in entries.flatten() {
|
|
|
|
|
let p = e.path();
|
|
|
|
|
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
|
|
|
|
if name.starts_with("events-") && name.ends_with(".jsonl") {
|
|
|
|
|
out.push((name, p, true));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// History files (per-session JSONL).
|
|
|
|
|
let history = state.join("history");
|
|
|
|
|
if let Ok(entries) = std::fs::read_dir(&history) {
|
|
|
|
|
for e in entries.flatten() {
|
|
|
|
|
let p = e.path();
|
|
|
|
|
let rel = format!(
|
|
|
|
|
"history/{}",
|
|
|
|
|
p.file_name().unwrap_or_default().to_string_lossy()
|
|
|
|
|
);
|
|
|
|
|
out.push((rel, p, true));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Catalog cache (custom catalogs).
|
|
|
|
|
let cache = state.join("catalog-cache.json");
|
|
|
|
|
if cache.exists() {
|
|
|
|
|
out.push(("catalog-cache.json".to_string(), cache, true));
|
|
|
|
|
}
|
|
|
|
|
// Backups (recursive: one entry per file under backups/<id>/).
|
|
|
|
|
let backups = state.join("backups");
|
|
|
|
|
if let Ok(entries) = std::fs::read_dir(&backups) {
|
|
|
|
|
for e in entries.flatten() {
|
|
|
|
|
let p = e.path();
|
|
|
|
|
if !p.is_dir() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let id = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
|
|
|
|
if let Ok(files) = std::fs::read_dir(&p) {
|
|
|
|
|
for f in files.flatten() {
|
|
|
|
|
let fp = f.path();
|
|
|
|
|
if fp.is_file() {
|
|
|
|
|
let rel = format!(
|
|
|
|
|
"backups/{}/{}",
|
|
|
|
|
id,
|
|
|
|
|
fp.file_name().unwrap_or_default().to_string_lossy()
|
|
|
|
|
);
|
|
|
|
|
out.push((rel, fp, false));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Write the bundle. Returns the number of bundled files.
|
|
|
|
|
pub fn export_bundle(app: &App, output: &Path) -> Result<usize> {
|
|
|
|
|
let files = collect_files(app);
|
|
|
|
|
let mut manifest = BundleManifest {
|
|
|
|
|
format: BUNDLE_FORMAT.to_string(),
|
|
|
|
|
version: BUNDLE_VERSION,
|
|
|
|
|
created: crate::installers::now_rfc3339(),
|
|
|
|
|
am_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
|
|
|
files: Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
let state_dir = app.events_dir();
|
|
|
|
|
let home = crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
|
|
|
|
let mut blobs: Vec<Vec<u8>> = Vec::new();
|
|
|
|
|
let mut offset: u64 = 0;
|
|
|
|
|
|
|
|
|
|
for (rel, abs, neutralize_paths) in &files {
|
|
|
|
|
let raw = std::fs::read(abs)
|
|
|
|
|
.with_context(|| format!("cannot read {}", abs.display()))?;
|
|
|
|
|
let data = if *neutralize_paths {
|
|
|
|
|
// Text files: neutralize absolute paths (works on both YAML and
|
|
|
|
|
// JSON since the placeholders are plain strings).
|
|
|
|
|
let text = String::from_utf8_lossy(&raw);
|
|
|
|
|
let neutral = neutralize(&text, &state_dir, &home);
|
|
|
|
|
neutral.into_bytes()
|
|
|
|
|
} else {
|
|
|
|
|
raw
|
|
|
|
|
};
|
|
|
|
|
manifest.files.push(BundleFile {
|
|
|
|
|
path: rel.clone(),
|
|
|
|
|
size: data.len() as u64,
|
|
|
|
|
sha256: sha256_hex(&data),
|
|
|
|
|
offset,
|
|
|
|
|
});
|
|
|
|
|
offset += data.len() as u64;
|
|
|
|
|
blobs.push(data);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(parent) = output.parent() {
|
|
|
|
|
std::fs::create_dir_all(parent)?;
|
|
|
|
|
}
|
|
|
|
|
let mut f = std::fs::File::create(output)
|
|
|
|
|
.with_context(|| format!("cannot create {}", output.display()))?;
|
|
|
|
|
let header = serde_json::to_string(&manifest)?;
|
|
|
|
|
writeln!(f, "{header}")?;
|
|
|
|
|
for b in &blobs {
|
|
|
|
|
f.write_all(b)?;
|
|
|
|
|
}
|
|
|
|
|
f.flush()?;
|
|
|
|
|
Ok(manifest.files.len())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Read and verify the bundle: parses the header, checks that every blob is
|
|
|
|
|
/// present and its hash matches. Returns (manifest, blob bytes).
|
|
|
|
|
pub fn read_bundle(path: &Path) -> Result<(BundleManifest, Vec<Vec<u8>>)> {
|
|
|
|
|
let mut raw = Vec::new();
|
|
|
|
|
std::fs::File::open(path)
|
|
|
|
|
.with_context(|| format!("cannot open {}", path.display()))?
|
|
|
|
|
.read_to_end(&mut raw)?;
|
|
|
|
|
let nl = raw
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|&b| b == b'\n')
|
|
|
|
|
.ok_or_else(|| anyhow!("{}: missing header line", path.display()))?;
|
|
|
|
|
let header: BundleManifest = serde_json::from_slice(&raw[..nl])
|
|
|
|
|
.with_context(|| format!("{}: invalid bundle header", path.display()))?;
|
|
|
|
|
if header.format != BUNDLE_FORMAT {
|
|
|
|
|
anyhow::bail!("{}: not an am-migrate bundle", path.display());
|
|
|
|
|
}
|
|
|
|
|
let body = &raw[nl + 1..];
|
|
|
|
|
let mut blobs = Vec::new();
|
|
|
|
|
for f in &header.files {
|
|
|
|
|
let start = f.offset as usize;
|
|
|
|
|
let end = start + f.size as usize;
|
|
|
|
|
if end > body.len() {
|
|
|
|
|
anyhow::bail!(
|
|
|
|
|
"{}: truncated bundle — '{}' is incomplete (interrupted transfer?)",
|
|
|
|
|
path.display(),
|
|
|
|
|
f.path
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
let data = body[start..end].to_vec();
|
|
|
|
|
if sha256_hex(&data) != f.sha256 {
|
|
|
|
|
anyhow::bail!(
|
|
|
|
|
"{}: checksum mismatch for '{}' — bundle corrupted",
|
|
|
|
|
path.display(),
|
|
|
|
|
f.path
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
blobs.push(data);
|
|
|
|
|
}
|
|
|
|
|
Ok((header, blobs))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Restore a bundle into the state layout of machine B. The previous state
|
|
|
|
|
/// directory is moved aside (resumable, nothing lost). Returns the list of
|
|
|
|
|
/// restored files.
|
|
|
|
|
pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<String>> {
|
|
|
|
|
let (manifest, blobs) = read_bundle(bundle)?;
|
|
|
|
|
let state_dir = app.events_dir();
|
|
|
|
|
let home = crate::config::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
|
|
|
|
|
|
|
|
|
// Resumable import: park the current state aside, then write the new one.
|
|
|
|
|
let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S");
|
|
|
|
|
if state_dir.exists() {
|
|
|
|
|
let park = state_dir.with_extension(format!("prev-{ts}"));
|
|
|
|
|
std::fs::rename(&state_dir, &park)
|
|
|
|
|
.with_context(|| format!("cannot park old state at {}", park.display()))?;
|
|
|
|
|
app.log
|
|
|
|
|
.info(&format!("previous state moved aside: {}", park.display()));
|
|
|
|
|
}
|
|
|
|
|
std::fs::create_dir_all(&state_dir)?;
|
|
|
|
|
|
|
|
|
|
let mut restored = Vec::new();
|
|
|
|
|
for (bf, data) in manifest.files.iter().zip(blobs.iter()) {
|
|
|
|
|
let rel = Path::new(&bf.path);
|
|
|
|
|
let target = if bf.path == "config.yaml" {
|
|
|
|
|
// Config goes to the user config directory of machine B.
|
|
|
|
|
let dir = crate::config::user_config_dir()
|
|
|
|
|
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")));
|
|
|
|
|
std::fs::create_dir_all(&dir)?;
|
|
|
|
|
dir.join(crate::config::CONFIG_FILE_NAME)
|
|
|
|
|
} else {
|
|
|
|
|
state_dir.join(rel)
|
|
|
|
|
};
|
|
|
|
|
if let Some(parent) = target.parent() {
|
|
|
|
|
std::fs::create_dir_all(parent)?;
|
|
|
|
|
}
|
|
|
|
|
let mut content = data.clone();
|
|
|
|
|
if !rel.starts_with("backups") {
|
|
|
|
|
// Text content: restore machine B paths.
|
|
|
|
|
let text = String::from_utf8_lossy(&content);
|
|
|
|
|
content = deneutralize(&text, &state_dir, &home).into_bytes();
|
|
|
|
|
}
|
|
|
|
|
std::fs::write(&target, &content)
|
|
|
|
|
.with_context(|| format!("cannot write {}", target.display()))?;
|
|
|
|
|
restored.push(bf.path.clone());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Config merge: the imported config replaces the local one only when
|
|
|
|
|
// confirmed; otherwise it is written next to it as config.migrated.yaml.
|
|
|
|
|
let cfg_rel = "config.yaml";
|
|
|
|
|
if restored.iter().any(|r| r == cfg_rel) && !confirm {
|
|
|
|
|
let dir = crate::config::user_config_dir()
|
|
|
|
|
.unwrap_or_else(|| crate::config::home_dir().unwrap_or_else(|| PathBuf::from(".")));
|
|
|
|
|
let imported = dir.join(crate::config::CONFIG_FILE_NAME);
|
|
|
|
|
if imported.exists() {
|
|
|
|
|
let target = dir.join("config.migrated.yaml");
|
|
|
|
|
std::fs::rename(&imported, &target)?;
|
|
|
|
|
app.log.info(&format!(
|
|
|
|
|
"imported config written as {} (use --yes to replace the local config)",
|
|
|
|
|
target.display()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
restored.sort();
|
|
|
|
|
Ok(restored)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// am migrate export/import.
|
|
|
|
|
pub fn run(app: &App, export: bool, output: Option<&Path>, bundle: Option<&Path>) -> Result<i32> {
|
|
|
|
|
if export {
|
|
|
|
|
let default_out = PathBuf::from("agent-manager-migrate.amx");
|
|
|
|
|
let out = output.unwrap_or(&default_out);
|
|
|
|
|
let n = export_bundle(app, out)?;
|
|
|
|
|
app.log.success(&format!(
|
|
|
|
|
"bundle written to {} ({n} files) — copy it to machine B and run 'am migrate import {}'",
|
|
|
|
|
out.display(),
|
|
|
|
|
out.display()
|
|
|
|
|
));
|
|
|
|
|
if let Some(dir) = app.paths.config_dir.as_ref() {
|
|
|
|
|
if dir.join(crate::config::CONFIG_FILE_NAME).exists() {
|
|
|
|
|
app.log.info("the bundle contains config.yaml, state.json, events, history, catalog cache and backups — absolute paths are neutralized");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
let bundle = bundle.ok_or_else(|| anyhow!("usage: am migrate import <bundle.amx>"))?;
|
|
|
|
|
if !bundle.exists() {
|
|
|
|
|
anyhow::bail!("bundle not found: {}", bundle.display());
|
|
|
|
|
}
|
|
|
|
|
let ask = format!(
|
|
|
|
|
"import {} into this machine? The current state will be moved aside",
|
|
|
|
|
bundle.display()
|
|
|
|
|
);
|
|
|
|
|
let confirmed = app.confirm(&ask)?;
|
|
|
|
|
if !confirmed {
|
|
|
|
|
app.log.info("import cancelled");
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
let restored = import_bundle(app, bundle, app.cli.yes)?;
|
|
|
|
|
app.log.success(&format!("imported {} file(s)", restored.len()));
|
|
|
|
|
|
|
|
|
|
// Post-import verification (issue #68: doctor passes after import).
|
|
|
|
|
let problems = crate::commands::doctor_cmd::run(app, false, None)?;
|
|
|
|
|
if problems > 0 {
|
|
|
|
|
app.log.warn("doctor found issues after the import — review the report above");
|
|
|
|
|
} else {
|
|
|
|
|
app.log.success("doctor: environment OK after import");
|
|
|
|
|
}
|
|
|
|
|
app.log.info(
|
|
|
|
|
"next steps: (1) verify 'am list' and 'am status', (2) reinstall agents if needed ('am install <agent>'), (3) start a session",
|
|
|
|
|
);
|
|
|
|
|
Ok(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn sha256_is_stable() {
|
|
|
|
|
let h1 = sha256_hex(b"hello");
|
|
|
|
|
let h2 = sha256_hex(b"hello");
|
|
|
|
|
assert_eq!(h1, h2);
|
|
|
|
|
assert_eq!(h1.len(), 64);
|
|
|
|
|
assert_ne!(h1, sha256_hex(b"world"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn neutralization_round_trip() {
|
|
|
|
|
let state = PathBuf::from("/mnt/machine-a/state");
|
|
|
|
|
let home = PathBuf::from("/home/user-a");
|
|
|
|
|
let text = format!(
|
|
|
|
|
"install_dir: {}\nrun: {}/bin/tool\n",
|
|
|
|
|
state.display(),
|
|
|
|
|
home.display()
|
|
|
|
|
);
|
|
|
|
|
let neutral = neutralize(&text, &state, &home);
|
|
|
|
|
assert!(neutral.contains("{{STATE_DIR}}"));
|
|
|
|
|
assert!(neutral.contains("{{HOME}}"));
|
|
|
|
|
assert!(!neutral.contains("machine-a"));
|
|
|
|
|
let state_b = PathBuf::from("/data/machine-b/state");
|
|
|
|
|
let home_b = PathBuf::from("/home/user-b");
|
|
|
|
|
let back = deneutralize(&neutral, &state_b, &home_b);
|
|
|
|
|
assert!(back.contains("/data/machine-b/state"));
|
|
|
|
|
assert!(back.contains("/home/user-b"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn test_app(tag: &str) -> crate::app::App {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let cfg = dir.path().join("config.yaml");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
&cfg,
|
|
|
|
|
concat!(
|
|
|
|
|
"version: \"1.0\"\n",
|
|
|
|
|
"settings:\n",
|
|
|
|
|
" auto_install_deps: false\n",
|
|
|
|
|
" confirm_before_run: false\n",
|
|
|
|
|
"agents: []\n",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
use clap::Parser;
|
|
|
|
|
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap(), "list"]);
|
|
|
|
|
let mut app = crate::app::App::from_cli(cli).expect("app should build");
|
|
|
|
|
app.paths.state_file = dir.path().join(format!("state-{tag}/state.json"));
|
|
|
|
|
app.paths.config_dir = Some(dir.path().to_path_buf());
|
|
|
|
|
app
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn export_import_round_trip() {
|
|
|
|
|
let app = test_app("exp");
|
|
|
|
|
let state = app.events_dir();
|
|
|
|
|
std::fs::create_dir_all(&state).unwrap();
|
|
|
|
|
std::fs::write(app.paths.state_file.clone(), "{\"version\":2,\"installed\":{}}").unwrap();
|
|
|
|
|
std::fs::write(state.join("events-202608.jsonl"), "{\"kind\":\"start\"}\n").unwrap();
|
|
|
|
|
std::fs::write(state.join("catalog-cache.json"), "{}").unwrap();
|
|
|
|
|
// The config file referenced by config_dir must exist to be bundled.
|
|
|
|
|
let cfg_path = app
|
|
|
|
|
.paths
|
|
|
|
|
.config_dir
|
|
|
|
|
.as_ref()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.join(crate::config::CONFIG_FILE_NAME);
|
|
|
|
|
std::fs::write(&cfg_path, "version: \"1.0\"\nagents: []\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let bundle = std::env::temp_dir().join(format!("am-mig-{}.amx", std::process::id()));
|
|
|
|
|
let n = export_bundle(&app, &bundle).unwrap();
|
|
|
|
|
assert!(n >= 3);
|
|
|
|
|
|
|
|
|
|
// Import into a second app with a different state dir.
|
|
|
|
|
let app_b = test_app("imp");
|
|
|
|
|
let restored = import_bundle(&app_b, &bundle, true).unwrap();
|
|
|
|
|
assert!(restored.iter().any(|r| r == "state.json"));
|
|
|
|
|
assert!(restored.iter().any(|r| r == "events-202608.jsonl"));
|
|
|
|
|
// State restored with the same content.
|
|
|
|
|
let state_b = app_b.events_dir();
|
|
|
|
|
let sf = state_b.join("state.json");
|
|
|
|
|
assert!(sf.exists());
|
|
|
|
|
let text = std::fs::read_to_string(sf).unwrap();
|
|
|
|
|
assert!(text.contains("\"version\":2"));
|
|
|
|
|
assert!(state_b.join("events-202608.jsonl").exists());
|
|
|
|
|
let _ = std::fs::remove_file(&bundle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn truncated_bundle_is_detected() {
|
|
|
|
|
let app = test_app("trunc");
|
|
|
|
|
let state = app.events_dir();
|
|
|
|
|
std::fs::create_dir_all(&state).unwrap();
|
|
|
|
|
std::fs::write(state.join("state.json"), "{\"v\":1}").unwrap();
|
|
|
|
|
let bundle = std::env::temp_dir().join(format!("am-trunc-{}.amx", std::process::id()));
|
|
|
|
|
export_bundle(&app, &bundle).unwrap();
|
|
|
|
|
// Truncate the last byte.
|
|
|
|
|
let mut data = std::fs::read(&bundle).unwrap();
|
|
|
|
|
data.pop();
|
|
|
|
|
std::fs::write(&bundle, data).unwrap();
|
|
|
|
|
let err = read_bundle(&bundle).unwrap_err().to_string();
|
|
|
|
|
assert!(err.contains("truncated") || err.contains("checksum"), "{err}");
|
|
|
|
|
let _ = std::fs::remove_file(&bundle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tampered_blob_is_detected() {
|
|
|
|
|
let app = test_app("tamper");
|
|
|
|
|
let state = app.events_dir();
|
|
|
|
|
std::fs::create_dir_all(&state).unwrap();
|
|
|
|
|
std::fs::write(state.join("state.json"), "{\"v\":1}").unwrap();
|
|
|
|
|
let bundle = std::env::temp_dir().join(format!("am-tamp-{}.amx", std::process::id()));
|
|
|
|
|
export_bundle(&app, &bundle).unwrap();
|
|
|
|
|
// Flip a byte inside the first blob (after the header line), not in
|
|
|
|
|
// the header itself.
|
|
|
|
|
let mut data = std::fs::read(&bundle).unwrap();
|
|
|
|
|
let nl = data.iter().position(|&b| b == b'\n').unwrap();
|
|
|
|
|
let mid = nl + 1 + (data.len() - nl - 1) / 2;
|
|
|
|
|
data[mid] ^= 0xFF;
|
|
|
|
|
std::fs::write(&bundle, data).unwrap();
|
|
|
|
|
let err = read_bundle(&bundle).unwrap_err().to_string();
|
|
|
|
|
assert!(err.contains("checksum"), "{err}");
|
|
|
|
|
let _ = std::fs::remove_file(&bundle);
|
|
|
|
|
}
|
|
|
|
|
}
|