//! Fixed remote file helper (`remoteEditor.py`) for the editor, privileged file operations and //! transfers (port of `SessionManager.remoteEditor` / `remoteChecksum`). Neither the UI nor a //! module can supply code: only JSON requests for the fixed script. use crate::core::error::{CoreError, CoreResult}; use crate::core::ssh::platform::detect_platform; use crate::core::ssh::privileged::{normalize_password, Frame, PrivilegedExecutor, RunOptions}; use crate::core::ssh::session::{shell_quote, SessionManager}; use crate::core::ssh::windows_exec::run_windows_script; use once_cell::sync::Lazy; use regex::Regex; use serde_json::Value; use std::time::Duration; pub const REMOTE_EDITOR: &str = include_str!("../../../resources/remote/remoteEditor.py"); const EDITOR_PRIVILEGED_SCRIPT: &str = include_str!("../ssh/scripts/editor_privileged.sh"); const WINDOWS_HASH_SCRIPT: &str = include_str!("../ssh/scripts/windows_hash.ps1"); const EDITOR_STARTED: &str = "\x1eCHJ_EDITOR_START\n"; const EDITOR_MUTATIONS: [&str; 9] = ["finalize", "publish", "rename", "remove", "mkdir", "create", "cleanup", "stage-out", "transfer-remove"]; pub const EDITOR_OUTPUT_LIMIT: usize = 36 * 1024 * 1024; static FILE_CODE: Lazy = Lazy::new(|| Regex::new(r"^FILE_[A-Z_]+$").unwrap()); static SUDO_CODE: Lazy = Lazy::new(|| Regex::new(r"^SUDO_[A-Z_]+$").unwrap()); /// Authorization fields a caller may pass (0.1.0 `authorization()` + Core-internal options). #[derive(Debug, Clone, Default)] pub struct Authorization { pub sudo: bool, pub sudo_password: Option, pub remember_sudo: bool, /// Core-internal: `useCachedSudo !== false`. pub ignore_cached_sudo: bool, /// Core-internal timeout (clamped 10 s .. 24 h, default 60 s). pub timeout_ms: Option, } impl Authorization { /// Only these option fields reach the helper; modules cannot set timeouts or internals. pub fn from_options(options: &Value) -> Self { Authorization { sudo: options.get("sudo") == Some(&Value::Bool(true)), sudo_password: match options.get("sudoPassword") { None | Some(Value::Null) => None, Some(Value::String(s)) if s.is_empty() => None, Some(Value::String(s)) => Some(s.clone()), Some(other) => Some(other.to_string()), }, remember_sudo: options.get("rememberSudo") == Some(&Value::Bool(true)), ..Default::default() } } pub fn sudo(mut self) -> Self { self.sudo = true; self } pub fn with_timeout(mut self, ms: u64) -> Self { self.timeout_ms = Some(ms); self } } fn session_error(code: &str, message: &str) -> CoreError { CoreError::new(code, message) } /// `remoteEditor(sessionId, request, authorization)`. pub async fn remote_editor(sessions: &SessionManager, privileged: &PrivilegedExecutor, session_id: &str, request: &Value, auth: &Authorization) -> CoreResult { let record = sessions.require_connected(session_id)?; let platform = detect_platform(sessions, &record, false).await?; if platform["os"] == "windows" { let caption = platform["caption"].as_str().filter(|s| !s.is_empty()).unwrap_or("Windows"); return Err(session_error("FEATURE_PLATFORM_UNSUPPORTED", &format!("The remote text editor is available only on Linux/POSIX servers; this server runs {caption}."))); } let password = normalize_password(auth.sudo_password.as_deref()).map_err(|_| session_error("FILE_SUDO_PASSWORD_INVALID", "Invalid sudo password."))?; if !password.is_empty() && !auth.sudo { return Err(session_error("FILE_SUDO_AUTHORIZATION_REQUIRED", "Explicit sudo authorization is required.")); } let timeout = Duration::from_millis(auth.timeout_ms.unwrap_or(60_000).clamp(10_000, 24 * 3600 * 1000)); let operation = request["operation"].as_str().unwrap_or(""); let mutation = EDITOR_MUTATIONS.contains(&operation); let not_confirmed = |dependency: bool| { session_error( if dependency { "FILE_DEPENDENCY_UNAVAILABLE" } else if mutation { "FILE_RESULT_UNKNOWN" } else { "FILE_EXEC_FAILED" }, "Remote file operation was not confirmed. Inspect the target before retrying.", ) }; let request_json = serde_json::to_string(request).unwrap_or_default(); let (stdout, code) = if auth.sudo { let options = RunOptions { script: EDITOR_PRIVILEGED_SCRIPT.to_string(), args: vec![REMOTE_EDITOR.to_string()], frames: vec![Frame::text("REQ", &request_json)], privileged: true, sudo_password: (!password.is_empty()).then(|| password.to_string()), remember_password: auth.remember_sudo, use_cached_password: !auth.ignore_cached_sudo, purpose: format!("files.{operation}"), error_prefix: "FILE_".into(), timeout, output_limit: EDITOR_OUTPUT_LIMIT, }; match privileged.run(session_id, options).await { Ok(result) => (result.stdout, result.code), // sudo refusals happen before the helper starts: definite, original untouched. Err(error) if SUDO_CODE.is_match(&error.code) || error.code == "FILE_DEPENDENCY_UNAVAILABLE" => return Err(error), Err(_) => return Err(not_confirmed(false)), } } else { let command = format!("printf '\\036CHJ_EDITOR_START\\n'; exec python3 -I -B -c {} {}", shell_quote(REMOTE_EDITOR), shell_quote(&request_json)); match sessions.exec_fixed(&record, &command, timeout, EDITOR_OUTPUT_LIMIT).await { Ok(result) => (result.stdout, result.code), // Remote stderr never becomes editor errors or logs. Err(error) => return Err(not_confirmed(error.details.get("remoteExitCode").and_then(Value::as_i64) == Some(127))), } }; let response: Value = match stdout.strip_prefix(EDITOR_STARTED).and_then(|rest| serde_json::from_str(rest).ok()) { Some(value) => value, None => { let code = if code == Some(127) { "FILE_DEPENDENCY_UNAVAILABLE" } else if mutation { "FILE_RESULT_UNKNOWN" } else { "FILE_EXEC_FAILED" }; return Err(session_error(code, "Remote file operation result could not be verified.")); } }; if response["ok"] != true || !response["value"].is_object() { let code = response["code"].as_str().filter(|c| FILE_CODE.is_match(c)).unwrap_or("FILE_PROTOCOL_ERROR").to_string(); let suffix = if mutation { "; the original was kept and recoverable copies were retained" } else { "" }; return Err(session_error(&code, &format!("{code}: Remote file operation stopped{suffix}."))); } if code != Some(0) { return Err(not_confirmed(code == Some(127))); } Ok(response["value"].clone()) } /// SHA-256 of a remote file with standard tools, for servers without python3. pub async fn remote_checksum(sessions: &SessionManager, session_id: &str, remote_path: &str) -> CoreResult { let record = sessions.require_connected(session_id)?; if !remote_path.starts_with('/') || remote_path.contains(['\0', '\r', '\n']) { return Err(session_error("FILE_UNSAFE_PATH", "Remote path must be absolute.")); } let unavailable = || session_error("FILE_CHECKSUM_UNAVAILABLE", "The remote checksum could not be read."); let six_hours = Duration::from_secs(6 * 3600); if detect_platform(sessions, &record, false).await?["os"] == "windows" { // Windows OpenSSH SFTP paths look like /C:/Users/x; the path travels as JSON, never in the command line. let result = run_windows_script(sessions, &record, WINDOWS_HASH_SCRIPT, &serde_json::json!({ "path": remote_path }), six_hours, 4 * 1024 * 1024, "METRICS_").await?; let hash = result["hash"].as_str().unwrap_or(""); return if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) { Ok(hash.to_string()) } else { Err(unavailable()) }; } let command = format!( "LC_ALL=C; p={}; if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$p\"; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$p\"; else exit 127; fi", shell_quote(remote_path) ); let output = sessions.exec_fixed_default(&record, &command, six_hours).await?; let first = output.stdout.trim().split(|c: char| !c.is_ascii_hexdigit()).next().unwrap_or(""); if first.len() == 64 && first.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) { Ok(first.to_string()) } else { Err(unavailable()) } }