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}