Skip to main content

pharos_runtime_ops/
lib.rs

1#![forbid(unsafe_code)]
2
3pub mod backup;
4pub mod provenance;
5
6pub use backup::{
7    BackupBundleContext, BackupRestoreError, backup_timestamp_utc,
8    build_backup_report_from_context, build_restore_report_from_context, json_top_level_long,
9    metadata_artifact_value, migration_contract_path_from_manifest, migration_schema_version,
10    validate_path_segment, write_backup_state_snapshot, write_text_file,
11};
12
13use std::fs::{self, OpenOptions};
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use std::time::{Instant, SystemTime, UNIX_EPOCH};
18
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21
22pub const DOCS_BUILD_REQUEST_SCHEMA: &str = "pharos.docs.build_request.v1";
23pub const MAX_DOCS_BUILD_REQUEST_BYTES: u64 = 64 * 1024;
24const DOCS_BUILD_RESPONSE_SCHEMA: &str = "pharos.docs.build_response.v1";
25const MAX_REPORT_BYTES: usize = 2 * 1024 * 1024;
26const MAX_NO_INDEX_DEPTH: usize = 64;
27
28#[derive(Debug, Deserialize)]
29pub struct DocsBuildRequest {
30    pub schema: String,
31    pub app_id: String,
32    pub app_root: String,
33    pub build_dir: String,
34    pub target_docs_dir: String,
35    #[serde(default)]
36    pub check_fingerprints: bool,
37}
38
39#[derive(Debug, Serialize)]
40pub struct DocsBuildResponse {
41    schema: String,
42    pub ok: bool,
43    artifact_dir: String,
44    rebuilt: bool,
45    elapsed_ms: u128,
46    diagnostics: Vec<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    error_code: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    message: Option<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DocsBuildJob {
55    pub repo_root: PathBuf,
56    pub app_id: String,
57    pub app_root: PathBuf,
58    pub build_dir: PathBuf,
59    pub target_docs_dir: PathBuf,
60    pub check_fingerprints: bool,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct BackupRestoreJob {
65    pub verb: String,
66    pub app_id: String,
67    pub app_root: PathBuf,
68    pub conf_path: Option<String>,
69    pub build_dir: Option<String>,
70    pub state_dir: Option<String>,
71    pub state_path: Option<String>,
72    pub backend: Option<String>,
73    pub output_dir: Option<PathBuf>,
74    pub input_dir: Option<PathBuf>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct BackupRestoreRuntimeContext {
79    pub config: pharos_runtime_core::config::ServeConfig,
80    pub app: pharos_runtime_core::config::AppRuntime,
81    pub bundle: BackupBundleContext,
82}
83
84pub fn prepare_backup_restore_runtime_context(
85    job: &BackupRestoreJob,
86) -> Result<BackupRestoreRuntimeContext, i32> {
87    if !job.app_root.is_dir() {
88        eprintln!(
89            "backup and restore app directory does not exist: {}",
90            job.app_root.display()
91        );
92        return Err(66);
93    }
94    let mut config_record = pharos_runtime_core::config::ServeConfig {
95        kind: Some("app".to_owned()),
96        target_id: Some(job.app_id.clone()),
97        conf_path: job.conf_path.clone(),
98        app_dir: Some(job.app_root.to_string_lossy().into_owned()),
99        build_dir: job.build_dir.clone(),
100        state_dir: job.state_dir.clone(),
101        state_path: job.state_path.clone(),
102        ..pharos_runtime_core::config::ServeConfig::default()
103    };
104    pharos_runtime_core::config::apply_serve_config_defaults_from_conf(&mut config_record);
105    let build_dir = config_record
106        .build_dir
107        .clone()
108        .unwrap_or_else(|| "dist".to_owned());
109    let app = pharos_runtime_core::config::AppRuntime {
110        app_id: Some(job.app_id.clone()),
111        app_root: Some(job.app_root.to_string_lossy().into_owned()),
112        build_dir: Some(build_dir),
113        state_path: config_record.state_path.clone(),
114        runtime_conf_path: config_record.conf_path.clone(),
115        ..pharos_runtime_core::config::AppRuntime::default()
116    };
117    let bundle = backup_bundle_context_from_core_config(&config_record, &app)?;
118    Ok(BackupRestoreRuntimeContext {
119        config: config_record,
120        app,
121        bundle,
122    })
123}
124
125pub fn backup_bundle_context_from_core_config(
126    config: &pharos_runtime_core::config::ServeConfig,
127    app: &pharos_runtime_core::config::AppRuntime,
128) -> Result<BackupBundleContext, i32> {
129    let root = app
130        .app_root
131        .as_deref()
132        .map(str::trim)
133        .filter(|value| !value.is_empty())
134        .map(PathBuf::from)
135        .ok_or_else(|| {
136            eprintln!("backup requires an app root");
137            64
138        })?;
139    if !root.is_dir() {
140        eprintln!("app root is not a directory: {}", root.display());
141        return Err(66);
142    }
143    let target_id = config
144        .target_id
145        .as_deref()
146        .map(str::trim)
147        .filter(|value| !value.is_empty())
148        .ok_or_else(|| {
149            eprintln!("resolved target_id is required");
150            64
151        })?;
152    validate_path_segment(target_id, "target_id must be a single safe path segment").map_err(
153        |error| {
154            eprintln!("backup or restore failed: {error:?}");
155            map_backup_restore_error(&error)
156        },
157    )?;
158    let configured_value = |key: &'static str| -> Result<String, i32> {
159        pharos_runtime_core::config::dynamic_config_effective_value(
160            &root,
161            app.runtime_conf_path.as_deref().map(Path::new),
162            key,
163        )
164        .filter(|value| !value.trim().is_empty())
165        .ok_or_else(|| {
166            eprintln!(
167                "{}",
168                match key {
169                    "db_backend" => "db_backend is required for backup or restore",
170                    "db_path" => "db_path is required for backup or restore",
171                    "backup_dir" => "backup_dir is required for backup",
172                    _ => "required runtime configuration is missing",
173                }
174            );
175            64
176        })
177    };
178    let selected_contract = if root.join("pharos.app.json").is_file() {
179        migration_contract_path_from_manifest(&root)
180    } else {
181        let build_dir = app
182            .build_dir
183            .as_deref()
184            .or(config.build_dir.as_deref())
185            .unwrap_or("dist");
186        let artifact = pharos_runtime_core::config::effective_app_artifact_dir(
187            &root,
188            Path::new(build_dir),
189            target_id,
190        )
191        .ok_or(64)?;
192        pharos_runtime_compiler::database::compile_selected(&root, Path::new(&artifact), target_id)
193            .map_err(|_| BackupRestoreError::InvalidInput("source database compilation failed"))
194    };
195    let migration_contract_path = selected_contract.map_err(|error| {
196        eprintln!("backup or restore failed: {error:?}");
197        map_backup_restore_error(&error)
198    })?;
199    let database = pharos_runtime_database::DbBackendConfig::from_lookup(target_id, |key| {
200        pharos_runtime_core::config::dynamic_config_effective_value(
201            &root,
202            app.runtime_conf_path.as_deref().map(Path::new),
203            key,
204        )
205    })
206    .map_err(|_| 64)?;
207    let backup_dir = PathBuf::from(configured_value("backup_dir")?);
208    Ok(BackupBundleContext {
209        target_id: target_id.to_owned(),
210        app_root: root,
211        database,
212        backup_dir,
213        migration_contract_path,
214    })
215}
216
217pub fn map_backup_restore_error(error: &BackupRestoreError) -> i32 {
218    match error {
219        BackupRestoreError::InvalidInput(_) | BackupRestoreError::InvalidPath(_) => 64,
220        BackupRestoreError::UnsupportedBackend(_) => 78,
221        BackupRestoreError::Database(
222            pharos_runtime_database::operations::DatabaseError::InvalidInput
223            | pharos_runtime_database::operations::DatabaseError::InvalidContract,
224        ) => 64,
225        BackupRestoreError::Database(
226            pharos_runtime_database::operations::DatabaseError::Conflict,
227        ) => 65,
228        BackupRestoreError::Database(_) => 69,
229        BackupRestoreError::Json(_) => 70,
230        BackupRestoreError::Io(_) | BackupRestoreError::FileTooLarge { .. } => 69,
231    }
232}
233
234pub fn sibling_pharos_binary() -> Result<PathBuf, String> {
235    if let Some(value) = std::env::var_os("PHAROS_NATIVE_BIN") {
236        let path = PathBuf::from(value);
237        if path.is_file() {
238            return Ok(path);
239        }
240    }
241    let exe = std::env::current_exe().map_err(|_| "could not resolve helper path".to_owned())?;
242    let Some(dir) = exe.parent() else {
243        return Err("could not resolve helper directory".to_owned());
244    };
245    let path = dir.join(if cfg!(windows) {
246        "pharos.exe"
247    } else {
248        "pharos"
249    });
250    if path.is_file() {
251        return Ok(path);
252    }
253    Err("could not locate sibling pharos binary".to_owned())
254}
255
256pub fn build_docs_with_pharos_binary(
257    job: &DocsBuildJob,
258    pharos: &Path,
259) -> Result<String, (String, String)> {
260    let output = Command::new(pharos)
261        .arg("build")
262        .arg("app")
263        .arg(&job.app_id)
264        .arg("--app-dir")
265        .arg(&job.app_root)
266        .arg("--build-dir")
267        .arg(&job.build_dir)
268        .current_dir(&job.repo_root)
269        .output()
270        .map_err(|_| {
271            (
272                "build_failed".to_owned(),
273                "could not start pharos app build".to_owned(),
274            )
275        })?;
276    if !output.status.success() {
277        let stderr = String::from_utf8_lossy(&output.stderr);
278        return Err((
279            "build_failed".to_owned(),
280            stderr
281                .lines()
282                .next()
283                .unwrap_or("pharos app build failed")
284                .to_owned(),
285        ));
286    }
287    Ok(job.target_docs_dir.to_string_lossy().into_owned())
288}
289
290pub fn build_docs_with_sibling_pharos(job: &DocsBuildJob) -> Result<String, (String, String)> {
291    let pharos =
292        sibling_pharos_binary().map_err(|message| ("helper_unavailable".to_owned(), message))?;
293    build_docs_with_pharos_binary(job, &pharos)
294}
295
296pub trait OpsRuntimeAdapter {
297    fn build_docs(&self, job: &DocsBuildJob) -> Result<String, (String, String)>;
298    fn backup_app(&self, job: &BackupRestoreJob) -> Result<String, i32>;
299    fn restore_app(&self, job: &BackupRestoreJob) -> Result<String, i32>;
300}
301
302pub fn docs_build_request_from_args(args: &[String]) -> Result<DocsBuildRequest, &'static str> {
303    if args.first().map(String::as_str) != Some("build") {
304        return Err("unsupported docs builder command");
305    }
306    Ok(DocsBuildRequest {
307        schema: DOCS_BUILD_REQUEST_SCHEMA.to_owned(),
308        app_id: option_value(args, "--app-id")
309            .unwrap_or("dev_docs")
310            .to_owned(),
311        app_root: option_value(args, "--app-root")
312            .ok_or("missing --app-root")?
313            .to_owned(),
314        build_dir: option_value(args, "--build-dir")
315            .ok_or("missing --build-dir")?
316            .to_owned(),
317        target_docs_dir: option_value(args, "--target-docs-dir")
318            .ok_or("missing --target-docs-dir")?
319            .to_owned(),
320        check_fingerprints: args.iter().any(|value| value == "--check-fingerprints"),
321    })
322}
323
324pub fn docs_build_request_from_reader(reader: impl Read) -> Result<DocsBuildRequest, &'static str> {
325    let mut bytes = Vec::new();
326    reader
327        .take(MAX_DOCS_BUILD_REQUEST_BYTES.saturating_add(1))
328        .read_to_end(&mut bytes)
329        .map_err(|_| "invalid docs builder request")?;
330    if bytes.is_empty() || bytes.len() as u64 > MAX_DOCS_BUILD_REQUEST_BYTES {
331        return Err("invalid docs builder request");
332    }
333    serde_json::from_slice::<DocsBuildRequest>(&bytes).map_err(|_| "invalid docs builder request")
334}
335
336pub fn run_docs_build_request(
337    request: DocsBuildRequest,
338    adapter: &impl OpsRuntimeAdapter,
339) -> DocsBuildResponse {
340    let started = Instant::now();
341    match prepare_docs_build_job(&request).and_then(|job| adapter.build_docs(&job)) {
342        Ok(artifact_dir) => DocsBuildResponse {
343            schema: DOCS_BUILD_RESPONSE_SCHEMA.to_owned(),
344            ok: true,
345            artifact_dir,
346            rebuilt: true,
347            elapsed_ms: started.elapsed().as_millis(),
348            diagnostics: if request.check_fingerprints {
349                vec!["fingerprint-aware docs build path used".to_owned()]
350            } else {
351                Vec::new()
352            },
353            error_code: None,
354            message: None,
355        },
356        Err((code, message)) => DocsBuildResponse {
357            schema: DOCS_BUILD_RESPONSE_SCHEMA.to_owned(),
358            ok: false,
359            artifact_dir: String::new(),
360            rebuilt: false,
361            elapsed_ms: started.elapsed().as_millis(),
362            diagnostics: Vec::new(),
363            error_code: Some(code),
364            message: Some(message),
365        },
366    }
367}
368
369pub fn docs_build_invalid_input_response(message: &str) -> DocsBuildResponse {
370    DocsBuildResponse {
371        schema: DOCS_BUILD_RESPONSE_SCHEMA.to_owned(),
372        ok: false,
373        artifact_dir: String::new(),
374        rebuilt: false,
375        elapsed_ms: 0,
376        diagnostics: Vec::new(),
377        error_code: Some("invalid_input".to_owned()),
378        message: Some(message.to_owned()),
379    }
380}
381
382pub fn prepare_docs_build_job(
383    request: &DocsBuildRequest,
384) -> Result<DocsBuildJob, (String, String)> {
385    if request.schema != DOCS_BUILD_REQUEST_SCHEMA {
386        return Err((
387            "invalid_schema".to_owned(),
388            "unsupported docs build request schema".to_owned(),
389        ));
390    }
391    if request.app_id != "dev_docs" {
392        return Err((
393            "invalid_app".to_owned(),
394            "docs builder only accepts dev_docs".to_owned(),
395        ));
396    }
397    let repo_root = resolve_repo_root_path().map_err(|_| {
398        (
399            "invalid_environment".to_owned(),
400            "could not resolve Pharos repository root".to_owned(),
401        )
402    })?;
403    let app_root = resolve_repo_path(&repo_root, &request.app_root, true)
404        .map_err(|message| ("invalid_app_root".to_owned(), message))?;
405    let build_dir = resolve_repo_path(&repo_root, &request.build_dir, false)
406        .map_err(|message| ("invalid_build_dir".to_owned(), message))?;
407    std::fs::create_dir_all(&build_dir).map_err(|_| {
408        (
409            "invalid_build_dir".to_owned(),
410            "could not create docs build directory".to_owned(),
411        )
412    })?;
413    let expected_target = build_dir.join("apps").join("dev_docs");
414    let target_docs_dir = resolve_repo_path(&repo_root, &request.target_docs_dir, false)
415        .map_err(|message| ("invalid_target_docs_dir".to_owned(), message))?;
416    if lexical_normalize(&target_docs_dir) != lexical_normalize(&expected_target) {
417        return Err((
418            "invalid_target_docs_dir".to_owned(),
419            "target_docs_dir must equal build_dir/apps/dev_docs".to_owned(),
420        ));
421    }
422    Ok(DocsBuildJob {
423        repo_root,
424        app_id: request.app_id.clone(),
425        app_root,
426        build_dir,
427        target_docs_dir,
428        check_fingerprints: request.check_fingerprints,
429    })
430}
431
432pub fn run_ops_helper_args(
433    args: &[String],
434    adapter: &impl OpsRuntimeAdapter,
435) -> Result<String, i32> {
436    match args.first().map(String::as_str) {
437        Some("provenance") => run_provenance_command(args),
438        Some("backup") | Some("restore") => run_backup_restore_command(args, adapter),
439        _ => Err(64),
440    }
441}
442
443pub fn run_provenance_command(args: &[String]) -> Result<String, i32> {
444    match args.get(1).map(String::as_str) {
445        Some("keygen") => {
446            let path = PathBuf::from(option_value(args, "--key").ok_or(64)?);
447            let public_path = PathBuf::from(option_value(args, "--public-key").ok_or(64)?);
448            let key = crate::provenance::generate_private_key().map_err(|_| 70)?;
449            let public_key = crate::provenance::public_key_from_private(&key).map_err(|_| 70)?;
450            pharos_runtime_core::fs::write_binary_file(&path, &key, 0o600).map_err(|_| 73)?;
451            pharos_runtime_core::fs::write_binary_file(&public_path, &public_key, 0o644)
452                .map_err(|_| 73)?;
453            Ok(json!({
454                "schema": "pharos.provenance-key.v1",
455                "status": "created",
456                "private_key_path": path,
457                "public_key_path": public_path,
458            })
459            .to_string())
460        }
461        Some("contract-set-digest") => {
462            let apps_dir = PathBuf::from(option_value(args, "--apps-dir").ok_or(64)?);
463            crate::provenance::app_contract_set_sha256(&apps_dir).ok_or(74)
464        }
465        Some("sign") => {
466            let artifact = PathBuf::from(option_value(args, "--artifact").ok_or(64)?);
467            let sbom = PathBuf::from(option_value(args, "--sbom").ok_or(64)?);
468            let key_path = PathBuf::from(option_value(args, "--key").ok_or(64)?);
469            let output = PathBuf::from(option_value(args, "--output").ok_or(64)?);
470            let key = std::fs::read(key_path).map_err(|_| 66)?;
471            let attestation =
472                crate::provenance::sign_attestation(&crate::provenance::SignAttestationRequest {
473                    artifact_path: &artifact,
474                    sbom_path: &sbom,
475                    source_revision: option_value(args, "--source-revision").ok_or(64)?,
476                    cargo_lock_sha256: option_value(args, "--cargo-lock-sha256").ok_or(64)?,
477                    toolchain: option_value(args, "--toolchain").ok_or(64)?,
478                    build_configuration: option_value(args, "--build-configuration").ok_or(64)?,
479                    app_contract_sha256: option_value(args, "--app-contract-sha256").ok_or(64)?,
480                    private_key_pkcs8: &key,
481                })
482                .map_err(|_| 74)?;
483            let encoded = serde_json::to_vec_pretty(&attestation).map_err(|_| 70)?;
484            pharos_runtime_core::fs::write_binary_file(&output, &encoded, 0o644).map_err(|_| 73)?;
485            Ok(json!({
486                "schema": "pharos.provenance-sign.v1",
487                "status": "signed",
488                "attestation": output,
489            })
490            .to_string())
491        }
492        Some("verify") => {
493            let artifact = PathBuf::from(option_value(args, "--artifact").ok_or(64)?);
494            let sbom = PathBuf::from(option_value(args, "--sbom").ok_or(64)?);
495            let path = PathBuf::from(option_value(args, "--attestation").ok_or(64)?);
496            let public_key_path = PathBuf::from(option_value(args, "--public-key").ok_or(64)?);
497            let trusted_public_key = std::fs::read(public_key_path).map_err(|_| 66)?;
498            let document = std::fs::read_to_string(path)
499                .ok()
500                .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
501                .ok_or(74)?;
502            crate::provenance::verify_attestation(&document, &artifact, &sbom, &trusted_public_key)
503                .map_err(|_| 74)?;
504            Ok(json!({
505                "schema": "pharos.provenance-verify.v1",
506                "status": "verified",
507            })
508            .to_string())
509        }
510        _ => Err(64),
511    }
512}
513
514pub fn prepare_backup_restore_job(args: &[String]) -> Result<BackupRestoreJob, i32> {
515    let verb = args.first().cloned().ok_or(64)?;
516    if !matches!(verb.as_str(), "backup" | "restore") {
517        return Err(64);
518    }
519    if args.get(1).map(String::as_str) != Some("app") {
520        eprintln!("backup and restore require: <backup|restore> app <app-id>");
521        return Err(64);
522    }
523    let app_id = match args.get(2).filter(|value| !value.is_empty()) {
524        Some(value) => value.to_owned(),
525        None => {
526            eprintln!("backup and restore require a non-empty app id");
527            return Err(64);
528        }
529    };
530    let app_dir = match option_value(args, "--app-dir") {
531        Some(value) => value,
532        None => {
533            eprintln!("backup and restore require --app-dir");
534            return Err(64);
535        }
536    };
537    let app_root = PathBuf::from(app_dir);
538    if !app_root.is_dir() {
539        eprintln!(
540            "backup and restore app directory does not exist: {}",
541            app_root.display()
542        );
543        return Err(66);
544    }
545    let output_dir = option_value(args, "--output-dir").map(PathBuf::from);
546    let input_dir = option_value(args, "--input-dir").map(PathBuf::from);
547    if verb == "restore" && input_dir.is_none() {
548        eprintln!("restore requires --input-dir");
549        return Err(64);
550    }
551    Ok(BackupRestoreJob {
552        verb,
553        app_id,
554        app_root,
555        conf_path: option_value(args, "--conf").map(ToOwned::to_owned),
556        build_dir: option_value(args, "--build-dir").map(ToOwned::to_owned),
557        state_dir: option_value(args, "--state-dir").map(ToOwned::to_owned),
558        state_path: option_value(args, "--state-path").map(ToOwned::to_owned),
559        backend: option_value(args, "--backend").map(ToOwned::to_owned),
560        output_dir,
561        input_dir,
562    })
563}
564
565fn run_backup_restore_command(
566    args: &[String],
567    adapter: &impl OpsRuntimeAdapter,
568) -> Result<String, i32> {
569    let job = prepare_backup_restore_job(args)?;
570    match job.verb.as_str() {
571        "backup" => adapter.backup_app(&job),
572        "restore" => adapter.restore_app(&job),
573        _ => Err(64),
574    }
575}
576
577pub fn push_argv_value(argv: &mut Vec<String>, value: Option<&str>, capacity: usize) -> i32 {
578    if capacity == 0 || argv.len() >= capacity.saturating_sub(1) {
579        return 69;
580    }
581    let Some(value) = value else {
582        return 69;
583    };
584    if value.contains('\0') || value.len() > 64 * 1024 {
585        return 64;
586    }
587    argv.push(value.to_owned());
588    0
589}
590
591pub fn cleanup_temp_body_file(path: &mut Option<PathBuf>) {
592    if let Some(path) = path.take() {
593        let _ = fs::remove_file(path);
594    }
595}
596
597pub fn write_temp_body_file(body: Option<&[u8]>) -> Result<PathBuf, i32> {
598    let body = body.unwrap_or_default();
599    if body.len() > 16 * 1024 * 1024 {
600        return Err(64);
601    }
602    let stamp = SystemTime::now()
603        .duration_since(UNIX_EPOCH)
604        .map(|value| value.as_nanos())
605        .unwrap_or_default();
606    let path = std::env::temp_dir().join(format!("pharos-body-{}-{stamp}.tmp", std::process::id()));
607    let mut file = OpenOptions::new()
608        .write(true)
609        .create_new(true)
610        .open(&path)
611        .map_err(|_| 69)?;
612    if file.write_all(body).is_err() {
613        let _ = fs::remove_file(&path);
614        return Err(69);
615    }
616    Ok(path)
617}
618
619pub fn identifier_safe(text: &str) -> bool {
620    !text.is_empty()
621        && text.len() <= 128
622        && text
623            .bytes()
624            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
625}
626
627pub fn sql_literal_escape(text: &str) -> String {
628    text.replace('\'', "''")
629}
630
631pub fn capture_trimmed(text: &str) -> Option<String> {
632    let trimmed = text.trim();
633    (!trimmed.is_empty() && !trimmed.chars().any(char::is_control)).then(|| trimmed.to_owned())
634}
635
636pub fn cross_deb_packaging_available(root: &Path) -> bool {
637    root.join("cross/packaging/debian").is_dir()
638}
639
640pub fn command_exists(command: &str) -> bool {
641    if command.is_empty() || command.contains('/') {
642        return false;
643    }
644    std::env::var_os("PATH")
645        .into_iter()
646        .flat_map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
647        .map(|directory| directory.join(command))
648        .any(|candidate| candidate.is_file())
649}
650
651pub fn write_error_log(
652    path: &Path,
653    app_id: Option<&str>,
654    method: Option<&str>,
655    request_path: Option<&str>,
656    status_text: Option<&str>,
657    reason: Option<&str>,
658) -> i32 {
659    let compact = |value: Option<&str>| {
660        value
661            .unwrap_or_default()
662            .replace(['\r', '\n'], " ")
663            .chars()
664            .take(512)
665            .collect::<String>()
666    };
667    let line = format!(
668        "error app={} status=\"{}\" method=\"{}\" path=\"{}\" reason=\"{}\"",
669        compact(app_id),
670        compact(status_text.or(Some("500 Internal Server Error"))),
671        compact(method),
672        compact(request_path),
673        compact(reason.or(Some("http_response"))),
674    );
675    write_access_log(path, &line)
676}
677
678pub fn write_access_log(path: &Path, line: &str) -> i32 {
679    if path.as_os_str().is_empty()
680        || line.len() > 64 * 1024
681        || line.contains('\n')
682        || line.contains('\r')
683    {
684        return 64;
685    }
686    let mut existing = fs::read_to_string(path).unwrap_or_default();
687    if existing.len().saturating_add(line.len() + 1) > MAX_REPORT_BYTES {
688        return 75;
689    }
690    existing.push_str(line);
691    existing.push('\n');
692    pharos_runtime_core::fs::write_text_file(path, &existing)
693}
694
695pub fn http_body_limit(value: Option<i64>) -> usize {
696    value
697        .and_then(|value| usize::try_from(value).ok())
698        .filter(|value| *value > 0)
699        .unwrap_or(1024 * 1024)
700        .min(16 * 1024 * 1024)
701}
702
703pub fn spool_request_body_to_temp_file(path: &Path, body: &[u8]) -> i32 {
704    if path.as_os_str().is_empty() || body.len() > 16 * 1024 * 1024 {
705        return 64;
706    }
707    if let Some(parent) = path.parent() {
708        let _ = fs::create_dir_all(parent);
709    }
710    pharos_runtime_core::fs::write_binary_file(path, body, 0o600)
711        .map(|_| 0)
712        .unwrap_or(73)
713}
714
715pub fn conninfo_append_kv(output: &mut String, key: &str, value: &str) -> bool {
716    if key.is_empty() || !identifier_safe(key) || value.contains('\0') {
717        return false;
718    }
719    if !output.is_empty() {
720        output.push(' ');
721    }
722    output.push_str(key);
723    output.push_str("='");
724    output.push_str(&value.replace('\\', "\\\\").replace('\'', "\\'"));
725    output.push('\'');
726    true
727}
728
729pub fn open_libpq_handle_candidate(candidate: Option<&Path>) -> bool {
730    candidate.is_some_and(|path| path.is_file())
731}
732
733pub fn long_from_value(value: &Value) -> i64 {
734    value
735        .as_i64()
736        .or_else(|| value.as_str().and_then(|value| value.parse().ok()))
737        .unwrap_or(0)
738}
739
740pub fn db_check_json(path: &Path) -> Result<String, &'static str> {
741    let valid = path.is_file();
742    serde_json::to_string(
743        &json!({"path": path, "readable": valid, "status": if valid { "ok" } else { "failed" }}),
744    )
745    .map_err(|_| "database report encoding failed")
746}
747
748pub fn output_path_status(path: &Path) -> Result<String, i32> {
749    if path.as_os_str().is_empty() {
750        return Err(64);
751    }
752    serde_json::to_string(&json!({
753        "path": path,
754        "exists": path.exists(),
755        "regular_file": path.is_file(),
756        "parent_present": path.parent().is_some_and(Path::is_dir),
757    }))
758    .map_err(|_| 75)
759}
760
761pub fn default_environment() -> &'static str {
762    "development"
763}
764
765pub fn environment_valid(environment: &str) -> bool {
766    matches!(environment, "development" | "test" | "production")
767}
768
769pub fn output_path_check_json(path: &Path) -> Result<String, &'static str> {
770    if path.as_os_str().is_empty() {
771        return Err("output path is required");
772    }
773    serde_json::to_string(&json!({"path": path, "parent_present": path.parent().is_some_and(Path::is_dir), "exists": path.exists()})).map_err(|_| "output path report encoding failed")
774}
775
776pub fn append_escaped_sql_char(output: &mut String, character: char) -> bool {
777    if output.len() >= MAX_REPORT_BYTES || character == '\0' {
778        return false;
779    }
780    if character == '\'' {
781        output.push('\'');
782    }
783    output.push(character);
784    true
785}
786
787fn write_noindex_markers_recursive_inner(
788    root: &Path,
789    depth: usize,
790    written: &mut usize,
791) -> Result<(), &'static str> {
792    if depth > MAX_NO_INDEX_DEPTH || !root.is_dir() {
793        return Err("noindex root is invalid or too deep");
794    }
795    if pharos_runtime_core::fs::write_text_file(&root.join(".metadata_never_index"), "") != 0 {
796        return Err("noindex marker write failed");
797    }
798    *written = written.saturating_add(1);
799    let entries = fs::read_dir(root).map_err(|_| "noindex directory read failed")?;
800    for entry in entries {
801        let path = entry.map_err(|_| "noindex directory entry failed")?.path();
802        if path.is_dir() {
803            write_noindex_markers_recursive_inner(&path, depth + 1, written)?;
804        }
805    }
806    Ok(())
807}
808
809pub fn write_noindex_markers_recursive(root: &Path) -> Result<usize, &'static str> {
810    if root.as_os_str().is_empty() {
811        return Err("noindex root is required");
812    }
813    let root = fs::canonicalize(root).map_err(|_| "noindex root is unavailable")?;
814    let mut written = 0usize;
815    write_noindex_markers_recursive_inner(&root, 0, &mut written)?;
816    Ok(written)
817}
818
819pub fn match_ops_command(args: &[String], first: &str, second: &str, third: Option<&str>) -> bool {
820    if args.len() < 2 || args.first().map(String::as_str) != Some(first) {
821        return false;
822    }
823    if args.get(1).map(String::as_str) != Some(second) {
824        return false;
825    }
826    match third {
827        Some(expected) => args.get(2).map(String::as_str) == Some(expected),
828        None => true,
829    }
830}
831
832pub fn normalize_base_path(value: &str) -> Option<String> {
833    if value.is_empty() || !value.starts_with('/') || value.contains('\0') {
834        return None;
835    }
836    let mut output = String::from("/");
837    for segment in value.split('/') {
838        if segment.is_empty() || segment == "." {
839            continue;
840        }
841        if segment == ".."
842            || segment.bytes().any(|byte| byte < 0x20 || byte == 0x7f)
843            || segment.contains('\\')
844        {
845            return None;
846        }
847        if output.len() > 1 {
848            output.push('/');
849        }
850        output.push_str(segment);
851    }
852    Some(output)
853}
854
855pub fn resolve_runtime_path(root: &Path, relative: &str) -> Option<PathBuf> {
856    if root.as_os_str().is_empty()
857        || relative.is_empty()
858        || Path::new(relative).is_absolute()
859        || relative.contains('\\')
860        || relative.split('/').any(|segment| segment == "..")
861    {
862        return None;
863    }
864    Some(root.join(relative))
865}
866
867pub fn build_app_deploy_check_report_local_json(
868    app_root: &Path,
869    target_id: &str,
870) -> Result<String, &'static str> {
871    if !identifier_safe(target_id) || !app_root.is_dir() {
872        return Err("invalid deployment check input");
873    }
874    let manifest = app_root.join("pharos.app.json");
875    let manifest_present = manifest.is_file();
876    let result = json!({
877        "report_id": "pharos-app-deploy-check-v1",
878        "target_kind": "app",
879        "target_id": target_id,
880        "status": if manifest_present { "ok" } else { "failed" },
881        "manifest_path": manifest.to_string_lossy(),
882        "manifest_present": manifest_present,
883        "artifact_path_contract": "dist/apps/<app_id>",
884    });
885    let text = serde_json::to_string(&result).map_err(|_| "report encode failed")?;
886    (text.len() <= MAX_REPORT_BYTES)
887        .then_some(text)
888        .ok_or("report exceeds bounded size")
889}
890
891pub fn build_app_doctor_report_local_json(
892    app_root: &Path,
893    target_id: &str,
894) -> Result<String, &'static str> {
895    if app_root.as_os_str().is_empty() || !identifier_safe(target_id) || !app_root.is_dir() {
896        return Err("invalid app doctor input");
897    }
898    let manifest = app_root.join("pharos.app.json");
899    let source_manifest = app_root.join("pharos.app.yml");
900    let manifest_present = manifest.is_file() || source_manifest.is_file();
901    serde_json::to_string(&json!({
902        "report_id": "pharos-doctor-v1",
903        "target_kind": "app",
904        "target_id": target_id,
905        "status": if manifest_present { "ok" } else { "needs_attention" },
906        "manifest_present": manifest_present,
907        "app_root": app_root,
908    }))
909    .map_err(|_| "doctor report encoding failed")
910}
911
912pub fn build_app_drift_report_local_json(
913    expected_path: &Path,
914    actual_path: &Path,
915) -> Result<String, &'static str> {
916    let expected_text =
917        fs::read_to_string(expected_path).map_err(|_| "expected report unreadable")?;
918    let actual_text = fs::read_to_string(actual_path).map_err(|_| "actual report unreadable")?;
919    let expected: Value =
920        serde_json::from_str(&expected_text).map_err(|_| "expected report invalid")?;
921    let actual: Value = serde_json::from_str(&actual_text).map_err(|_| "actual report invalid")?;
922    let matches = expected == actual;
923    let result = json!({
924        "report_id": "pharos-app-drift-v1",
925        "status": if matches { "ok" } else { "drift" },
926        "matches": matches,
927        "expected_path": expected_path.to_string_lossy(),
928        "actual_path": actual_path.to_string_lossy(),
929    });
930    let text = serde_json::to_string(&result).map_err(|_| "report encode failed")?;
931    (text.len() <= MAX_REPORT_BYTES)
932        .then_some(text)
933        .ok_or("report exceeds bounded size")
934}
935
936pub fn conninfo_append(output: &mut String, key: &str, value: &str) -> bool {
937    conninfo_append_kv(output, key, value)
938}
939
940fn option_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
941    args.windows(2)
942        .find(|window| window[0] == flag)
943        .map(|window| window[1].as_str())
944}
945
946fn resolve_repo_root_path() -> Result<PathBuf, ()> {
947    let cwd = std::env::current_dir().map_err(|_| ())?;
948    let mut current = Some(cwd.as_path());
949    while let Some(path) = current {
950        if path.join("apps").is_dir() && path.join("scripts").is_dir() {
951            return path.canonicalize().map_err(|_| ());
952        }
953        current = path.parent();
954    }
955    Err(())
956}
957
958fn resolve_repo_path(repo_root: &Path, value: &str, must_exist: bool) -> Result<PathBuf, String> {
959    let trimmed = value.trim();
960    if trimmed.is_empty() {
961        return Err("path is required".to_owned());
962    }
963    let input = Path::new(trimmed);
964    if input
965        .components()
966        .any(|component| matches!(component, std::path::Component::ParentDir))
967    {
968        return Err("path traversal is not allowed".to_owned());
969    }
970    let path = if input.is_absolute() {
971        input.to_path_buf()
972    } else {
973        repo_root.join(input)
974    };
975    let check_path = if must_exist {
976        path.canonicalize()
977            .map_err(|_| "path does not exist".to_owned())?
978    } else if let Some(parent) = path.parent() {
979        std::fs::create_dir_all(parent)
980            .map_err(|_| "path parent could not be created".to_owned())?;
981        let canonical_parent = parent
982            .canonicalize()
983            .map_err(|_| "path parent does not exist".to_owned())?;
984        canonical_parent.join(
985            path.file_name()
986                .ok_or_else(|| "path file name missing".to_owned())?,
987        )
988    } else {
989        return Err("path parent missing".to_owned());
990    };
991    if !check_path.starts_with(repo_root) {
992        return Err("path must stay inside the Pharos repository".to_owned());
993    }
994    Ok(path)
995}
996
997fn lexical_normalize(path: &Path) -> PathBuf {
998    let mut normalized = PathBuf::new();
999    for component in path.components() {
1000        match component {
1001            std::path::Component::CurDir => {}
1002            std::path::Component::ParentDir => {
1003                normalized.pop();
1004            }
1005            other => normalized.push(other.as_os_str()),
1006        }
1007    }
1008    normalized
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014    use std::io::Cursor;
1015
1016    #[test]
1017    fn parses_docs_build_request_from_cli_args() {
1018        let args = vec![
1019            "build".to_owned(),
1020            "--app-root".to_owned(),
1021            "apps/dev_docs".to_owned(),
1022            "--build-dir".to_owned(),
1023            "dist".to_owned(),
1024            "--target-docs-dir".to_owned(),
1025            "dist/apps/dev_docs".to_owned(),
1026            "--check-fingerprints".to_owned(),
1027        ];
1028        let request = docs_build_request_from_args(&args).expect("docs request");
1029        assert_eq!(request.schema, DOCS_BUILD_REQUEST_SCHEMA);
1030        assert_eq!(request.app_id, "dev_docs");
1031        assert!(request.check_fingerprints);
1032    }
1033
1034    #[test]
1035    fn rejects_oversized_docs_build_request() {
1036        let bytes = vec![b' '; (MAX_DOCS_BUILD_REQUEST_BYTES + 1) as usize];
1037        let error = docs_build_request_from_reader(Cursor::new(bytes)).expect_err("size error");
1038        assert_eq!(error, "invalid docs builder request");
1039    }
1040
1041    #[test]
1042    fn validates_identifier_and_base_path_inputs() {
1043        assert!(identifier_safe("commerce_spa.v1"));
1044        assert!(!identifier_safe("../commerce_spa"));
1045        assert_eq!(normalize_base_path("/a//b/./c").as_deref(), Some("/a/b/c"));
1046        assert!(normalize_base_path("/a/../b").is_none());
1047    }
1048
1049    #[test]
1050    fn escapes_conninfo_values_without_accepting_bad_keys() {
1051        let mut output = String::new();
1052        assert!(conninfo_append_kv(&mut output, "host", "local'host"));
1053        assert_eq!(output, "host='local\\'host'");
1054        assert!(!conninfo_append_kv(&mut output, "bad key", "value"));
1055    }
1056}