Skip to main content

nextcloud_uploads/
path.rs

1//! URL and path helpers for Nextcloud WebDAV Chunked Upload v2.
2
3use nextcloud_dav::DavPath;
4use nextcloud_sdk_core::{ApiPath, UrlError};
5
6/// Validates that a user identifier is suitable as a single URL path segment.
7pub fn validate_user(user_id: &str) -> Result<&str, UrlError> {
8    let trimmed = user_id.trim();
9    if trimmed.is_empty() || trimmed == "." || trimmed == ".." || trimmed.contains('/') {
10        return Err(UrlError::InvalidSegment(user_id.to_owned()));
11    }
12    check_characters(trimmed)?;
13    Ok(trimmed)
14}
15
16/// Validates that an upload identifier is suitable as a single URL path segment.
17pub fn validate_upload_id(upload_id: &str) -> Result<&str, UrlError> {
18    let trimmed = upload_id.trim();
19    if trimmed.is_empty() || trimmed == "." || trimmed == ".." || trimmed.contains('/') {
20        return Err(UrlError::InvalidSegment(upload_id.to_owned()));
21    }
22    check_characters(trimmed)?;
23    Ok(trimmed)
24}
25
26/// Validates that a chunk index is valid.
27///
28/// Nextcloud Chunking v2 protocol numbers chunks starting from 1 up to 10,000.
29pub fn validate_chunk_index(chunk_index: u32) -> Result<u32, UrlError> {
30    if chunk_index == 0 {
31        return Err(UrlError::InvalidSegment(
32            "chunk index must be greater than zero".to_owned(),
33        ));
34    }
35    Ok(chunk_index)
36}
37
38/// Resolves and validates a destination path or URI for the `Destination` header.
39///
40/// If `destination_path` is already a full URI (e.g. starting with `http://` or `https://`)
41/// or an absolute WebDAV path (starting with `/remote.php/dav/`), it is validated and returned.
42///
43/// Otherwise, it is treated as a path relative to the user's root and transformed into
44/// `/remote.php/dav/files/{user_id}/{destination_path}` via [`DavPath`].
45pub fn destination_header_value(user_id: &str, destination_path: &str) -> Result<String, UrlError> {
46    let trimmed = destination_path.trim();
47    if trimmed.is_empty() {
48        return Err(UrlError::InvalidSegment(
49            "destination path cannot be empty".to_owned(),
50        ));
51    }
52
53    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
54        check_characters(trimmed)?;
55        return Ok(trimmed.to_owned());
56    }
57
58    if trimmed.starts_with("/remote.php/dav/") {
59        check_characters(trimmed)?;
60        return Ok(trimmed.to_owned());
61    }
62
63    let relative = trimmed.trim_start_matches('/');
64    if relative.is_empty() {
65        return Err(UrlError::InvalidSegment(
66            "destination path cannot be a bare slash".to_owned(),
67        ));
68    }
69
70    let dav_path = DavPath::new(user_id, relative)?;
71    Ok(dav_path.as_str().to_owned())
72}
73
74/// Rejects control characters and URL delimiters that cannot appear literally in paths.
75fn check_characters(segment: &str) -> Result<(), UrlError> {
76    let rejected = segment
77        .chars()
78        .any(|c| c.is_control() || matches!(c, '?' | '#' | '\\'));
79    if rejected {
80        return Err(UrlError::InvalidSegment(segment.to_owned()));
81    }
82    Ok(())
83}
84
85/// Builds the [`ApiPath`] addressing the upload collection directory.
86///
87/// Resulting path: `/remote.php/dav/uploads/{user_id}/{upload_id}`
88pub fn upload_collection_path(user_id: &str, upload_id: &str) -> Result<ApiPath, UrlError> {
89    let user = validate_user(user_id)?;
90    let upload = validate_upload_id(upload_id)?;
91    ApiPath::dav(format!("uploads/{user}/{upload}"))
92}
93
94/// Builds the [`ApiPath`] addressing a single chunk in the upload collection.
95///
96/// Formats the chunk index as a 5-digit zero-padded number (e.g., `00001`).
97/// Resulting path: `/remote.php/dav/uploads/{user_id}/{upload_id}/{chunk_number}`
98pub fn chunk_path(user_id: &str, upload_id: &str, chunk_index: u32) -> Result<ApiPath, UrlError> {
99    let user = validate_user(user_id)?;
100    let upload = validate_upload_id(upload_id)?;
101    let valid_index = validate_chunk_index(chunk_index)?;
102    let chunk_segment = format!("{valid_index:05}");
103    ApiPath::dav(format!("uploads/{user}/{upload}/{chunk_segment}"))
104}
105
106/// Builds the [`ApiPath`] addressing the assembly target file.
107///
108/// Resulting path: `/remote.php/dav/uploads/{user_id}/{upload_id}/.file`
109pub fn assembly_path(user_id: &str, upload_id: &str) -> Result<ApiPath, UrlError> {
110    let user = validate_user(user_id)?;
111    let upload = validate_upload_id(upload_id)?;
112    ApiPath::dav(format!("uploads/{user}/{upload}/.file"))
113}
114
115/// Builds the [`ApiPath`] addressing the upload directory to abort and delete it.
116///
117/// Resulting path: `/remote.php/dav/uploads/{user_id}/{upload_id}/`
118pub fn abort_path(user_id: &str, upload_id: &str) -> Result<ApiPath, UrlError> {
119    let user = validate_user(user_id)?;
120    let upload = validate_upload_id(upload_id)?;
121    ApiPath::dav(format!("uploads/{user}/{upload}/"))
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn valid_paths_construction() {
130        let collection = upload_collection_path("alice", "up-123").unwrap();
131        assert_eq!(collection.as_str(), "/remote.php/dav/uploads/alice/up-123");
132
133        let chunk1 = chunk_path("alice", "up-123", 1).unwrap();
134        assert_eq!(
135            chunk1.as_str(),
136            "/remote.php/dav/uploads/alice/up-123/00001"
137        );
138
139        let chunk42 = chunk_path("alice", "up-123", 42).unwrap();
140        assert_eq!(
141            chunk42.as_str(),
142            "/remote.php/dav/uploads/alice/up-123/00042"
143        );
144
145        let assembly = assembly_path("alice", "up-123").unwrap();
146        assert_eq!(
147            assembly.as_str(),
148            "/remote.php/dav/uploads/alice/up-123/.file"
149        );
150
151        let abort = abort_path("alice", "up-123").unwrap();
152        assert_eq!(abort.as_str(), "/remote.php/dav/uploads/alice/up-123/");
153    }
154
155    #[test]
156    fn destination_header_resolution() {
157        let rel = destination_header_value("alice", "Photos/trip.jpg").unwrap();
158        assert_eq!(rel, "/remote.php/dav/files/alice/Photos/trip.jpg");
159
160        let leading_slash_rel = destination_header_value("alice", "/Photos/trip.jpg").unwrap();
161        assert_eq!(
162            leading_slash_rel,
163            "/remote.php/dav/files/alice/Photos/trip.jpg"
164        );
165
166        let full_dav =
167            destination_header_value("alice", "/remote.php/dav/files/alice/Photos/trip.jpg")
168                .unwrap();
169        assert_eq!(full_dav, "/remote.php/dav/files/alice/Photos/trip.jpg");
170
171        let full_url = destination_header_value(
172            "alice",
173            "https://cloud.example.com/remote.php/dav/files/alice/Photos/trip.jpg",
174        )
175        .unwrap();
176        assert_eq!(
177            full_url,
178            "https://cloud.example.com/remote.php/dav/files/alice/Photos/trip.jpg"
179        );
180    }
181
182    #[test]
183    fn rejects_invalid_segments() {
184        assert!(validate_user("").is_err());
185        assert!(validate_user("..").is_err());
186        assert!(validate_user("alice/bob").is_err());
187        assert!(validate_user("alice?x").is_err());
188
189        assert!(validate_upload_id("").is_err());
190        assert!(validate_upload_id("..").is_err());
191        assert!(validate_upload_id("id/nested").is_err());
192        assert!(validate_upload_id("id#bad").is_err());
193
194        assert!(validate_chunk_index(0).is_err());
195        assert!(validate_chunk_index(1).is_ok());
196
197        assert!(destination_header_value("alice", "").is_err());
198        assert!(destination_header_value("alice", "/").is_err());
199        assert!(destination_header_value("alice", "../traversal").is_err());
200        assert!(destination_header_value("alice", "file?bad").is_err());
201    }
202}