From 49366ee24ea24aae3d4f505aa74ae5ab66b1a495 Mon Sep 17 00:00:00 2001 From: Charles GTE Date: Sat, 12 Sep 2026 21:10:50 +0200 Subject: [PATCH] refactor(storage): generic rclone config builder and shared obscure util --- .../storage/providers/rclone/helpers.rs | 42 ++++++++++++ .../storage/providers/sftp/helpers.rs | 68 +++++++------------ src/tests/storage/rclone.rs | 22 ++++++ 3 files changed, 90 insertions(+), 42 deletions(-) diff --git a/src/services/storage/providers/rclone/helpers.rs b/src/services/storage/providers/rclone/helpers.rs index 80962a1..d0e624d 100644 --- a/src/services/storage/providers/rclone/helpers.rs +++ b/src/services/storage/providers/rclone/helpers.rs @@ -78,6 +78,48 @@ pub fn validate_config(config_text: &str, remote_name: &str) -> Result<()> { Ok(()) } +/// `rclone obscure ` — several backends (sftp, ...) require the +/// password field to be obscured rather than plain. +pub fn obscure_password(password: &str) -> Result { + let out = std::process::Command::new("rclone") + .arg("obscure") + .arg(password) + .output() + .context("failed to spawn rclone (is the binary installed in this image?)")?; + + if !out.status.success() { + bail!( + "rclone obscure failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// Serialize ordered `(key, value)` fields into an rclone config section +/// ("json to rclone config"). Empty values are skipped; values containing line +/// breaks are rejected to prevent config injection. Order is preserved. +pub fn build_rclone_config(remote_name: &str, fields: &[(&str, String)]) -> Result { + if remote_name.contains(['\r', '\n']) { + bail!("rclone remote name must not contain line breaks"); + } + + let mut lines = vec![format!("[{remote_name}]")]; + for (key, value) in fields { + let value = value.trim(); + if value.is_empty() { + continue; + } + if value.contains(['\r', '\n']) { + bail!("rclone config value for '{key}' must not contain line breaks"); + } + lines.push(format!("{key} = {value}")); + } + + Ok(lines.join("\n") + "\n") +} + /// `:/` pub fn remote_target(remote_name: &str, remote_path: &str, remote_file_path: &str) -> String { let base = remote_path.trim().trim_matches('/'); diff --git a/src/services/storage/providers/sftp/helpers.rs b/src/services/storage/providers/sftp/helpers.rs index a817897..9d7b140 100644 --- a/src/services/storage/providers/sftp/helpers.rs +++ b/src/services/storage/providers/sftp/helpers.rs @@ -1,26 +1,9 @@ +use crate::services::storage::providers::rclone::helpers::{build_rclone_config, obscure_password}; use crate::services::storage::providers::sftp::models::SftpProviderConfig; use anyhow::{Context, Result, bail}; use std::io::Write; -use std::process::Command; use tempfile::NamedTempFile; -pub fn obscure_password(password: &str) -> Result { - let out = Command::new("rclone") - .arg("obscure") - .arg(password) - .output() - .context("failed to spawn rclone (is the binary installed in this image?)")?; - - if !out.status.success() { - bail!( - "rclone obscure failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - ); - } - - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) -} - fn write_key(private_key: &str) -> Result { let mut file = NamedTempFile::new().context("failed to create sftp key temp file")?; @@ -46,9 +29,6 @@ pub fn build_sftp_config( if config.username.trim().is_empty() { bail!("sftp username is required"); } - if config.host.contains(['\r', '\n']) || config.username.contains(['\r', '\n']) { - bail!("sftp host/username must not contain line breaks"); - } let has_password = config.password.as_deref().is_some_and(|p| !p.trim().is_empty()); let has_key = config.private_key.as_deref().is_some_and(|k| !k.trim().is_empty()); @@ -56,32 +36,36 @@ pub fn build_sftp_config( bail!("sftp requires a password or a private key"); } - let mut lines = vec![ - "[sftp]".to_string(), - "type = sftp".to_string(), - format!("host = {}", config.host.trim()), - ]; - - if let Some(port) = config.port.as_deref() { - let port = port.trim(); - if !port.is_empty() { - lines.push(format!("port = {port}")); - } - } - - lines.push(format!("user = {}", config.username.trim())); - let mut key_file: Option = None; + let mut key_file_path = String::new(); if has_key { let file = write_key(config.private_key.as_deref().unwrap())?; - lines.push(format!("key_file = {}", file.path().display())); + key_file_path = file.path().display().to_string(); key_file = Some(file); } - if has_password { - let obscured = obscure_password(config.password.as_deref().unwrap())?; - lines.push(format!("pass = {obscured}")); - } + let pass = if has_password { + obscure_password(config.password.as_deref().unwrap())? + } else { + String::new() + }; - Ok((lines.join("\n") + "\n", key_file)) + let port = config + .port + .as_deref() + .unwrap_or("") + .trim() + .to_string(); + + let fields: &[(&str, String)] = &[ + ("type", "sftp".to_string()), + ("host", config.host.trim().to_string()), + ("port", port), + ("user", config.username.trim().to_string()), + ("key_file", key_file_path), + ("pass", pass), + ]; + + let config_text = build_rclone_config("sftp", fields)?; + Ok((config_text, key_file)) } diff --git a/src/tests/storage/rclone.rs b/src/tests/storage/rclone.rs index 827fc41..8cbcead 100644 --- a/src/tests/storage/rclone.rs +++ b/src/tests/storage/rclone.rs @@ -36,6 +36,28 @@ fn config_deserializes_from_dashboard_camel_case() { assert!(config.config_text.contains("type = s3")); } +#[test] +fn build_rclone_config_skips_empty_and_rejects_line_breaks() { + use crate::services::storage::providers::rclone::helpers::build_rclone_config; + + let text = build_rclone_config( + "sftp", + &[ + ("type", "sftp".to_string()), + ("host", "h".to_string()), + ("port", "".to_string()), // empty -> skipped + ("user", " u ".to_string()), // trimmed + ], + ) + .unwrap(); + assert_eq!(text, "[sftp]\ntype = sftp\nhost = h\nuser = u\n"); + + let err = build_rclone_config("sftp", &[("host", "a\nkey = injected".to_string())]) + .unwrap_err() + .to_string(); + assert!(err.contains("line breaks"), "unexpected error: {err}"); +} + #[test] fn validate_config_accepts_the_target_remote() { assert!(validate_config(OVH_CONFIG, "ovhcloud-rbx").is_ok());