use nextcloud_sdk_core::{ApiPath, UrlError};
pub(crate) fn validate_user_id(user_id: &str) -> Result<&str, UrlError> {
let trimmed = user_id.trim();
if trimmed.is_empty() || trimmed == "." || trimmed == ".." || trimmed.contains('/') {
return Err(UrlError::InvalidSegment(user_id.to_owned()));
}
check_characters(trimmed)?;
Ok(trimmed)
}
pub(crate) fn validate_item_id(item_id: &str) -> Result<&str, UrlError> {
let trimmed = item_id.trim();
if trimmed.is_empty() || trimmed == "." || trimmed == ".." || trimmed.contains('/') {
return Err(UrlError::InvalidSegment(item_id.to_owned()));
}
check_characters(trimmed)?;
Ok(trimmed)
}
pub(crate) fn validate_restore_path(restore_path: &str) -> Result<String, UrlError> {
let trimmed = restore_path.trim().trim_matches('/');
if trimmed.is_empty() {
return Ok(String::new());
}
for segment in trimmed.split('/') {
if segment.is_empty() || segment == "." || segment == ".." {
return Err(UrlError::InvalidSegment(segment.to_owned()));
}
check_characters(segment)?;
}
Ok(trimmed.to_owned())
}
fn check_characters(segment: &str) -> Result<(), UrlError> {
let rejected = segment
.chars()
.any(|c| c.is_control() || matches!(c, '?' | '#' | '\\'));
if rejected {
return Err(UrlError::InvalidSegment(segment.to_owned()));
}
Ok(())
}
pub(crate) fn trash_root_path(user_id: &str) -> Result<ApiPath, UrlError> {
let user = validate_user_id(user_id)?;
ApiPath::dav(format!("trashbin/{user}/trash/"))
}
pub(crate) fn trash_item_path(user_id: &str, item_id: &str) -> Result<ApiPath, UrlError> {
let user = validate_user_id(user_id)?;
let item = validate_item_id(item_id)?;
ApiPath::dav(format!("trashbin/{user}/trash/{item}"))
}
pub(crate) fn restore_destination_path(
user_id: &str,
restore_path: &str,
) -> Result<ApiPath, UrlError> {
let user = validate_user_id(user_id)?;
let path = validate_restore_path(restore_path)?;
if path.is_empty() {
ApiPath::dav(format!("trashbin/{user}/restore"))
} else {
ApiPath::dav(format!("trashbin/{user}/restore/{path}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_user_and_item_paths() {
let root = trash_root_path("alice").expect("valid user");
assert_eq!(root.as_str(), "/remote.php/dav/trashbin/alice/trash/");
let item = trash_item_path("alice", "file.txt.d12345").expect("valid item");
assert_eq!(
item.as_str(),
"/remote.php/dav/trashbin/alice/trash/file.txt.d12345"
);
}
#[test]
fn restore_destination_paths() {
let default_dest = restore_destination_path("alice", "").expect("empty restore path");
assert_eq!(
default_dest.as_str(),
"/remote.php/dav/trashbin/alice/restore"
);
let slash_dest = restore_destination_path("alice", " / ").expect("whitespace/slashes");
assert_eq!(
slash_dest.as_str(),
"/remote.php/dav/trashbin/alice/restore"
);
let custom_dest =
restore_destination_path("alice", "Documents/notes.txt").expect("custom restore path");
assert_eq!(
custom_dest.as_str(),
"/remote.php/dav/trashbin/alice/restore/Documents/notes.txt"
);
let leading_slash =
restore_destination_path("alice", "/Photos/img.png").expect("leading slash");
assert_eq!(
leading_slash.as_str(),
"/remote.php/dav/trashbin/alice/restore/Photos/img.png"
);
}
#[test]
fn rejects_invalid_segments() {
assert!(trash_root_path("").is_err());
assert!(trash_root_path("..").is_err());
assert!(trash_root_path("alice/bob").is_err());
assert!(trash_root_path("alice?query").is_err());
assert!(trash_item_path("alice", "").is_err());
assert!(trash_item_path("alice", "..").is_err());
assert!(trash_item_path("alice", "item/nested").is_err());
assert!(restore_destination_path("alice", "../escape").is_err());
assert!(restore_destination_path("alice", "doc/./file").is_err());
assert!(restore_destination_path("alice", "doc?query").is_err());
}
}