refactor(storage): generic rclone config builder and shared obscure util

This commit is contained in:
Charles GTE
2026-09-12 21:10:50 +02:00
parent 012a984973
commit 49366ee24e
3 changed files with 90 additions and 42 deletions
@@ -78,6 +78,48 @@ pub fn validate_config(config_text: &str, remote_name: &str) -> Result<()> {
Ok(())
}
/// `rclone obscure <password>` — several backends (sftp, ...) require the
/// password field to be obscured rather than plain.
pub fn obscure_password(password: &str) -> Result<String> {
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<String> {
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")
}
/// `<remote>:<remote_path>/<remote_file_path>`
pub fn remote_target(remote_name: &str, remote_path: &str, remote_file_path: &str) -> String {
let base = remote_path.trim().trim_matches('/');
+26 -42
View File
@@ -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<String> {
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<NamedTempFile> {
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<NamedTempFile> = 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))
}
+22
View File
@@ -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());