//! `pic pull [--dir .]` — export an app's current server state into //! a `picloud.toml` manifest plus `scripts/.rhai` source files, for //! declarative management with `pic plan` / `pic apply`. //! //! Read-only: issues `GET`s only and writes local files. Every trigger //! kind is exported except `email` — the server stores the *sealed secret //! value*, not the secret name, so the manifest's `inbound_secret_ref` //! can't be reconstructed (email triggers must be set up by hand). use std::collections::HashMap; use std::path::Path; use anyhow::{Context, Result}; use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId}; use serde::Deserialize; use crate::client::{Client, VarOwnerArg}; use crate::config; use crate::manifest::{ CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp, ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE, }; use crate::output::{KvBlock, OutputMode}; pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; // Refuse to clobber an existing project (mirrors `pic init`). `pull` // overwrites `picloud.toml` and every colliding `scripts/*.rhai`, so a // stray `pic pull ` in a populated dir would destroy local // edits. Fail fast — before any network call or file write — unless the // operator opted in with `--force`. let manifest_path = dir.join(MANIFEST_FILE); if !force && manifest_path.exists() { anyhow::bail!( "{} already exists; refusing to overwrite. Re-run with --force to \ replace it (and any colliding scripts/*.rhai).", manifest_path.display() ); } // One GET per resource kind (routes are per-script, below). let app = client.apps_get(app_ident).await?; let scripts = client.scripts_list_by_app(app_ident).await?; let triggers = client.triggers_list(app_ident).await?.triggers; let secrets = client .secrets_list(VarOwnerArg::App(app_ident), None) .await? .secrets; let name_by_id: HashMap = scripts.iter().map(|s| (s.id, s.name.clone())).collect(); // Routes: the admin surface lists them per script. let mut routes = Vec::new(); for s in &scripts { for r in client.routes_list_for_script(&s.id.to_string()).await? { routes.push(ManifestRoute { script: s.name.clone(), method: r.method, host_kind: r.host_kind, host: r.host, host_param_name: r.host_param_name, path_kind: r.path_kind, path: r.path, dispatch_mode: r.dispatch_mode, enabled: r.enabled, }); } } // The server does not constrain script names to a filesystem-safe // charset, so a name containing a path separator or `..` would let `pull` // write outside the project dir. Validate ALL names up front, before any // file is written, so a single bad name can't leave a half-written dir. // Reject rather than sanitize: a silent rename would desync the manifest // `name` from its `file`. for s in &scripts { if !is_safe_filename(&s.name) { anyhow::bail!( "script name {:?} is not filesystem-safe (a path separator, \ `..`, a leading dot, a control character, or longer than 200 \ bytes); cannot pull", s.name ); } } // Scripts: write each source next to the manifest and record a path ref. let scripts_dir = dir.join("scripts"); std::fs::create_dir_all(&scripts_dir) .with_context(|| format!("creating {}", scripts_dir.display()))?; let mut manifest_scripts = Vec::with_capacity(scripts.len()); for s in &scripts { let rel = format!("scripts/{}.rhai", s.name); std::fs::write(dir.join(&rel), &s.source).with_context(|| format!("writing {rel}"))?; manifest_scripts.push(ManifestScript { name: s.name.clone(), file: rel, kind: s.kind, description: s.description.clone(), timeout_seconds: i32::try_from(s.timeout_seconds).ok(), memory_limit_mb: i32::try_from(s.memory_limit_mb).ok(), sandbox: if s.sandbox.is_empty() { None } else { Some(s.sandbox) }, enabled: s.enabled, }); } // Triggers: map the five settled kinds; warn + skip the rest. let mut manifest_triggers = ManifestTriggers::default(); let mut skipped: Vec = Vec::new(); for t in &triggers { let script = name_by_id .get(&t.script_id) .cloned() .unwrap_or_else(|| t.script_id.to_string()); let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode); let retry_max_attempts = Some(t.retry_max_attempts); match t.kind.as_str() { "kv" => { let d: CollectionDetails = decode_details(&t.details, &t.kind)?; manifest_triggers.kv.push(KvTriggerSpec { script, collection_glob: d.collection_glob, ops: d.ops, dispatch_mode, retry_max_attempts, }); } "docs" => { let d: CollectionDetails = decode_details(&t.details, &t.kind)?; manifest_triggers.docs.push(DocsTriggerSpec { script, collection_glob: d.collection_glob, ops: d.ops, dispatch_mode, retry_max_attempts, }); } "files" => { let d: CollectionDetails = decode_details(&t.details, &t.kind)?; manifest_triggers.files.push(FilesTriggerSpec { script, collection_glob: d.collection_glob, ops: d.ops, dispatch_mode, retry_max_attempts, }); } "cron" => { let d: CronDetails = decode_details(&t.details, &t.kind)?; manifest_triggers.cron.push(CronTriggerSpec { script, schedule: d.schedule, timezone: d.timezone, dispatch_mode, retry_max_attempts, }); } "pubsub" => { let d: PubsubDetails = decode_details(&t.details, &t.kind)?; manifest_triggers.pubsub.push(PubsubTriggerSpec { script, topic_pattern: d.topic_pattern, dispatch_mode, retry_max_attempts, }); } "queue" => { let d: QueueDetails = decode_details(&t.details, &t.kind)?; manifest_triggers.queue.push(QueueTriggerSpec { script, queue_name: d.queue_name, visibility_timeout_secs: Some(d.visibility_timeout_secs), dispatch_mode, retry_max_attempts, }); } // `email` is skipped: the server stores the sealed secret value, // not the secret *name*, so the manifest's `inbound_secret_ref` // can't be reconstructed — set it up by hand. other => skipped.push(format!("{other} ({})", t.id)), } } for s in &skipped { eprintln!("warning: skipping {s} trigger — not yet representable in the manifest"); } let manifest = Manifest { app: ManifestApp { slug: app.app.slug.clone(), name: app.app.name.clone(), description: app.app.description.clone(), }, scripts: manifest_scripts, routes, triggers: manifest_triggers, secrets: ManifestSecrets { names: secrets.iter().map(|s| s.name.clone()).collect(), }, }; std::fs::write(&manifest_path, manifest.to_toml()?) .with_context(|| format!("writing {}", manifest_path.display()))?; let mut block = KvBlock::new(); block .field("manifest", manifest_path.display().to_string()) .field("app", manifest.app.slug.clone()) .field("scripts", manifest.scripts.len().to_string()) .field("routes", manifest.routes.len().to_string()) .field("triggers", trigger_count(&manifest.triggers).to_string()) .field("secrets", manifest.secrets.names.len().to_string()); block.print(mode); Ok(()) } fn trigger_count(t: &ManifestTriggers) -> usize { t.kv.len() + t.docs.len() + t.files.len() + t.cron.len() + t.pubsub.len() + t.queue.len() } /// True if `name` is safe to use as a single path component in `scripts/`. /// Rejects empty/over-long names, path separators, `.`/`..`, leading dots, /// and any deceptive display character — a server-returned name is otherwise /// written verbatim as a filename and printed to the operator's terminal. fn is_safe_filename(name: &str) -> bool { // Leave headroom under NAME_MAX (255 bytes on common filesystems) for the // `.rhai` suffix. const MAX_LEN: usize = 200; !name.is_empty() && name.len() <= MAX_LEN && !name.starts_with('.') && !name.contains('/') && !name.contains('\\') && name != ".." && !name.chars().any(is_deceptive_char) } /// Control characters (NUL, newlines, ANSI escapes) plus the Unicode /// bidirectional-override and zero-width/format characters used to spoof how a /// name renders in a terminal — both classes are unsafe to print verbatim. fn is_deceptive_char(c: char) -> bool { c.is_control() || matches!(c, '\u{200B}'..='\u{200F}' // zero-width space … LTR/RTL marks | '\u{2028}'..='\u{2029}' // line / paragraph separators | '\u{202A}'..='\u{202E}' // bidi embeddings / overrides | '\u{2060}' // word joiner | '\u{2066}'..='\u{2069}' // bidi isolates | '\u{FEFF}' // BOM / zero-width no-break space ) } /// Deserialize a trigger's `details` JSON, attributing failures to the kind. /// The server tags details with a `kind` field which these structs ignore. fn decode_details Deserialize<'de>>( details: &serde_json::Value, kind: &str, ) -> Result { serde_json::from_value(details.clone()) .with_context(|| format!("decoding {kind} trigger details")) } #[derive(Deserialize)] struct CollectionDetails { collection_glob: String, #[serde(default = "Vec::new")] ops: Vec, } #[derive(Deserialize)] struct CronDetails { schedule: String, #[serde(default = "default_timezone")] timezone: String, } #[derive(Deserialize)] struct PubsubDetails { topic_pattern: String, } #[derive(Deserialize)] struct QueueDetails { queue_name: String, visibility_timeout_secs: u32, } fn default_timezone() -> String { "UTC".to_string() } #[cfg(test)] mod tests { use super::is_safe_filename; #[test] fn rejects_traversal_and_separators() { for bad in [ "", ".", "..", "../etc/passwd", "a/b", "a\\b", ".hidden", "with\0nul", ] { assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected"); } } #[test] fn rejects_control_chars_and_overlong() { for bad in [ "a\nb", "a\tb", "line\rdrop", "esc\x1b[2Jseq", "rtl\u{202E}gpj.exe", // bidi override (filename spoof) "zero\u{200B}width", // zero-width space "bom\u{FEFF}name", // BOM ] { assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected"); } assert!( !is_safe_filename(&"a".repeat(201)), "expected an over-long name to be rejected" ); assert!( is_safe_filename(&"a".repeat(200)), "a 200-char name is at the limit and allowed" ); } #[test] fn accepts_normal_names() { for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] { assert!(is_safe_filename(ok), "expected {ok:?} to be accepted"); } } }