diff --git a/Tasks.md b/Tasks.md
index 8ab556e..9a4b56b 100644
--- a/Tasks.md
+++ b/Tasks.md
@@ -73,7 +73,7 @@ This document defines the complete project roadmap and task tracking system for
| [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Fixed | Foundation |
| [NUT-003](#nut-003) | Implement WebDAV Upload Logic | Fixed | Feature |
| [NUT-004](#nut-004) | Implement OCS Share Link Generation | Fixed | Feature |
-| [NUT-005](#nut-005) | Implement Direct Download URL Builder | Triage | Feature |
+| [NUT-005](#nut-005) | Implement Direct Download URL Builder | Fixed | Feature |
| [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature |
| [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Triage | Feature |
| [NUT-008](#nut-008) | Implement CLI Frontend | Triage | Feature |
@@ -174,19 +174,19 @@ Implement creation of public share links using the OCS Sharing API.
- NUT-002
-
+
### Implement Direct Download URL Builder
**ID:** NUT-005
-**Status:** Triage
+**Status:** Fixed
**Type:** Feature
**Description:**
Generate direct-download URLs from share tokens.
**Requirements:**
-- [ ] Build URL: `/index.php/s//download`
-- [ ] Validate token format
-- [ ] Provide helper API
+- [x] Build URL: `/index.php/s//download`
+- [x] Validate token format
+- [x] Provide helper API
**Dependencies:**
- NUT-004
@@ -517,7 +517,7 @@ Tasks progress through defined statuses:
1. `triage`: Under initial evaluation and specification. Missing requirements allowed.
2. `pending`: Scope defined and ready for active work.
3. `in_progress`: Active implementation in progress.
-4. `done`: Work complete, requirements checked, and verified.
+4. `done`: Fixed. Work complete, requirements checked, and verified.
5. `blocked`: Blocked by an external obstacle or unmet dependency.
6. `cancelled`: Deprecated or abandoned.
diff --git a/nextcloud_client/src/client.rs b/nextcloud_client/src/client.rs
index 08f293f..1f74ed5 100644
--- a/nextcloud_client/src/client.rs
+++ b/nextcloud_client/src/client.rs
@@ -109,17 +109,6 @@ impl NextcloudClient {
.map_err(NextcloudError::from)
}
- /// Build the public download URL from a share token.
- ///
- /// Format: `https:///index.php/s//download`
- pub fn direct_download_url(&self, share_token: &str) -> Result {
- let path_segment = format!("index.php/s/{share_token}/download");
- self.config
- .server_url
- .join(&path_segment)
- .map_err(NextcloudError::from)
- }
-
/// Check server reachability and retrieve server version info from `/status.php`.
///
/// This endpoint does not require authentication.
@@ -218,18 +207,6 @@ mod tests {
);
}
- #[test]
- fn test_direct_download_url() {
- let config = ClientConfig::new("https://nextcloud.example.com", None).unwrap();
- let client = NextcloudClient::new(config).unwrap();
- let download_url = client.direct_download_url("AbCdEf12345").unwrap();
-
- assert_eq!(
- download_url.as_str(),
- "https://nextcloud.example.com/index.php/s/AbCdEf12345/download"
- );
- }
-
#[test]
fn test_status_deserialization() {
let sample_json = r#"{
diff --git a/nextcloud_client/src/download.rs b/nextcloud_client/src/download.rs
new file mode 100644
index 0000000..f63bf30
--- /dev/null
+++ b/nextcloud_client/src/download.rs
@@ -0,0 +1,149 @@
+use url::Url;
+
+use crate::client::NextcloudClient;
+use crate::error::{NextcloudError, Result};
+
+/// Minimum permitted length for a Nextcloud share token.
+pub const MIN_SHARE_TOKEN_LENGTH: usize = 6;
+
+/// Maximum permitted length for a Nextcloud share token.
+pub const MAX_SHARE_TOKEN_LENGTH: usize = 64;
+
+/// Validates that a share token consists only of valid alphanumeric or safe characters (`[a-zA-Z0-9_-]`).
+///
+/// Returns `Ok(())` if valid, or a [`NextcloudError::Other`] describing the validation failure.
+pub fn validate_share_token(token: &str) -> Result<()> {
+ let trimmed = token.trim();
+
+ if trimmed.is_empty() {
+ return Err(NextcloudError::Other("Share token cannot be empty".into()));
+ }
+
+ if trimmed.len() < MIN_SHARE_TOKEN_LENGTH || trimmed.len() > MAX_SHARE_TOKEN_LENGTH {
+ return Err(NextcloudError::Other(format!(
+ "Invalid share token length ({}): expected between {} and {} characters",
+ trimmed.len(),
+ MIN_SHARE_TOKEN_LENGTH,
+ MAX_SHARE_TOKEN_LENGTH
+ )));
+ }
+
+ if !trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
+ return Err(NextcloudError::Other(format!(
+ "Invalid characters in share token '{token}': tokens must only contain alphanumeric characters, hyphens, or underscores"
+ )));
+ }
+
+ Ok(())
+}
+
+/// Builds a direct download URL for a shared file given a base Nextcloud URL and share token.
+///
+/// Format: `https:///index.php/s//download`
+pub fn build_direct_download_url(base_url: &Url, share_token: &str) -> Result {
+ validate_share_token(share_token)?;
+ let clean_token = share_token.trim();
+ let path_segment = format!("index.php/s/{clean_token}/download");
+ base_url.join(&path_segment).map_err(NextcloudError::from)
+}
+
+/// Builds a direct download URL for a specific file within a shared folder.
+///
+/// Nextcloud supports downloading an individual file from a shared directory via query parameters:
+/// `https:///index.php/s//download?path=%2F&files=filename.ext`
+pub fn build_subfile_download_url(
+ base_url: &Url,
+ share_token: &str,
+ subfile_path: &str,
+) -> Result {
+ let mut url = build_direct_download_url(base_url, share_token)?;
+
+ let clean_path = subfile_path.trim_start_matches('/');
+ if let Some((dir, file)) = clean_path.rsplit_once('/') {
+ let root_dir = format!("/{dir}");
+ url.query_pairs_mut()
+ .append_pair("path", &root_dir)
+ .append_pair("files", file);
+ } else {
+ url.query_pairs_mut()
+ .append_pair("path", "/")
+ .append_pair("files", clean_path);
+ }
+
+ Ok(url)
+}
+
+impl NextcloudClient {
+ /// Generate a validated direct download URL from a share token.
+ pub fn direct_download_url(&self, share_token: &str) -> Result {
+ build_direct_download_url(&self.config.server_url, share_token)
+ }
+
+ /// Generate a validated direct download URL for a specific sub-file in a shared folder.
+ pub fn subfile_download_url(&self, share_token: &str, subfile_path: &str) -> Result {
+ build_subfile_download_url(&self.config.server_url, share_token, subfile_path)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_valid_tokens() {
+ assert!(validate_share_token("AbCdEf123456789").is_ok());
+ assert!(validate_share_token("aB1_2-3z").is_ok());
+ assert!(validate_share_token("kLx9Q2wP0vN4m1R").is_ok());
+ }
+
+ #[test]
+ fn test_invalid_tokens() {
+ // Empty
+ assert!(validate_share_token("").is_err());
+ // Too short (< 6 chars)
+ assert!(validate_share_token("abc").is_err());
+ // Too long (> 64 chars)
+ assert!(validate_share_token(&"a".repeat(65)).is_err());
+ // Path traversal / slashes
+ assert!(validate_share_token("../secret").is_err());
+ assert!(validate_share_token("abc/def").is_err());
+ // Spaces & query chars
+ assert!(validate_share_token("abc def").is_err());
+ assert!(validate_share_token("abc?download=1").is_err());
+ assert!(validate_share_token("abc#frag").is_err());
+ }
+
+ #[test]
+ fn test_build_direct_download_url() {
+ let base = Url::parse("https://cloud.example.com/").unwrap();
+ let download_url = build_direct_download_url(&base, "kLx9Q2wP0vN4m1R").unwrap();
+
+ assert_eq!(
+ download_url.as_str(),
+ "https://cloud.example.com/index.php/s/kLx9Q2wP0vN4m1R/download"
+ );
+ }
+
+ #[test]
+ fn test_build_subpath_installation() {
+ let base = Url::parse("https://example.com/nextcloud/").unwrap();
+ let download_url = build_direct_download_url(&base, "kLx9Q2wP0vN4m1R").unwrap();
+
+ assert_eq!(
+ download_url.as_str(),
+ "https://example.com/nextcloud/index.php/s/kLx9Q2wP0vN4m1R/download"
+ );
+ }
+
+ #[test]
+ fn test_build_subfile_download_url() {
+ let base = Url::parse("https://cloud.example.com/").unwrap();
+ let subfile_url =
+ build_subfile_download_url(&base, "kLx9Q2wP0vN4m1R", "archive/document.pdf").unwrap();
+
+ assert_eq!(
+ subfile_url.as_str(),
+ "https://cloud.example.com/index.php/s/kLx9Q2wP0vN4m1R/download?path=%2Farchive&files=document.pdf"
+ );
+ }
+}
diff --git a/nextcloud_client/src/lib.rs b/nextcloud_client/src/lib.rs
index 8293657..da30533 100644
--- a/nextcloud_client/src/lib.rs
+++ b/nextcloud_client/src/lib.rs
@@ -7,6 +7,7 @@ pub mod auth;
pub mod client;
pub mod config;
pub mod credentials;
+pub mod download;
pub mod error;
pub mod models;
pub mod progress;
@@ -21,6 +22,10 @@ pub use auth::{
pub use client::{NextcloudClient, ServerStatus};
pub use config::{AccountCredentials, ClientConfig};
pub use credentials::{CredentialStore, StoredAccount, KEYRING_SERVICE_NAME};
+pub use download::{
+ build_direct_download_url, build_subfile_download_url, validate_share_token,
+ MAX_SHARE_TOKEN_LENGTH, MIN_SHARE_TOKEN_LENGTH,
+};
pub use error::{NextcloudError, Result};
pub use models::{UploadOptions, UploadResult};
pub use progress::{ProgressCallback, ProgressEvent};