use std::fs;
use std::io::{Error, ErrorKind, Read};
use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use clap::Args;
use cortex_core::{AuthorityClass, ClaimCeiling, ClaimProofState, RuntimeMode};
use cortex_ledger::{
audit::{verify_chain, verify_schema_migration_v1_to_v2_boundary},
JsonlError,
};
use rusqlite::Connection;
use serde::Serialize;
use crate::exit::Exit;
use crate::output::{self, Envelope};
use crate::paths::DataLayout;
const MANIFEST_FILENAME: &str = "BACKUP_MANIFEST";
const SQLITE_BUNDLE_FILENAME: &str = "cortex.db";
const JSONL_BUNDLE_FILENAME: &str = "events.jsonl";
const PRE_V2_BACKUP_KIND: &str = "cortex_pre_v2_backup";
const POST_V2_BACKUP_KIND: &str = "cortex_post_v2_backup";
const PRE_V2_BACKUP_SCHEMA_VERSION: u16 = 1;
#[derive(Debug, Args)]
pub struct BackupArgs {
#[arg(long, value_name = "DIR")]
pub output: PathBuf,
}
#[derive(Debug, Serialize)]
struct BackupManifest {
kind: &'static str,
schema_version: u16,
sqlite_store: &'static str,
jsonl_mirror: &'static str,
tool_version: &'static str,
backup_timestamp: String,
sqlite_store_size_bytes: u64,
sqlite_store_blake3: String,
jsonl_mirror_size_bytes: u64,
jsonl_mirror_blake3: String,
jsonl_mirror_audit_status: &'static str,
jsonl_mirror_audit_rows_scanned: usize,
jsonl_mirror_audit_failures: usize,
table_row_counts: BackupTableRowCounts,
runtime_mode: RuntimeMode,
proof_state: ClaimProofState,
claim_ceiling: ClaimCeiling,
authority_class: AuthorityClass,
}
#[derive(Debug, Clone, Copy, Serialize)]
struct BackupTableRowCounts {
events: u64,
traces: u64,
episodes: u64,
memories: u64,
}
#[derive(Debug)]
struct BundleArtifacts {
sqlite_store_size_bytes: u64,
sqlite_store_blake3: String,
jsonl_mirror_size_bytes: u64,
jsonl_mirror_blake3: String,
jsonl_mirror_audit: JsonlCopyAudit,
table_row_counts: BackupTableRowCounts,
boundary_present: bool,
}
#[derive(Debug)]
struct JsonlCopyAudit {
status: &'static str,
rows_scanned: usize,
failures: usize,
}
pub fn run(args: BackupArgs) -> Exit {
let json = output::json_enabled();
match run_inner(&args) {
Ok(manifest_path) => {
if json {
let manifest_value = match fs::read(&manifest_path).and_then(|bytes| {
serde_json::from_slice::<serde_json::Value>(&bytes)
.map_err(std::io::Error::other)
}) {
Ok(value) => value,
Err(err) => {
eprintln!(
"cortex backup: failed to re-read manifest {} for JSON output: {err}",
manifest_path.display()
);
return Exit::Internal;
}
};
let payload = serde_json::json!({
"bundle_dir": args.output.display().to_string(),
"manifest_path": manifest_path.display().to_string(),
"manifest": manifest_value,
"restore_verification": "not_performed",
});
let envelope = Envelope::new("cortex.backup", Exit::Ok, payload);
output::emit(&envelope, Exit::Ok)
} else {
println!(
"cortex backup: local offline bundle = {}",
args.output.display()
);
println!("cortex backup: manifest = {}", manifest_path.display());
println!("cortex backup: copied JSONL audit = verified.");
println!("cortex backup: restore verification not performed.");
Exit::Ok
}
}
Err(exit) => {
if json {
let payload = serde_json::json!({
"status": "error",
"bundle_dir": args.output.display().to_string(),
});
let envelope = Envelope::new("cortex.backup", exit, payload);
output::emit(&envelope, exit)
} else {
exit
}
}
}
}
fn run_inner(args: &BackupArgs) -> Result<PathBuf, Exit> {
let layout = DataLayout::resolve(None, None)?;
require_existing_file(&layout.db_path, "SQLite store")?;
require_existing_file(&layout.event_log_path, "JSONL mirror")?;
checkpoint_or_reject_active_sqlite_sidecars(&layout.db_path)?;
reject_existing_path(&args.output, "output")?;
let parent = args.output.parent().filter(|p| !p.as_os_str().is_empty());
if let Some(parent) = parent {
ensure_output_parent(parent)?;
}
let staging = staging_dir_for(&args.output);
reject_existing_path(&staging, "staging path")?;
fs::create_dir(&staging).map_err(|err| {
eprintln!(
"cortex backup: failed to create staging directory {}: {err}",
staging.display()
);
Exit::PreconditionUnmet
})?;
match populate_staging_bundle(&layout, &staging) {
Ok(()) => {}
Err(exit) => {
cleanup_staging(&staging);
return Err(exit);
}
}
if let Err(exit) = reject_existing_path(&args.output, "output") {
cleanup_staging(&staging);
return Err(exit);
}
publish_staging_bundle(&staging, &args.output).map_err(|err| {
cleanup_staging(&staging);
eprintln!(
"cortex backup: failed to publish backup bundle {}: {err}",
args.output.display()
);
Exit::PreconditionUnmet
})?;
Ok(args.output.join(MANIFEST_FILENAME))
}
fn reject_existing_path(path: &Path, label: &str) -> Result<(), Exit> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
eprintln!(
"cortex backup: precondition unmet: {label} {} is a symlink; no state was changed.",
path.display()
);
Err(Exit::PreconditionUnmet)
}
Ok(_) => {
eprintln!(
"cortex backup: precondition unmet: {label} {} already exists; no state was changed.",
path.display()
);
Err(Exit::PreconditionUnmet)
}
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => {
eprintln!(
"cortex backup: failed to inspect {label} {}: {err}; no state was changed.",
path.display()
);
Err(Exit::PreconditionUnmet)
}
}
}
fn ensure_output_parent(parent: &Path) -> Result<(), Exit> {
match fs::symlink_metadata(parent) {
Ok(metadata) => require_output_parent_directory(parent, &metadata),
Err(err) if err.kind() == ErrorKind::NotFound => {
fs::create_dir_all(parent).map_err(|err| {
eprintln!(
"cortex backup: failed to create output parent {}: {err}",
parent.display()
);
Exit::PreconditionUnmet
})?;
let metadata = fs::symlink_metadata(parent).map_err(|err| {
eprintln!(
"cortex backup: failed to inspect output parent {}: {err}; no state was changed.",
parent.display()
);
Exit::PreconditionUnmet
})?;
require_output_parent_directory(parent, &metadata)
}
Err(err) => {
eprintln!(
"cortex backup: failed to inspect output parent {}: {err}; no state was changed.",
parent.display()
);
Err(Exit::PreconditionUnmet)
}
}
}
fn require_output_parent_directory(parent: &Path, metadata: &fs::Metadata) -> Result<(), Exit> {
if metadata.file_type().is_symlink() {
eprintln!(
"cortex backup: precondition unmet: output parent {} is a symlink; no state was changed.",
parent.display()
);
return Err(Exit::PreconditionUnmet);
}
if !metadata.is_dir() {
eprintln!(
"cortex backup: precondition unmet: output parent {} is not a directory; no state was changed.",
parent.display()
);
return Err(Exit::PreconditionUnmet);
}
Ok(())
}
fn checkpoint_or_reject_active_sqlite_sidecars(db_path: &Path) -> Result<(), Exit> {
let sidecars = sqlite_sidecar_paths(db_path);
if sidecars.iter().any(|sidecar| sidecar.exists()) {
let pool = Connection::open(db_path).map_err(|err| {
eprintln!(
"cortex backup: failed to open SQLite store {} for WAL checkpoint: {err}; backup bundle was not published.",
db_path.display()
);
Exit::PreconditionUnmet
})?;
pool.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
.map_err(|err| {
eprintln!(
"cortex backup: SQLite WAL checkpoint failed for {}: {err}; backup bundle was not published.",
db_path.display()
);
Exit::PreconditionUnmet
})?;
drop(pool);
}
let wal_path = &sidecars[0];
match fs::symlink_metadata(wal_path) {
Ok(metadata) if metadata.len() > 0 => {
eprintln!(
"cortex backup: precondition unmet: active SQLite WAL sidecar {} still contains {} bytes after checkpoint; backup bundle was not published. Run a snapshot-safe backup path before claiming SQLite backup completeness.",
wal_path.display(),
metadata.len()
);
Err(Exit::PreconditionUnmet)
}
Ok(_) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => {
eprintln!(
"cortex backup: failed to inspect SQLite WAL sidecar {}: {err}; backup bundle was not published.",
wal_path.display()
);
Err(Exit::PreconditionUnmet)
}
}
}
fn sqlite_sidecar_paths(db_path: &Path) -> [PathBuf; 2] {
[
db_path.with_file_name(format!(
"{}-wal",
db_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("cortex.db")
)),
db_path.with_file_name(format!(
"{}-shm",
db_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("cortex.db")
)),
]
}
fn populate_staging_bundle(layout: &DataLayout, staging: &Path) -> Result<(), Exit> {
let sqlite_store = staging.join(SQLITE_BUNDLE_FILENAME);
let jsonl_mirror = staging.join(JSONL_BUNDLE_FILENAME);
copy_artifact(&layout.db_path, &sqlite_store, "SQLite store")?;
copy_artifact(&layout.event_log_path, &jsonl_mirror, "JSONL mirror")?;
let artifacts = BundleArtifacts {
sqlite_store_size_bytes: file_size(&sqlite_store, "copied SQLite store")?,
sqlite_store_blake3: blake3_file(&sqlite_store, "copied SQLite store")?,
jsonl_mirror_size_bytes: file_size(&jsonl_mirror, "copied JSONL mirror")?,
jsonl_mirror_blake3: blake3_file(&jsonl_mirror, "copied JSONL mirror")?,
jsonl_mirror_audit: audit_verify_copied_jsonl(&jsonl_mirror)?,
table_row_counts: count_backup_tables(&sqlite_store)?,
boundary_present: detect_v1_to_v2_boundary(&jsonl_mirror)?,
};
write_manifest(staging, artifacts)
}
fn detect_v1_to_v2_boundary(jsonl_mirror: &Path) -> Result<bool, Exit> {
match verify_schema_migration_v1_to_v2_boundary(jsonl_mirror, false) {
Ok(report) => Ok(!report.boundary_rows.is_empty()),
Err(err) => {
eprintln!(
"cortex backup: failed to inspect copied JSONL mirror `{}` for v1-to-v2 boundary state: {err}; backup bundle was not published.",
jsonl_mirror.display()
);
Err(map_jsonl_verify_err(&err))
}
}
}
fn count_backup_tables(sqlite_store: &Path) -> Result<BackupTableRowCounts, Exit> {
let uri = format!(
"file:{}?mode=ro&immutable=1",
sqlite_store.display().to_string().replace('\\', "/")
);
let pool = Connection::open_with_flags(
&uri,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
)
.map_err(|err| {
eprintln!(
"cortex backup: failed to open copied SQLite store for table-count manifest field {}: {err}",
sqlite_store.display()
);
Exit::PreconditionUnmet
})?;
let counts = BackupTableRowCounts {
events: optional_table_count(&pool, "events")?,
traces: optional_table_count(&pool, "traces")?,
episodes: optional_table_count(&pool, "episodes")?,
memories: optional_table_count(&pool, "memories")?,
};
drop(pool);
Ok(counts)
}
fn optional_table_count(pool: &Connection, table: &'static str) -> Result<u64, Exit> {
let exists: i64 = pool
.query_row(
"SELECT EXISTS (
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1
);",
[table],
|row| row.get(0),
)
.map_err(|err| {
eprintln!(
"cortex backup: failed to inspect sqlite_master for table `{table}` in copied SQLite store: {err}"
);
Exit::PreconditionUnmet
})?;
if exists == 0 {
return Ok(0);
}
let sql = format!("SELECT COUNT(*) FROM {table};");
pool.query_row(&sql, [], |row| row.get::<_, u64>(0))
.map_err(|err| {
eprintln!(
"cortex backup: failed to read row count for table `{table}` from copied SQLite store: {err}"
);
Exit::PreconditionUnmet
})
}
fn audit_verify_copied_jsonl(path: &Path) -> Result<JsonlCopyAudit, Exit> {
match verify_chain(path) {
Ok(report) if report.ok() => Ok(JsonlCopyAudit {
status: "verified",
rows_scanned: report.rows_scanned,
failures: report.failures.len(),
}),
Ok(report) => {
for failure in &report.failures {
eprintln!(
"cortex backup: copied JSONL audit failure at line {}: {:?}",
failure.line, failure.reason
);
}
eprintln!(
"cortex backup: copied JSONL audit verification failed for `{}`; backup bundle was not published.",
path.display()
);
Err(Exit::IntegrityFailure)
}
Err(err) => {
eprintln!(
"cortex backup: copied JSONL audit verification failed for `{}`: {err}; backup bundle was not published.",
path.display()
);
Err(map_jsonl_verify_err(&err))
}
}
}
fn map_jsonl_verify_err(err: &JsonlError) -> Exit {
match err {
JsonlError::Decode { .. } | JsonlError::ChainBroken(_) => Exit::ChainCorruption,
JsonlError::Validation(_) => Exit::PreconditionUnmet,
JsonlError::Io { .. } | JsonlError::Encode(_) => Exit::Internal,
}
}
fn require_existing_file(path: &Path, label: &str) -> Result<(), Exit> {
let metadata = fs::metadata(path).map_err(|_| {
eprintln!(
"cortex backup: precondition unmet: {label} {} does not exist; no state was changed.",
path.display()
);
Exit::PreconditionUnmet
})?;
if !metadata.is_file() {
eprintln!(
"cortex backup: precondition unmet: {label} {} is not a file; no state was changed.",
path.display()
);
return Err(Exit::PreconditionUnmet);
}
Ok(())
}
fn copy_artifact(source: &Path, destination: &Path, label: &str) -> Result<(), Exit> {
fs::copy(source, destination).map_err(|err| {
eprintln!(
"cortex backup: failed to copy {label} {} to {}: {err}",
source.display(),
destination.display()
);
Exit::PreconditionUnmet
})?;
Ok(())
}
fn file_size(path: &Path, label: &str) -> Result<u64, Exit> {
fs::metadata(path)
.map(|metadata| metadata.len())
.map_err(|err| {
eprintln!(
"cortex backup: failed to read {label} metadata {}: {err}",
path.display()
);
Exit::PreconditionUnmet
})
}
fn blake3_file(path: &Path, label: &str) -> Result<String, Exit> {
let mut file = fs::File::open(path).map_err(|err| {
eprintln!(
"cortex backup: failed to open {label} {}: {err}",
path.display()
);
Exit::PreconditionUnmet
})?;
let mut hasher = blake3::Hasher::new();
let mut buffer = [0_u8; 16 * 1024];
loop {
let read = file.read(&mut buffer).map_err(|err| {
eprintln!(
"cortex backup: failed to read {label} {}: {err}",
path.display()
);
Exit::PreconditionUnmet
})?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("blake3:{}", hasher.finalize().to_hex()))
}
fn write_manifest(staging: &Path, artifacts: BundleArtifacts) -> Result<(), Exit> {
let (kind, schema_version) = if artifacts.boundary_present {
(POST_V2_BACKUP_KIND, cortex_core::SCHEMA_VERSION)
} else {
(PRE_V2_BACKUP_KIND, PRE_V2_BACKUP_SCHEMA_VERSION)
};
let runtime_mode = RuntimeMode::LocalUnsigned;
let proof_state = ClaimProofState::FullChainVerified;
let authority_class = AuthorityClass::Observed;
let claim_ceiling = cortex_core::effective_ceiling(
runtime_mode,
authority_class,
proof_state,
ClaimCeiling::LocalUnsigned,
);
let manifest = BackupManifest {
kind,
schema_version,
sqlite_store: SQLITE_BUNDLE_FILENAME,
jsonl_mirror: JSONL_BUNDLE_FILENAME,
tool_version: env!("CARGO_PKG_VERSION"),
backup_timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
sqlite_store_size_bytes: artifacts.sqlite_store_size_bytes,
sqlite_store_blake3: artifacts.sqlite_store_blake3,
jsonl_mirror_size_bytes: artifacts.jsonl_mirror_size_bytes,
jsonl_mirror_blake3: artifacts.jsonl_mirror_blake3,
jsonl_mirror_audit_status: artifacts.jsonl_mirror_audit.status,
jsonl_mirror_audit_rows_scanned: artifacts.jsonl_mirror_audit.rows_scanned,
jsonl_mirror_audit_failures: artifacts.jsonl_mirror_audit.failures,
table_row_counts: artifacts.table_row_counts,
runtime_mode,
proof_state,
claim_ceiling,
authority_class,
};
let tmp_path = staging.join(format!("{MANIFEST_FILENAME}.tmp"));
let manifest_path = staging.join(MANIFEST_FILENAME);
let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| {
eprintln!("cortex backup: failed to serialize backup manifest: {err}");
Exit::Internal
})?;
fs::write(&tmp_path, manifest_bytes).map_err(|err| {
eprintln!(
"cortex backup: failed to write temporary manifest {}: {err}",
tmp_path.display()
);
Exit::PreconditionUnmet
})?;
fs::rename(&tmp_path, &manifest_path).map_err(|err| {
eprintln!(
"cortex backup: failed to publish manifest {}: {err}",
manifest_path.display()
);
Exit::PreconditionUnmet
})?;
Ok(())
}
fn publish_staging_bundle(staging: &Path, output: &Path) -> Result<(), Error> {
fs::create_dir(output)?;
let publish_result = (|| {
fs::rename(
staging.join(SQLITE_BUNDLE_FILENAME),
output.join(SQLITE_BUNDLE_FILENAME),
)?;
fs::rename(
staging.join(JSONL_BUNDLE_FILENAME),
output.join(JSONL_BUNDLE_FILENAME),
)?;
fs::rename(
staging.join(MANIFEST_FILENAME),
output.join(MANIFEST_FILENAME),
)?;
fs::remove_dir(staging)
})();
if publish_result.is_err() {
cleanup_staging(staging);
cleanup_output(output);
}
publish_result
}
fn staging_dir_for(output: &Path) -> PathBuf {
let parent = output.parent().filter(|p| !p.as_os_str().is_empty());
let name = output
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("cortex-backup");
let staging_name = format!(".{name}.staging");
parent
.map(|parent| parent.join(&staging_name))
.unwrap_or_else(|| PathBuf::from(staging_name))
}
fn cleanup_staging(staging: &Path) {
if staging.exists() {
let _ = fs::remove_dir_all(staging);
}
}
fn cleanup_output(output: &Path) {
if output.exists() {
let _ = fs::remove_dir_all(output);
}
}