use std::path::{Path, PathBuf};
use crate::commands::keyring::store::Store;
const RCLONE_CONFIG_FILE_NAME: &str = "rclone.conf";
const BACKEND_SECTION_SUFFIX: &str = ".s3";
fn backend_section_name(alias: &str) -> String {
format!("{alias}{BACKEND_SECTION_SUFFIX}")
}
pub(crate) fn rclone_config_path() -> Result<PathBuf, String> {
rclone_config_path_beside(&Store::resolve_path()?)
}
fn rclone_config_path_beside(keyring_path: &Path) -> Result<PathBuf, String> {
let dir = keyring_path.parent().ok_or_else(|| {
format!(
"could not determine a parent directory for {}",
keyring_path.display()
)
})?;
Ok(dir.join(RCLONE_CONFIG_FILE_NAME))
}
pub(crate) fn write_sandboxed_config(store: &Store, path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
let mut contents = String::new();
for bucket_config in store.bucket_configs() {
let backend = backend_section_name(&bucket_config.alias);
if store.contains_alias(&backend) {
return Err(format!(
"cannot generate an rclone config: the backend section for bucket-config '{}' \
would be named '{backend}', which collides with another configured alias -- \
rename one of them",
bucket_config.alias,
));
}
contents.push_str(&format!(
"[{backend}]\ntype = s3\nprovider = Other\naccess_key_id = {}\nsecret_access_key = {}\nendpoint = {}\nacl = private\nno_check_bucket = true\n\n[{}]\ntype = alias\nremote = {backend}:{}\n\n",
bucket_config.access_key_id,
bucket_config.secret_key,
bucket_config.endpoint,
bucket_config.alias,
bucket_config.bucket,
));
}
std::fs::write(path, contents)
.map_err(|err| format!("failed to write {}: {err}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.map_err(|err| format!("failed to set permissions on {}: {err}", path.display()))?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RemoteTarget {
alias: String,
prefix: Option<String>,
}
impl RemoteTarget {
pub(crate) fn parse(value: &str, store: &Store) -> Result<RemoteTarget, String> {
let (alias, raw_prefix) = match value.split_once(':') {
Some((alias, prefix)) => (alias, prefix),
None => (value, ""),
};
if !store.contains_alias(alias) {
return Err(format!("no bucket-config named '{alias}'"));
}
Ok(RemoteTarget {
alias: alias.to_string(),
prefix: normalize_prefix(raw_prefix)?,
})
}
pub(crate) fn bucket_root(alias: &str) -> RemoteTarget {
RemoteTarget {
alias: alias.to_string(),
prefix: None,
}
}
pub(crate) fn with_default_prefix(mut self, default_prefix: &str) -> Result<Self, String> {
if self.prefix.is_none() {
self.prefix = normalize_prefix(default_prefix)?;
}
Ok(self)
}
pub(crate) fn address(&self) -> String {
match &self.prefix {
Some(prefix) => format!("{}:{prefix}", self.alias),
None => format!("{}:", self.alias),
}
}
}
fn normalize_prefix(raw: &str) -> Result<Option<String>, String> {
let trimmed = raw.trim_matches('/');
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.split('/').any(|segment| segment == "..") {
return Err(format!(
"invalid prefix '{raw}': a '..' path segment is not allowed"
));
}
Ok(Some(format!("{trimmed}/")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::keyring::bucket::store::BucketConfig;
use crate::commands::keyring::store::Entry;
#[test]
fn rclone_config_path_lands_beside_an_overridden_keyring_path() {
let path = rclone_config_path_beside(Path::new("/etc/pigeon/keyring.toml")).unwrap();
assert_eq!(path, PathBuf::from("/etc/pigeon/rclone.conf"));
}
#[test]
fn rclone_config_path_follows_the_keyring_into_any_directory() {
let path = rclone_config_path_beside(Path::new("/tmp/alt/custom-name.toml")).unwrap();
assert_eq!(path, PathBuf::from("/tmp/alt/rclone.conf"));
}
fn sample_bucket(alias: &str) -> BucketConfig {
BucketConfig {
alias: alias.to_string(),
endpoint: "https://nyc3.digitaloceanspaces.com".to_string(),
bucket: "my-bucket".to_string(),
access_key_id: "AKID".to_string(),
secret_key: "shh".to_string(),
}
}
fn store_with(aliases: &[&str]) -> Store {
let mut store = Store::default();
for alias in aliases {
store.push(Entry::Bucket(sample_bucket(alias)));
}
store
}
#[test]
fn a_bare_alias_addresses_the_whole_bucket_root() {
let store = store_with(&["backup"]);
let target = RemoteTarget::parse("backup", &store).unwrap();
assert_eq!(target.address(), "backup:");
}
#[test]
fn a_trailing_colon_with_no_prefix_is_also_the_bucket_root() {
let store = store_with(&["backup"]);
assert_eq!(
RemoteTarget::parse("backup:", &store).unwrap().address(),
"backup:"
);
}
#[test]
fn a_prefix_is_normalized_to_exactly_one_trailing_slash() {
let store = store_with(&["source"]);
for spelling in ["source:mp4", "source:mp4/", "source:/mp4/"] {
assert_eq!(
RemoteTarget::parse(spelling, &store).unwrap().address(),
"source:mp4/",
"spelling {spelling} did not normalize"
);
}
}
#[test]
fn a_nested_prefix_keeps_its_interior_separators() {
let store = store_with(&["source"]);
assert_eq!(
RemoteTarget::parse("source:video/mp4/", &store)
.unwrap()
.address(),
"source:video/mp4/"
);
}
#[test]
fn the_alias_half_is_validated_against_the_keyring() {
let store = store_with(&["source"]);
assert_eq!(
RemoteTarget::parse("nope:mp4/", &store),
Err("no bucket-config named 'nope'".to_string())
);
assert_eq!(
RemoteTarget::parse("nope", &store),
Err("no bucket-config named 'nope'".to_string())
);
}
#[test]
fn a_dot_dot_path_segment_is_rejected() {
let store = store_with(&["source"]);
assert!(RemoteTarget::parse("source:../escape/", &store).is_err());
assert!(RemoteTarget::parse("source:mp4/../other/", &store).is_err());
assert_eq!(
RemoteTarget::parse("source:..hidden/", &store)
.unwrap()
.address(),
"source:..hidden/"
);
}
#[test]
fn with_default_prefix_fills_in_only_an_absent_prefix() {
let store = store_with(&["source"]);
let defaulted = RemoteTarget::parse("source", &store)
.unwrap()
.with_default_prefix("mp4")
.unwrap();
assert_eq!(defaulted.address(), "source:mp4/");
let explicit = RemoteTarget::parse("source:raw-video/", &store)
.unwrap()
.with_default_prefix("mp4")
.unwrap();
assert_eq!(explicit.address(), "source:raw-video/");
}
#[test]
fn with_an_empty_default_prefix_the_target_stays_the_bucket_root() {
let store = store_with(&["source"]);
let target = RemoteTarget::parse("source", &store)
.unwrap()
.with_default_prefix("")
.unwrap();
assert_eq!(target.address(), "source:");
}
#[test]
fn write_sandboxed_config_with_no_bucket_configs_writes_an_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rclone.conf");
let store = Store::default();
write_sandboxed_config(&store, &path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.is_empty());
}
#[test]
fn write_sandboxed_config_writes_an_s3_backend_plus_a_bucket_scoped_alias_per_bucket_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rclone.conf");
let mut store = Store::default();
store.push(Entry::Bucket(sample_bucket("backup")));
write_sandboxed_config(&store, &path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(
contents,
"[backup.s3]\n\
type = s3\n\
provider = Other\n\
access_key_id = AKID\n\
secret_access_key = shh\n\
endpoint = https://nyc3.digitaloceanspaces.com\n\
acl = private\n\
no_check_bucket = true\n\
\n\
[backup]\n\
type = alias\n\
remote = backup.s3:my-bucket\n\
\n"
);
}
#[test]
fn two_aliases_naming_the_same_bucket_get_their_own_backend_sections() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rclone.conf");
let mut store = Store::default();
store.push(Entry::Bucket(sample_bucket("here")));
let mut elsewhere = sample_bucket("there");
elsewhere.endpoint = "https://s3.fr-par.scw.cloud".to_string();
elsewhere.access_key_id = "OTHER".to_string();
store.push(Entry::Bucket(elsewhere));
write_sandboxed_config(&store, &path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("[here.s3]"));
assert!(contents.contains("remote = here.s3:my-bucket"));
assert!(contents.contains("[there.s3]"));
assert!(contents.contains("remote = there.s3:my-bucket"));
assert!(contents.contains("access_key_id = AKID"));
assert!(contents.contains("access_key_id = OTHER"));
}
#[test]
fn write_sandboxed_config_rejects_an_alias_colliding_with_a_derived_backend_name() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rclone.conf");
let mut store = Store::default();
store.push(Entry::Bucket(sample_bucket("backup")));
store.push(Entry::Bucket(sample_bucket("backup.s3")));
let err = write_sandboxed_config(&store, &path).unwrap_err();
assert!(err.contains("backup.s3"), "unexpected error: {err}");
}
#[test]
fn write_sandboxed_config_never_reads_an_existing_file_first() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rclone.conf");
std::fs::write(&path, "[unrelated]\ntype = sftp\n").unwrap();
let store = Store::default();
write_sandboxed_config(&store, &path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(
!contents.contains("unrelated"),
"write_sandboxed_config must overwrite, never merge with, any pre-existing file"
);
}
#[test]
#[cfg(unix)]
fn write_sandboxed_config_sets_0600_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rclone.conf");
let mut store = Store::default();
store.push(Entry::Bucket(sample_bucket("backup")));
write_sandboxed_config(&store, &path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
}