mirror of
https://github.com/Portabase/agent.git
synced 2026-10-04 05:25:49 +00:00
refactor(storage): generic rclone config builder and shared obscure util
This commit is contained in:
@@ -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('/');
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user