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
295fn 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
351pub 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
416fn 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
462pub 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(×tamp);
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
513pub 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}