Skip to main content

pharos_runtime_ops/
backup.rs

1use std::error::Error;
2use std::fmt::{self, Display, Formatter};
3use std::fs::{self, File, OpenOptions};
4use std::io::{self, Read, Write};
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use pharos_runtime_database::operations::{DatabaseError, DatabaseOperations};
9use pharos_runtime_database::snapshot::DatabaseSnapshot;
10use serde_json::Value;
11
12const MAX_TEXT_FILE_BYTES: u64 = 4 * 1024 * 1024;
13const MAX_BINARY_FILE_BYTES: u64 = 512 * 1024 * 1024;
14const COPY_BUFFER_BYTES: usize = 64 * 1024;
15const BACKUP_SCHEMA: &str = "pharos.backup.bundle.v2";
16
17#[derive(Debug)]
18pub enum BackupRestoreError {
19    InvalidInput(&'static str),
20    InvalidPath(&'static str),
21    UnsupportedBackend(String),
22    Database(DatabaseError),
23    Io(io::Error),
24    Json(serde_json::Error),
25    FileTooLarge { path: PathBuf, limit: u64 },
26}
27
28impl Display for BackupRestoreError {
29    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::InvalidInput(message) => formatter.write_str(message),
32            Self::InvalidPath(message) => formatter.write_str(message),
33            Self::UnsupportedBackend(backend) => {
34                write!(formatter, "unsupported backup backend: {backend}")
35            }
36            Self::Database(error) => write!(formatter, "database operation failed: {error:?}"),
37            Self::Io(error) => write!(formatter, "backup or restore IO failed: {error}"),
38            Self::Json(error) => write!(formatter, "backup or restore JSON failed: {error}"),
39            Self::FileTooLarge { path, limit } => write!(
40                formatter,
41                "file exceeds the {} byte limit: {}",
42                limit,
43                path.display()
44            ),
45        }
46    }
47}
48
49impl Error for BackupRestoreError {}
50
51impl From<io::Error> for BackupRestoreError {
52    fn from(error: io::Error) -> Self {
53        Self::Io(error)
54    }
55}
56
57impl From<serde_json::Error> for BackupRestoreError {
58    fn from(error: serde_json::Error) -> Self {
59        Self::Json(error)
60    }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct BackupBundleContext {
65    pub target_id: String,
66    pub app_root: PathBuf,
67    pub database: pharos_runtime_database::DbBackendConfig,
68    pub backup_dir: PathBuf,
69    pub migration_contract_path: PathBuf,
70}
71
72fn non_empty<'a>(
73    value: Option<&'a str>,
74    error: &'static str,
75) -> Result<&'a str, BackupRestoreError> {
76    value
77        .map(str::trim)
78        .filter(|value| !value.is_empty())
79        .ok_or(BackupRestoreError::InvalidInput(error))
80}
81
82pub fn validate_path_segment(value: &str, error: &'static str) -> Result<(), BackupRestoreError> {
83    if value.is_empty()
84        || value == "."
85        || value == ".."
86        || value
87            .chars()
88            .any(|character| character.is_control() || character == '/' || character == '\\')
89    {
90        return Err(BackupRestoreError::InvalidPath(error));
91    }
92    Ok(())
93}
94
95fn require_regular_file(path: &Path, error: &'static str) -> Result<(), BackupRestoreError> {
96    if path.as_os_str().is_empty() || !path.is_file() {
97        return Err(BackupRestoreError::InvalidPath(error));
98    }
99    Ok(())
100}
101
102fn read_bounded(path: &Path, limit: u64) -> Result<Vec<u8>, BackupRestoreError> {
103    let file = File::open(path)?;
104    let metadata = file.metadata()?;
105    if !metadata.is_file() {
106        return Err(BackupRestoreError::InvalidPath("expected a regular file"));
107    }
108    if metadata.len() > limit {
109        return Err(BackupRestoreError::FileTooLarge {
110            path: path.to_path_buf(),
111            limit,
112        });
113    }
114
115    let mut bytes = Vec::with_capacity(metadata.len() as usize);
116    let mut reader = file.take(limit.saturating_add(1));
117    reader.read_to_end(&mut bytes)?;
118    if bytes.len() as u64 > limit {
119        return Err(BackupRestoreError::FileTooLarge {
120            path: path.to_path_buf(),
121            limit,
122        });
123    }
124    Ok(bytes)
125}
126
127fn read_json_file(path: &Path) -> Result<Value, BackupRestoreError> {
128    let bytes = read_bounded(path, MAX_TEXT_FILE_BYTES)?;
129    Ok(serde_json::from_slice(&bytes)?)
130}
131
132pub fn json_top_level_long(text: &str, key: &str, fallback_value: i64) -> i64 {
133    if key.is_empty() || text.len() > MAX_TEXT_FILE_BYTES as usize {
134        return fallback_value;
135    }
136    serde_json::from_str::<Value>(text)
137        .ok()
138        .and_then(|root| root.get(key).and_then(Value::as_i64))
139        .unwrap_or(fallback_value)
140}
141
142fn temp_path(path: &Path) -> Result<PathBuf, BackupRestoreError> {
143    let parent = path
144        .parent()
145        .filter(|parent| !parent.as_os_str().is_empty())
146        .ok_or(BackupRestoreError::InvalidPath(
147            "target has no parent directory",
148        ))?;
149    let file_name = path
150        .file_name()
151        .ok_or(BackupRestoreError::InvalidPath("target has no file name"))?
152        .to_string_lossy();
153    let nonce = SystemTime::now()
154        .duration_since(UNIX_EPOCH)
155        .map_err(|_| BackupRestoreError::InvalidInput("system clock is before the Unix epoch"))?
156        .as_nanos();
157    Ok(parent.join(format!(
158        ".{file_name}.pharos-tmp-{}-{nonce}",
159        std::process::id()
160    )))
161}
162
163fn atomic_replace<F>(path: &Path, writer: F) -> Result<(), BackupRestoreError>
164where
165    F: FnOnce(&mut File) -> Result<(), BackupRestoreError>,
166{
167    let parent = path
168        .parent()
169        .filter(|parent| !parent.as_os_str().is_empty())
170        .ok_or(BackupRestoreError::InvalidPath(
171            "target has no parent directory",
172        ))?;
173    fs::create_dir_all(parent)?;
174
175    let temporary = temp_path(path)?;
176    let result = (|| {
177        let mut file = OpenOptions::new()
178            .create_new(true)
179            .write(true)
180            .open(&temporary)?;
181        #[cfg(unix)]
182        {
183            use std::os::unix::fs::PermissionsExt;
184            file.set_permissions(fs::Permissions::from_mode(0o640))?;
185        }
186        writer(&mut file)?;
187        file.sync_all()?;
188        drop(file);
189        fs::rename(&temporary, path)?;
190        #[cfg(unix)]
191        {
192            use std::os::unix::fs::PermissionsExt;
193            fs::set_permissions(path, fs::Permissions::from_mode(0o640))?;
194            OpenOptions::new().read(true).open(parent)?.sync_all()?;
195        }
196        Ok(())
197    })();
198
199    if result.is_err() {
200        let _ = fs::remove_file(&temporary);
201    }
202    result
203}
204
205fn copy_file_to(path: &Path, destination: &mut File) -> Result<(), BackupRestoreError> {
206    let mut source = File::open(path)?;
207    let metadata = source.metadata()?;
208    if !metadata.is_file() {
209        return Err(BackupRestoreError::InvalidPath(
210            "copy source is not a regular file",
211        ));
212    }
213    if metadata.len() > MAX_BINARY_FILE_BYTES {
214        return Err(BackupRestoreError::FileTooLarge {
215            path: path.to_path_buf(),
216            limit: MAX_BINARY_FILE_BYTES,
217        });
218    }
219
220    let mut buffer = [0u8; COPY_BUFFER_BYTES];
221    let mut copied = 0u64;
222    loop {
223        let read = source.read(&mut buffer)?;
224        if read == 0 {
225            break;
226        }
227        copied = copied.saturating_add(read as u64);
228        if copied > MAX_BINARY_FILE_BYTES {
229            return Err(BackupRestoreError::FileTooLarge {
230                path: path.to_path_buf(),
231                limit: MAX_BINARY_FILE_BYTES,
232            });
233        }
234        destination.write_all(&buffer[..read])?;
235    }
236    Ok(())
237}
238
239fn atomic_copy(source: &Path, destination: &Path) -> Result<(), BackupRestoreError> {
240    require_regular_file(source, "copy source is missing")?;
241    if source == destination {
242        return Err(BackupRestoreError::InvalidPath(
243            "copy source and destination must differ",
244        ));
245    }
246    atomic_replace(destination, |file| copy_file_to(source, file))
247}
248
249pub fn write_text_file(path: &Path, content: &str) -> Result<(), BackupRestoreError> {
250    if content.len() as u64 > MAX_TEXT_FILE_BYTES {
251        return Err(BackupRestoreError::FileTooLarge {
252            path: path.to_path_buf(),
253            limit: MAX_TEXT_FILE_BYTES,
254        });
255    }
256    atomic_replace(path, |file| {
257        file.write_all(content.as_bytes())?;
258        Ok(())
259    })
260}
261
262fn civil_date_from_days(days_since_epoch: i64) -> (i64, u32, u32) {
263    let shifted = days_since_epoch + 719_468;
264    let era = if shifted >= 0 {
265        shifted / 146_097
266    } else {
267        (shifted - 146_096) / 146_097
268    };
269    let day_of_era = shifted - era * 146_097;
270    let year_of_era =
271        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
272    let year = year_of_era + era * 400;
273    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
274    let month_part = (5 * day_of_year + 2) / 153;
275    let day = day_of_year - (153 * month_part + 2) / 5 + 1;
276    let month = month_part + if month_part < 10 { 3 } else { -9 };
277    (year + i64::from(month <= 2), month as u32, day as u32)
278}
279
280pub fn backup_timestamp_utc() -> Result<String, BackupRestoreError> {
281    let elapsed = SystemTime::now()
282        .duration_since(UNIX_EPOCH)
283        .map_err(|_| BackupRestoreError::InvalidInput("system clock is before the Unix epoch"))?;
284    let seconds = elapsed.as_secs();
285    let (year, month, day) = civil_date_from_days((seconds / 86_400) as i64);
286    let day_seconds = seconds % 86_400;
287    let hour = day_seconds / 3_600;
288    let minute = (day_seconds % 3_600) / 60;
289    let second = day_seconds % 60;
290    Ok(format!(
291        "{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z"
292    ))
293}
294
295/// Reads compiled metadata for runtime operations; source-only JSON imports remain compatible.
296fn read_manifest_value(app_root: &Path) -> Result<Value, BackupRestoreError> {
297    if app_root.join("pharos.app.json").is_file() {
298        return read_json_file(&app_root.join("pharos.app.json"));
299    }
300    let source = app_root.join("pharos.app.yml");
301    if source.exists() {
302        let bytes = read_bounded(&source, MAX_TEXT_FILE_BYTES)?;
303        let text = std::str::from_utf8(&bytes)
304            .map_err(|_| BackupRestoreError::InvalidInput("manifest encoding is invalid"))?;
305        return pharos_runtime_core::yaml::parse_yaml_text(text)
306            .ok_or(BackupRestoreError::InvalidInput("manifest is invalid"));
307    }
308    read_json_file(&app_root.join("pharos.app.json"))
309}
310
311pub fn migration_contract_path_from_manifest(
312    app_root: &Path,
313) -> Result<PathBuf, BackupRestoreError> {
314    if app_root.as_os_str().is_empty() || !app_root.is_dir() {
315        return Err(BackupRestoreError::InvalidPath(
316            "app root is not a directory",
317        ));
318    }
319    let manifest = read_manifest_value(app_root)?;
320    let relative = manifest
321        .get("database")
322        .and_then(Value::as_object)
323        .and_then(|database| database.get("migration_contract"))
324        .and_then(Value::as_str)
325        .filter(|value| !value.trim().is_empty())
326        .ok_or(BackupRestoreError::InvalidInput(
327            "manifest migration_contract is required",
328        ))?;
329    let relative_path = Path::new(relative);
330    if relative_path.extension().and_then(|value| value.to_str()) != Some("json")
331        || relative_path
332            .components()
333            .any(|component| !matches!(component, std::path::Component::Normal(_)))
334    {
335        return Err(BackupRestoreError::InvalidPath(
336            "migration fixture must be relative to the app root",
337        ));
338    }
339    let migration_contract = app_root.join(relative_path);
340    let canonical_root = app_root.canonicalize()?;
341    let canonical_contract = migration_contract.canonicalize()?;
342    if canonical_contract != canonical_root.join(relative_path) {
343        return Err(BackupRestoreError::InvalidPath(
344            "migration fixture escapes the app root",
345        ));
346    }
347    require_regular_file(&canonical_contract, "migration fixture is missing")?;
348    Ok(canonical_contract)
349}
350
351/// Reports the number of ordered logical migrations after validating their checksums.
352pub fn migration_schema_version(migration_contract_path: &Path) -> Result<i64, BackupRestoreError> {
353    let contract = read_json_file(migration_contract_path)?;
354    let app_id = contract
355        .get("schema")
356        .and_then(|schema| schema.get("app_id"))
357        .and_then(Value::as_str)
358        .ok_or(BackupRestoreError::InvalidInput(
359            "logical migration app_id is required",
360        ))?;
361    let contract =
362        pharos_runtime_database::contract::load_contract(migration_contract_path, app_id)
363            .map_err(BackupRestoreError::Database)?;
364    i64::try_from(contract.migrations.len())
365        .map_err(|_| BackupRestoreError::InvalidInput("migration count exceeds supported range"))
366}
367
368pub fn write_backup_state_snapshot(
369    state_path: &Path,
370    output_path: &Path,
371) -> Result<(), BackupRestoreError> {
372    atomic_copy(state_path, output_path)
373}
374
375fn metadata_artifact_value_from_root(
376    metadata: &Value,
377    key: &str,
378) -> Result<String, BackupRestoreError> {
379    if key.is_empty() {
380        return Err(BackupRestoreError::InvalidInput(
381            "metadata artifact key is required",
382        ));
383    }
384    metadata
385        .get("artifacts")
386        .and_then(Value::as_object)
387        .and_then(|artifacts| artifacts.get(key))
388        .and_then(Value::as_str)
389        .map(str::trim)
390        .filter(|value| !value.is_empty())
391        .map(ToOwned::to_owned)
392        .ok_or(BackupRestoreError::InvalidInput(
393            "required metadata artifact is missing",
394        ))
395}
396
397pub fn metadata_artifact_value(
398    metadata_path: &Path,
399    key: &str,
400) -> Result<String, BackupRestoreError> {
401    let metadata = read_json_file(metadata_path)?;
402    metadata_artifact_value_from_root(&metadata, key)
403}
404
405fn override_text<'a>(
406    value: Option<&'a str>,
407    error: &'static str,
408) -> Result<Option<&'a str>, BackupRestoreError> {
409    match value {
410        Some(value) if value.trim().is_empty() => Err(BackupRestoreError::InvalidInput(error)),
411        Some(value) => Ok(Some(value.trim())),
412        None => Ok(None),
413    }
414}
415
416/// Requires the configured backend instead of allowing a backup flag to redirect data.
417fn configured_backend(
418    context: &BackupBundleContext,
419    backend_override: Option<&str>,
420) -> Result<String, BackupRestoreError> {
421    if override_text(backend_override, "backend override is empty")?
422        .is_some_and(|backend| backend != context.database.backend)
423    {
424        return Err(BackupRestoreError::InvalidInput(
425            "backend override differs from runtime configuration",
426        ));
427    }
428    Ok(context.database.backend.clone())
429}
430
431fn input_artifact_path(input_dir: &Path, reference: &str) -> Result<PathBuf, BackupRestoreError> {
432    let input_root = input_dir.canonicalize()?;
433    let reference_path = Path::new(reference);
434    let candidate = if reference_path.is_absolute() {
435        reference_path.to_path_buf()
436    } else {
437        input_root.join(reference_path)
438    };
439    let canonical = candidate.canonicalize()?;
440    if !canonical.starts_with(&input_root) {
441        return Err(BackupRestoreError::InvalidPath(
442            "metadata artifact escapes the restore bundle",
443        ));
444    }
445    require_regular_file(&canonical, "metadata artifact is missing")?;
446    Ok(canonical)
447}
448
449fn checked_target_id(context: &BackupBundleContext) -> Result<&str, BackupRestoreError> {
450    let target_id = non_empty(
451        Some(context.target_id.as_str()),
452        "resolved target_id is required",
453    )?;
454    validate_path_segment(target_id, "target_id must be a single safe path segment")?;
455    Ok(target_id)
456}
457
458fn string_path(path: &Path) -> Value {
459    Value::String(path.to_string_lossy().into_owned())
460}
461
462/// Captures a backend-independent database archive through the configured operation interface.
463pub fn build_backup_report_from_context(
464    context: &BackupBundleContext,
465    adapter: &dyn DatabaseOperations,
466    backend_override: Option<&str>,
467    output_dir_override: Option<&Path>,
468) -> Result<String, BackupRestoreError> {
469    let target_id = checked_target_id(context)?;
470    let backend = configured_backend(context, backend_override)?;
471    let base_dir = match output_dir_override {
472        Some(path) if !path.as_os_str().is_empty() => path.to_path_buf(),
473        Some(_) => {
474            return Err(BackupRestoreError::InvalidInput(
475                "output directory is empty",
476            ));
477        }
478        None => context.backup_dir.clone(),
479    };
480    let contract = pharos_runtime_database::contract::load_contract(
481        &context.migration_contract_path,
482        target_id,
483    )
484    .map_err(BackupRestoreError::Database)?;
485    let snapshot = DatabaseSnapshot::capture(adapter, &context.database, &contract)
486        .map_err(BackupRestoreError::Database)?;
487    let encoded = snapshot.encode().map_err(BackupRestoreError::Database)?;
488    let timestamp = backup_timestamp_utc()?;
489    let backup_dir = base_dir.join(target_id).join(&timestamp);
490    fs::create_dir_all(base_dir.join(target_id))?;
491    fs::create_dir(&backup_dir)?;
492    let snapshot_path = backup_dir.join("database.json");
493    atomic_replace(&snapshot_path, |file| {
494        file.write_all(encoded.as_bytes())?;
495        Ok(())
496    })?;
497    let metadata_path = backup_dir.join("metadata.json");
498    let metadata = serde_json::json!({
499        "schema": BACKUP_SCHEMA, "target_kind": "app", "target_id": target_id,
500        "backend": backend, "created_at": timestamp,
501        "contract_fingerprint": snapshot.contract_fingerprint,
502        "snapshot_sha256": pharos_runtime_database::operations::bytes_fingerprint(encoded.as_bytes()),
503        "artifacts": { "database_snapshot": "database.json" }
504    });
505    write_text_file(&metadata_path, &serde_json::to_string(&metadata)?)?;
506    Ok(serde_json::to_string(&serde_json::json!({
507        "report_id": "pharos-backup-v1", "target_kind": "app", "target_id": target_id,
508        "backend": backend, "status": "backed_up", "backup_dir": string_path(&backup_dir),
509        "metadata_path": string_path(&metadata_path), "database_snapshot": string_path(&snapshot_path)
510    }))?)
511}
512
513/// Restores a portable archive atomically without copying a physical database or JSON projection.
514pub fn build_restore_report_from_context(
515    context: &BackupBundleContext,
516    adapter: &dyn DatabaseOperations,
517    backend_override: Option<&str>,
518    input_dir: &Path,
519) -> Result<String, BackupRestoreError> {
520    let target_id = checked_target_id(context)?;
521    let backend = configured_backend(context, backend_override)?;
522    if input_dir.as_os_str().is_empty() || !input_dir.is_dir() {
523        return Err(BackupRestoreError::InvalidPath(
524            "restore input directory is missing",
525        ));
526    }
527    let metadata = read_json_file(&input_dir.join("metadata.json"))?;
528    if metadata.get("schema").and_then(Value::as_str) != Some(BACKUP_SCHEMA)
529        || metadata.get("target_id").and_then(Value::as_str) != Some(target_id)
530    {
531        return Err(BackupRestoreError::InvalidInput(
532            "restore metadata does not match the requested app bundle",
533        ));
534    }
535    let snapshot_reference = metadata_artifact_value_from_root(&metadata, "database_snapshot")?;
536    let snapshot_path = input_artifact_path(input_dir, &snapshot_reference)?;
537    let bytes = read_bounded(
538        &snapshot_path,
539        pharos_runtime_database::snapshot::OPERATOR_LIMITS.max_bytes,
540    )?;
541    if metadata.get("snapshot_sha256").and_then(Value::as_str)
542        != Some(pharos_runtime_database::operations::bytes_fingerprint(&bytes).as_str())
543    {
544        return Err(BackupRestoreError::InvalidInput(
545            "database snapshot checksum mismatch",
546        ));
547    }
548    let snapshot = DatabaseSnapshot::decode(&bytes).map_err(BackupRestoreError::Database)?;
549    if metadata.get("contract_fingerprint").and_then(Value::as_str)
550        != Some(snapshot.contract_fingerprint.as_str())
551    {
552        return Err(BackupRestoreError::InvalidInput(
553            "database snapshot contract mismatch",
554        ));
555    }
556    let contract = pharos_runtime_database::contract::load_contract(
557        &context.migration_contract_path,
558        target_id,
559    )
560    .map_err(BackupRestoreError::Database)?;
561    snapshot
562        .restore(adapter, &context.database, &contract, &[])
563        .map_err(BackupRestoreError::Database)?;
564    Ok(serde_json::to_string(&serde_json::json!({
565        "report_id": "pharos-restore-v1", "target_kind": "app", "target_id": target_id,
566        "backend": backend, "input_dir": string_path(input_dir), "status": "restored"
567    }))?)
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use std::time::{SystemTime, UNIX_EPOCH};
574
575    fn unique_temp_dir(label: &str) -> PathBuf {
576        let nonce = SystemTime::now()
577            .duration_since(UNIX_EPOCH)
578            .expect("clock should be after epoch")
579            .as_nanos();
580        let path = std::env::temp_dir().join(format!(
581            "pharos-ops-backup-test-{label}-{}-{nonce}",
582            std::process::id()
583        ));
584        fs::create_dir_all(&path).expect("temp dir should be created");
585        path
586    }
587
588    fn write_manifest(root: &Path, manifest_text: &str) {
589        fs::write(root.join("pharos.app.json"), manifest_text).expect("manifest should be written");
590        let contracts_dir = root.join("data/contracts");
591        fs::create_dir_all(&contracts_dir).expect("contracts dir should be created");
592        fs::write(
593            contracts_dir.join("migrations.json"),
594            "{\"schema_version\":1}\n",
595        )
596        .expect("migration contract should be written");
597    }
598
599    #[test]
600    fn migration_contract_path_reads_database_contract_from_built_manifest() {
601        let root = unique_temp_dir("database-contract");
602        write_manifest(
603            &root,
604            r#"{
605  "database": {
606    "migration_contract": "data/contracts/migrations.json"
607  }
608}
609"#,
610        );
611        let contract = migration_contract_path_from_manifest(&root)
612            .expect("database migration contract should resolve");
613        assert_eq!(
614            contract,
615            root.join("data/contracts/migrations.json")
616                .canonicalize()
617                .expect("contract path should canonicalize")
618        );
619        fs::remove_dir_all(&root).expect("temp dir should be removed");
620    }
621
622    #[test]
623    fn migration_contract_path_rejects_undeclared_legacy_contract() {
624        let root = unique_temp_dir("legacy-contract");
625        write_manifest(
626            &root,
627            r#"{
628  "contracts": {
629    "migration_contract": "data/contracts/migrations.json"
630  }
631}
632"#,
633        );
634        assert!(migration_contract_path_from_manifest(&root).is_err());
635        fs::remove_dir_all(&root).expect("temp dir should be removed");
636    }
637}