diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1e7c2a7..3cdbae7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,6 +13,7 @@ confluence2md/ ├── src/ # Rust source (Cargo binary + library) │ ├── main.rs # CLI entry point: arg parsing + pipeline orchestration │ ├── lib.rs # Library root: re-declares public modules +│ ├── auth.rs # Auth env resolution + Authorization header helpers │ ├── confluence.rs # Confluence REST client: page/attachment fetch, image rewrite │ ├── drawio.rs # draw.io fallback resolution + PNG tEXt embedding │ ├── plantuml.rs # PlantUML source extraction + !include rewriting @@ -80,7 +81,12 @@ confluence2md is a single Rust crate that exposes one binary (`confluence2md`) a - **Inputs:** `` arg, `--output-path`, `--log-level`, `--table-conversion`, plus `CONFLUENCE2MD_*` env vars. - **Outputs:** `Page_Title.md`, `Page_Title_assets/`. When `--dump-state-path ` or `CONFLUENCE2MD_DUMP_STATE_PATH` is specified, the raw page API snapshot (`content.json`), debug intermediates (`export.html`, `storage.html`, `rewrite_*.html`), and raw draw.io XML files (`*.drawio`) are written to that dump directory. -### 3.2. `confluence` — REST Client and Image Rewriter +### 3.2. `auth` — Authentication + +- **Responsibility:** Resolve Confluence credentials from environment variables and build Authorization headers. +- **Key functions:** `resolve_auth`, `auth_headers`, `binary_auth_headers`. + +### 3.3. `confluence` — REST Client and Image Rewriter - **Responsibility:** Talk to the Confluence REST API v1 and download regular binary assets referenced by the page HTML. draw.io and PlantUML assets are resolved by their dedicated modules first. - **Key types:** `EnvConfig`, `PageResult`, `Attachment`, `AttachmentMaps`, `DownloadBinaryOptions`, `DownloadAttachmentOptions`, `DownloadImagesOptions`. diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..c7242db --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,719 @@ +//! Authentication helpers for Confluence REST API access. +//! +//! Resolves Bearer (personal access token) or Basic (Cloud username + API token) +//! credentials from environment variables and builds request headers. + +use anyhow::{Result, bail}; +use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue}; + +/// Reads authentication environment variables and returns the full +/// Authorization header value string. +/// +/// Priority: +/// 1. If CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN is set (non-empty, non-whitespace) → "Bearer " +/// 2. If both CONFLUENCE2MD_USERNAME and CONFLUENCE2MD_API_TOKEN are set → "Basic " +/// 3. Otherwise → error +pub fn resolve_auth() -> Result { + let pat = std::env::var("CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN").ok(); + let username = std::env::var("CONFLUENCE2MD_USERNAME").ok(); + let api_token = std::env::var("CONFLUENCE2MD_API_TOKEN").ok(); + + // 1. Check PAT first + if let Some(ref pat_val) = pat + && !pat_val.is_empty() + { + // PAT is set (non-empty) — validate it's not whitespace-only + if pat_val.trim().is_empty() { + bail!("CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN is set but empty or whitespace-only."); + } + return Ok(format!("Bearer {pat_val}")); + } + + // 2. PAT is unset or empty — check Cloud pair + let has_username = matches!(&username, Some(u) if !u.is_empty()); + let has_api_token = matches!(&api_token, Some(t) if !t.is_empty()); + + if has_username && has_api_token { + // 3. Both Cloud vars set — validate neither is whitespace-only + let u = username.unwrap(); + let t = api_token.unwrap(); + if u.trim().is_empty() { + bail!("CONFLUENCE2MD_USERNAME must not be empty or whitespace-only."); + } + if t.trim().is_empty() { + bail!("CONFLUENCE2MD_API_TOKEN must not be empty or whitespace-only."); + } + let credentials = format!("{u}:{t}"); + let encoded = base64_encode(credentials.as_bytes()); + return Ok(format!("Basic {encoded}")); + } + + // 4. Exactly one Cloud var set — error naming the missing one + if has_username && !has_api_token { + bail!( + "CONFLUENCE2MD_USERNAME is set but CONFLUENCE2MD_API_TOKEN is missing. \ + Both are required for Cloud authentication." + ); + } + if has_api_token && !has_username { + bail!( + "CONFLUENCE2MD_API_TOKEN is set but CONFLUENCE2MD_USERNAME is missing. \ + Both are required for Cloud authentication." + ); + } + + // 5. Nothing set at all + bail!( + "No authentication configured. Set CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN for Bearer auth, \ + or both CONFLUENCE2MD_USERNAME and CONFLUENCE2MD_API_TOKEN for Basic auth." + ); +} + +// ── Base64 encoding ──────────────────────────────────────────────── + +/// RFC 4648 standard Base64 encoding (no line breaks, standard alphabet with padding). +pub(crate) fn base64_encode(input: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + let chunks = input.chunks(3); + + for chunk in chunks { + let b0 = chunk[0] as u32; + let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; + + let triple = (b0 << 16) | (b1 << 8) | b2; + + out.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char); + out.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char); + + if chunk.len() > 1 { + out.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char); + } else { + out.push('='); + } + + if chunk.len() > 2 { + out.push(ALPHABET[(triple & 0x3F) as usize] as char); + } else { + out.push('='); + } + } + + out +} + +// ── HTTP helpers ─────────────────────────────────────────────────── + +pub(crate) fn auth_headers(auth_value: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + if let Ok(v) = HeaderValue::from_str(auth_value) { + headers.insert(AUTHORIZATION, v); + } + headers +} + +pub(crate) fn binary_auth_headers(auth_value: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Ok(v) = HeaderValue::from_str(auth_value) { + headers.insert(AUTHORIZATION, v); + } + headers +} + +#[cfg(test)] +mod tests { + use super::{auth_headers, base64_encode, binary_auth_headers, resolve_auth}; + use reqwest::header::{ACCEPT, AUTHORIZATION}; + + // Feature: cloud-api-token-auth, Property 1: base64 encoding correctness + + #[test] + fn base64_encode_empty_input() { + assert_eq!(base64_encode(b""), ""); + } + + #[test] + fn base64_encode_rfc4648_test_vectors() { + // RFC 4648 §10 test vectors + assert_eq!(base64_encode(b"f"), "Zg=="); + assert_eq!(base64_encode(b"fo"), "Zm8="); + assert_eq!(base64_encode(b"foo"), "Zm9v"); + assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); + assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + } + + #[test] + fn base64_encode_confluence_credential_example() { + // Typical Cloud auth: email:api-token + let input = b"user@example.com:my-api-token"; + assert_eq!( + base64_encode(input), + "dXNlckBleGFtcGxlLmNvbTpteS1hcGktdG9rZW4=" + ); + } + + #[test] + fn base64_encode_non_ascii_utf8() { + // Non-ASCII UTF-8 bytes + let input = "ü:tökën".as_bytes(); + assert_eq!(base64_encode(input), "w7w6dMO2a8Orbg=="); + } + + #[test] + fn base64_encode_all_zero_bytes() { + assert_eq!(base64_encode(&[0, 0, 0]), "AAAA"); + assert_eq!(base64_encode(&[0]), "AA=="); + assert_eq!(base64_encode(&[0, 0]), "AAA="); + } + + #[test] + fn base64_encode_all_ff_bytes() { + assert_eq!(base64_encode(&[0xFF, 0xFF, 0xFF]), "////"); + } + + /// Validates: Requirements 3.1, 3.4, 6.1 + /// Explicitly verifies all padding cases based on input length mod 3: + /// - len % 3 == 0 → no padding + /// - len % 3 == 1 → two `=` padding chars + /// - len % 3 == 2 → one `=` padding char + #[test] + fn base64_encode_padding_cases_by_length_mod_3() { + // len % 3 == 0: no padding (lengths 3, 6) + let no_pad_3 = base64_encode(b"abc"); + assert!( + !no_pad_3.ends_with('='), + "len=3 should have no padding, got: {no_pad_3}" + ); + assert_eq!(no_pad_3, "YWJj"); + + let no_pad_6 = base64_encode(b"abcdef"); + assert!( + !no_pad_6.ends_with('='), + "len=6 should have no padding, got: {no_pad_6}" + ); + assert_eq!(no_pad_6, "YWJjZGVm"); + + // len % 3 == 1: two padding chars (lengths 1, 4) + let two_pad_1 = base64_encode(b"a"); + assert!( + two_pad_1.ends_with("=="), + "len=1 should have == padding, got: {two_pad_1}" + ); + assert_eq!(two_pad_1, "YQ=="); + + let two_pad_4 = base64_encode(b"abcd"); + assert!( + two_pad_4.ends_with("=="), + "len=4 should have == padding, got: {two_pad_4}" + ); + assert_eq!(two_pad_4, "YWJjZA=="); + + // len % 3 == 2: one padding char (lengths 2, 5) + let one_pad_2 = base64_encode(b"ab"); + assert!( + one_pad_2.ends_with('=') && !one_pad_2.ends_with("=="), + "len=2 should have single = padding, got: {one_pad_2}" + ); + assert_eq!(one_pad_2, "YWI="); + + let one_pad_5 = base64_encode(b"abcde"); + assert!( + one_pad_5.ends_with('=') && !one_pad_5.ends_with("=="), + "len=5 should have single = padding, got: {one_pad_5}" + ); + assert_eq!(one_pad_5, "YWJjZGU="); + } + + // Feature: cloud-api-token-auth, Property 1: base64 encoding correctness + // Validates: Requirements 3.1, 3.4, 6.1 + + /// Programmatic property coverage: verify all single-byte values (0x00..=0xFF) + /// produce output containing only valid base64 characters and the expected length. + #[test] + fn base64_encode_property_all_single_bytes() { + let valid_chars = |s: &str| -> bool { + s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') + }; + + for byte in 0x00u8..=0xFF { + let input = [byte]; + let output = base64_encode(&input); + + // Single-byte input → output length must be 4 (4 * ceil(1/3) = 4) + assert_eq!( + output.len(), + 4, + "byte 0x{byte:02X}: expected output length 4, got {}", + output.len() + ); + + // Output must only contain valid base64 characters + assert!( + valid_chars(&output), + "byte 0x{byte:02X}: output contains invalid base64 chars: {output}" + ); + } + } + + /// Programmatic property coverage: verify inputs of length 0..=4 produce correct + /// output length and only valid base64 characters, covering all padding cases. + #[test] + fn base64_encode_property_lengths_0_through_4() { + let valid_chars = |s: &str| -> bool { + s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') + }; + + // Expected output lengths: 4 * ceil(input_len / 3) + // len 0 → 0, len 1 → 4, len 2 → 4, len 3 → 4, len 4 → 8 + let expected_output_lengths: [usize; 5] = [0, 4, 4, 4, 8]; + + // Use representative byte patterns for each length + let test_patterns: &[&[u8]] = &[ + &[], // length 0 + &[0x00], // length 1 (min byte) + &[0x7F, 0x80], // length 2 (boundary bytes) + &[0xFF, 0x00, 0xAB], // length 3 (mixed) + &[0x01, 0x02, 0x03, 0x04], // length 4 + ]; + + for pattern in test_patterns { + let output = base64_encode(pattern); + let expected_len = expected_output_lengths[pattern.len()]; + + assert_eq!( + output.len(), + expected_len, + "input len {}: expected output length {expected_len}, got {}. input: {pattern:?}", + pattern.len(), + output.len() + ); + + assert!( + valid_chars(&output), + "input len {}: output contains invalid base64 chars: {output}. input: {pattern:?}", + pattern.len() + ); + } + + // Additional patterns per length to cover more byte diversity + let additional_patterns: &[&[u8]] = &[ + // More length 1 examples + &[0xFF], + &[0x41], // 'A' + &[0x80], + // More length 2 examples + &[0x00, 0x00], + &[0xFF, 0xFF], + &[0x41, 0x42], + // More length 3 examples + &[0x00, 0x00, 0x00], + &[0xFF, 0xFF, 0xFF], + &[0x41, 0x42, 0x43], + // More length 4 examples + &[0x00, 0x00, 0x00, 0x00], + &[0xFF, 0xFF, 0xFF, 0xFF], + &[0xDE, 0xAD, 0xBE, 0xEF], + ]; + + for pattern in additional_patterns { + let output = base64_encode(pattern); + let expected_len = expected_output_lengths[pattern.len()]; + + assert_eq!( + output.len(), + expected_len, + "input len {}: expected output length {expected_len}, got {}. input: {pattern:?}", + pattern.len(), + output.len() + ); + + assert!( + valid_chars(&output), + "input len {}: output contains invalid base64 chars: {output}. input: {pattern:?}", + pattern.len() + ); + } + } + + // Feature: cloud-api-token-auth — auth_headers / binary_auth_headers tests + // Validates: Requirements 3.2, 4.2 + + #[test] + fn auth_headers_bearer_sets_authorization_and_accept() { + let headers = auth_headers("Bearer my-token"); + assert_eq!( + headers.get(AUTHORIZATION).unwrap().to_str().unwrap(), + "Bearer my-token" + ); + assert_eq!( + headers.get(ACCEPT).unwrap().to_str().unwrap(), + "application/json" + ); + } + + #[test] + fn auth_headers_basic_sets_authorization_and_accept() { + let headers = auth_headers("Basic dXNlcjpwYXNz"); + assert_eq!( + headers.get(AUTHORIZATION).unwrap().to_str().unwrap(), + "Basic dXNlcjpwYXNz" + ); + assert_eq!( + headers.get(ACCEPT).unwrap().to_str().unwrap(), + "application/json" + ); + } + + #[test] + fn binary_auth_headers_bearer_sets_authorization_without_accept() { + let headers = binary_auth_headers("Bearer my-token"); + assert_eq!( + headers.get(AUTHORIZATION).unwrap().to_str().unwrap(), + "Bearer my-token" + ); + assert!( + headers.get(ACCEPT).is_none(), + "binary_auth_headers must not set Accept header" + ); + } + + #[test] + fn binary_auth_headers_basic_sets_authorization_without_accept() { + let headers = binary_auth_headers("Basic dXNlcjpwYXNz"); + assert_eq!( + headers.get(AUTHORIZATION).unwrap().to_str().unwrap(), + "Basic dXNlcjpwYXNz" + ); + assert!( + headers.get(ACCEPT).is_none(), + "binary_auth_headers must not set Accept header" + ); + } + + // ── resolve_auth() unit tests ────────────────────────────────────── + // + // Feature: cloud-api-token-auth + // Validates: Requirements 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5, 4.1, 4.3, 7.2, 7.3 + // + // Environment variables are process-global, so these tests must not run + // concurrently. We use a static Mutex to serialize access. + + use std::sync::Mutex; + + static AUTH_ENV_LOCK: Mutex<()> = Mutex::new(()); + + const PAT_VAR: &str = "CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN"; + const USER_VAR: &str = "CONFLUENCE2MD_USERNAME"; + const TOKEN_VAR: &str = "CONFLUENCE2MD_API_TOKEN"; + + /// Helper: clear all auth env vars, returning the lock guard. + /// + /// # Safety + /// Env var manipulation is unsafe in Rust 2024 edition because it is not + /// thread-safe. We serialize access via AUTH_ENV_LOCK so concurrent + /// modification cannot occur within our test suite. + fn clear_auth_env() -> std::sync::MutexGuard<'static, ()> { + let guard = AUTH_ENV_LOCK.lock().unwrap(); + // SAFETY: guarded by AUTH_ENV_LOCK — no concurrent env mutation. + unsafe { + std::env::remove_var(PAT_VAR); + std::env::remove_var(USER_VAR); + std::env::remove_var(TOKEN_VAR); + } + guard + } + + #[test] + fn resolve_auth_pat_set_alone_returns_bearer() { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { std::env::set_var(PAT_VAR, "my-pat-value") }; + + let result = resolve_auth().unwrap(); + assert_eq!(result, "Bearer my-pat-value"); + } + + #[test] + fn resolve_auth_cloud_vars_set_no_pat_returns_basic() { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { + std::env::set_var(USER_VAR, "user@example.com"); + std::env::set_var(TOKEN_VAR, "my-api-token"); + } + + let result = resolve_auth().unwrap(); + // base64("user@example.com:my-api-token") = "dXNlckBleGFtcGxlLmNvbTpteS1hcGktdG9rZW4=" + assert_eq!(result, "Basic dXNlckBleGFtcGxlLmNvbTpteS1hcGktdG9rZW4="); + } + + #[test] + fn resolve_auth_both_cloud_and_pat_set_returns_bearer_priority() { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { + std::env::set_var(PAT_VAR, "my-pat"); + std::env::set_var(USER_VAR, "user@example.com"); + std::env::set_var(TOKEN_VAR, "my-api-token"); + } + + let result = resolve_auth().unwrap(); + assert_eq!(result, "Bearer my-pat"); + } + + #[test] + fn resolve_auth_no_vars_set_returns_error_listing_both_options() { + let _guard = clear_auth_env(); + + let err = resolve_auth().unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN"), + "error should mention PAT var: {msg}" + ); + assert!( + msg.contains("CONFLUENCE2MD_USERNAME") || msg.contains("CONFLUENCE2MD_API_TOKEN"), + "error should mention Cloud vars: {msg}" + ); + } + + #[test] + fn resolve_auth_only_username_set_errors_naming_missing_api_token() { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { std::env::set_var(USER_VAR, "user@example.com") }; + + let err = resolve_auth().unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("CONFLUENCE2MD_API_TOKEN"), + "error should name missing API token var: {msg}" + ); + } + + #[test] + fn resolve_auth_only_api_token_set_errors_naming_missing_username() { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { std::env::set_var(TOKEN_VAR, "my-api-token") }; + + let err = resolve_auth().unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("CONFLUENCE2MD_USERNAME"), + "error should name missing username var: {msg}" + ); + } + + #[test] + fn resolve_auth_pat_whitespace_only_returns_error() { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { std::env::set_var(PAT_VAR, " \t ") }; + + let err = resolve_auth().unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("whitespace") || msg.contains("empty"), + "error should mention whitespace/empty PAT: {msg}" + ); + } + + #[test] + fn resolve_auth_username_whitespace_only_with_valid_api_token_returns_error() { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { + std::env::set_var(USER_VAR, " "); + std::env::set_var(TOKEN_VAR, "valid-token"); + } + + let err = resolve_auth().unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("USERNAME") || msg.contains("username"), + "error should mention username issue: {msg}" + ); + } + + #[test] + fn resolve_auth_api_token_whitespace_only_with_valid_username_returns_error() { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { + std::env::set_var(USER_VAR, "user@example.com"); + std::env::set_var(TOKEN_VAR, " \t"); + } + + let err = resolve_auth().unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("API_TOKEN") || msg.contains("api_token") || msg.contains("API token"), + "error should mention API token issue: {msg}" + ); + } + + // Feature: cloud-api-token-auth, Property 2: PAT priority and Bearer header format + // **Validates: Requirements 2.1, 2.4, 4.1, 4.2** + #[test] + fn resolve_auth_pat_priority_and_bearer_format_programmatic() { + let long_pat = "a".repeat(100); + let pat_values_owned: Vec<&str> = vec![ + "x", + &long_pat, + "abc!@#$%^&*()_+-=[]{}", + "tökën-pät", + "token with spaces", + ]; + + for pat_val in &pat_values_owned { + // Case A: PAT set alone (no cloud vars) + { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { + std::env::set_var(PAT_VAR, pat_val); + } + + let result = resolve_auth(); + assert!( + result.is_ok(), + "PAT alone should succeed for pat={pat_val:?}, got err: {:?}", + result.unwrap_err() + ); + let header = result.unwrap(); + assert!( + header.starts_with("Bearer "), + "Header must start with 'Bearer ' for pat={pat_val:?}, got: {header:?}" + ); + let remainder = &header["Bearer ".len()..]; + assert_eq!( + remainder, *pat_val, + "Remainder after 'Bearer ' must equal exact PAT value for pat={pat_val:?}" + ); + } + + // Case B: PAT set WITH cloud vars also set (PAT takes priority) + { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { + std::env::set_var(PAT_VAR, pat_val); + std::env::set_var(USER_VAR, "user@example.com"); + std::env::set_var(TOKEN_VAR, "token123"); + } + + let result = resolve_auth(); + assert!( + result.is_ok(), + "PAT with cloud vars should succeed for pat={pat_val:?}, got err: {:?}", + result.unwrap_err() + ); + let header = result.unwrap(); + assert!( + header.starts_with("Bearer "), + "Header must start with 'Bearer ' when PAT has priority for pat={pat_val:?}, got: {header:?}" + ); + let remainder = &header["Bearer ".len()..]; + assert_eq!( + remainder, *pat_val, + "Remainder after 'Bearer ' must equal exact PAT value (priority over cloud) for pat={pat_val:?}" + ); + } + } + } + + // Feature: cloud-api-token-auth, Property 3: Cloud Basic auth header format + // **Validates: Requirements 1.5, 2.2, 3.1, 3.4** + // + // For any pair of non-empty, non-whitespace-only values for username and + // api_token, when PAT is not set, resolve_auth() returns "Basic " + // where == base64_encode("{username}:{api_token}".as_bytes()). + // + // This test loops over ASCII and non-ASCII username/token pairs, including + // colons in the token (edge case for the separator), and verifies: + // - Result is Ok + // - Result starts with "Basic " + // - The base64 portion only contains valid base64 chars [A-Za-z0-9+/=] + // - The base64 portion matches base64_encode("{username}:{api_token}".as_bytes()) + #[test] + fn resolve_auth_basic_header_format_property_coverage() { + let long_user = "a".repeat(50) + "@example.com"; + let long_token = "b".repeat(100); + + let pairs: Vec<(&str, &str)> = vec![ + // Normal ASCII + ("user@example.com", "simple-token"), + // Unicode username + ("üser@example.com", "token123"), + // Unicode token + ("user@example.com", "tökën-123"), + // Both unicode + ("ü@ëx.com", "tök:ën"), + // Colon in token (edge case for the separator) + ("user@example.com", "token:with:colons"), + // Special chars + ("user+tag@example.com", "abc!@#$%^&*()"), + ]; + + // Also include long values (owned strings) + let owned_pairs: Vec<(String, String)> = vec![(long_user.clone(), long_token.clone())]; + + // Combine into a single iteration + let all_pairs: Vec<(&str, &str)> = pairs + .iter() + .copied() + .chain(owned_pairs.iter().map(|(u, t)| (u.as_str(), t.as_str()))) + .collect(); + + for (username, api_token) in &all_pairs { + let _guard = clear_auth_env(); + // SAFETY: guarded by AUTH_ENV_LOCK. + unsafe { + std::env::set_var(USER_VAR, username); + std::env::set_var(TOKEN_VAR, api_token); + } + + let result = resolve_auth(); + assert!( + result.is_ok(), + "resolve_auth() should succeed for ({username:?}, {api_token:?}), got: {:?}", + result.err() + ); + + let header_value = result.unwrap(); + + // Must start with "Basic " + assert!( + header_value.starts_with("Basic "), + "Header for ({username:?}, {api_token:?}) should start with 'Basic ', got: {header_value}" + ); + + // Extract the base64 portion + let b64_portion = &header_value["Basic ".len()..]; + + // Verify base64 alphabet: only [A-Za-z0-9+/=] + assert!( + b64_portion + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='), + "Base64 portion for ({username:?}, {api_token:?}) contains invalid chars: {b64_portion}" + ); + + // Verify correctness: re-encode expected input and compare + let expected_input = format!("{username}:{api_token}"); + let expected_b64 = base64_encode(expected_input.as_bytes()); + assert_eq!( + b64_portion, expected_b64, + "Base64 mismatch for ({username:?}, {api_token:?}): got {b64_portion}, expected {expected_b64}" + ); + } + } + +} diff --git a/src/confluence.rs b/src/confluence.rs index 2784f06..5d9393b 100644 --- a/src/confluence.rs +++ b/src/confluence.rs @@ -9,18 +9,26 @@ use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; use regex::Regex; use reqwest::Client; use reqwest::header::{ - ACCEPT, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap, HeaderValue, + ACCEPT, + AUTHORIZATION, + CONTENT_DISPOSITION, + CONTENT_TYPE, + HeaderMap, + HeaderValue, }; use serde::Deserialize; use serde_json::Value; use tracing::{debug, warn}; use url::Url; +use crate::auth::{auth_headers, binary_auth_headers}; use crate::utils::{ HeaderHints, URI_COMPONENT, decode_html_attribute, ensure_dir, get_file_name_from_url_or_headers, resolve_url, to_markdown_asset_path, }; +pub use crate::auth::resolve_auth; + const USER_AGENT: &str = concat!("confluence2md/", env!("CARGO_PKG_VERSION")); const SHORT_URL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); @@ -49,125 +57,6 @@ pub struct AttachmentMaps { pub by_title: HashMap, } -// ── Authentication ───────────────────────────────────────────────── - -/// Reads authentication environment variables and returns the full -/// Authorization header value string. -/// -/// Priority: -/// 1. If CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN is set (non-empty, non-whitespace) → "Bearer " -/// 2. If both CONFLUENCE2MD_USERNAME and CONFLUENCE2MD_API_TOKEN are set → "Basic " -/// 3. Otherwise → error -pub fn resolve_auth() -> Result { - let pat = std::env::var("CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN").ok(); - let username = std::env::var("CONFLUENCE2MD_USERNAME").ok(); - let api_token = std::env::var("CONFLUENCE2MD_API_TOKEN").ok(); - - // 1. Check PAT first - if let Some(ref pat_val) = pat - && !pat_val.is_empty() - { - // PAT is set (non-empty) — validate it's not whitespace-only - if pat_val.trim().is_empty() { - bail!("CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN is set but empty or whitespace-only."); - } - return Ok(format!("Bearer {pat_val}")); - } - - // 2. PAT is unset or empty — check Cloud pair - let has_username = matches!(&username, Some(u) if !u.is_empty()); - let has_api_token = matches!(&api_token, Some(t) if !t.is_empty()); - - if has_username && has_api_token { - // 3. Both Cloud vars set — validate neither is whitespace-only - let u = username.unwrap(); - let t = api_token.unwrap(); - if u.trim().is_empty() { - bail!("CONFLUENCE2MD_USERNAME must not be empty or whitespace-only."); - } - if t.trim().is_empty() { - bail!("CONFLUENCE2MD_API_TOKEN must not be empty or whitespace-only."); - } - let credentials = format!("{u}:{t}"); - let encoded = base64_encode(credentials.as_bytes()); - return Ok(format!("Basic {encoded}")); - } - - // 4. Exactly one Cloud var set — error naming the missing one - if has_username && !has_api_token { - bail!( - "CONFLUENCE2MD_USERNAME is set but CONFLUENCE2MD_API_TOKEN is missing. \ - Both are required for Cloud authentication." - ); - } - if has_api_token && !has_username { - bail!( - "CONFLUENCE2MD_API_TOKEN is set but CONFLUENCE2MD_USERNAME is missing. \ - Both are required for Cloud authentication." - ); - } - - // 5. Nothing set at all - bail!( - "No authentication configured. Set CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN for Bearer auth, \ - or both CONFLUENCE2MD_USERNAME and CONFLUENCE2MD_API_TOKEN for Basic auth." - ); -} - -// ── Base64 encoding ──────────────────────────────────────────────── - -/// RFC 4648 standard Base64 encoding (no line breaks, standard alphabet with padding). -fn base64_encode(input: &[u8]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - - let mut out = String::with_capacity(input.len().div_ceil(3) * 4); - let chunks = input.chunks(3); - - for chunk in chunks { - let b0 = chunk[0] as u32; - let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; - let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; - - let triple = (b0 << 16) | (b1 << 8) | b2; - - out.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char); - out.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char); - - if chunk.len() > 1 { - out.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char); - } else { - out.push('='); - } - - if chunk.len() > 2 { - out.push(ALPHABET[(triple & 0x3F) as usize] as char); - } else { - out.push('='); - } - } - - out -} - -// ── HTTP helpers ─────────────────────────────────────────────────── - -fn auth_headers(auth_value: &str) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(ACCEPT, HeaderValue::from_static("application/json")); - if let Ok(v) = HeaderValue::from_str(auth_value) { - headers.insert(AUTHORIZATION, v); - } - headers -} - -fn binary_auth_headers(auth_value: &str) -> HeaderMap { - let mut headers = HeaderMap::new(); - if let Ok(v) = HeaderValue::from_str(auth_value) { - headers.insert(AUTHORIZATION, v); - } - headers -} - pub async fn fetch_json(client: &Client, url: &str, token: &str) -> Result { let text = fetch_json_text(client, url, token).await?; parse_json_text(&text) @@ -1022,592 +911,373 @@ mod tests { )); } - // Feature: cloud-api-token-auth, Property 1: base64 encoding correctness +// ── Short URL expansion ──────────────────────────────────────────── - #[test] - fn base64_encode_empty_input() { - assert_eq!(base64_encode(b""), ""); - } +// Matches `/x/` as the final path segment, with any context path in +// front of it (e.g. Confluence Cloud's `/wiki/x/`). +static SHORT_URL_RE: Lazy = Lazy::new(|| Regex::new(r"(?:^|/)x/[A-Za-z0-9_-]+/?$").unwrap()); - #[test] - fn base64_encode_rfc4648_test_vectors() { - // RFC 4648 §10 test vectors - assert_eq!(base64_encode(b"f"), "Zg=="); - assert_eq!(base64_encode(b"fo"), "Zm8="); - assert_eq!(base64_encode(b"foo"), "Zm9v"); - assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); - assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); - assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); - } +// Matches the `tinyurl.action` endpoint that `/x/` redirects through +// before reaching the real page URL (e.g. `/pages/tinyurl.action`). +static TINYURL_ACTION_RE: Lazy = + Lazy::new(|| Regex::new(r"(?:^|/)tinyurl\.action$").unwrap()); - #[test] - fn base64_encode_confluence_credential_example() { - // Typical Cloud auth: email:api-token - let input = b"user@example.com:my-api-token"; - assert_eq!( - base64_encode(input), - "dXNlckBleGFtcGxlLmNvbTpteS1hcGktdG9rZW4=" - ); - } +const MAX_REDIRECT_HOPS: usize = 5; - #[test] - fn base64_encode_non_ascii_utf8() { - // Non-ASCII UTF-8 bytes - let input = "ü:tökën".as_bytes(); - assert_eq!(base64_encode(input), "w7w6dMO2a8Orbg=="); - } +/// Returns `true` if `url` is a Confluence endpoint that only redirects +/// (`/x/` or `tinyurl.action?urlIdentifier=...`) rather than a page URL. +fn is_confluence_redirect_url(parsed: &Url) -> bool { + SHORT_URL_RE.is_match(parsed.path()) + || (TINYURL_ACTION_RE.is_match(parsed.path()) + && parsed.query_pairs().any(|(k, _)| k == "urlIdentifier")) +} - #[test] - fn base64_encode_all_zero_bytes() { - assert_eq!(base64_encode(&[0, 0, 0]), "AAAA"); - assert_eq!(base64_encode(&[0]), "AA=="); - assert_eq!(base64_encode(&[0, 0]), "AAA="); +/// Resolves `/x/` short links and the `tinyurl.action` hop they +/// redirect through, returning the first URL that isn't itself a redirect. +/// +/// `url` must be HTTPS, checked before any request is sent so the token is +/// never sent in cleartext. Non-redirect URLs (the common case) pass through +/// untouched, with no request made at all. +async fn resolve_confluence_redirects(url: &str, token: &str) -> Result { + let parsed = Url::parse(url).context("invalid URL")?; + if !is_confluence_redirect_url(&parsed) { + return Ok(url.to_owned()); + } + if parsed.scheme() != "https" { + bail!("Confluence short URL must use HTTPS: {url}"); } + follow_redirect_chain(parsed, token).await +} - #[test] - fn base64_encode_all_ff_bytes() { - assert_eq!(base64_encode(&[0xFF, 0xFF, 0xFF]), "////"); +/// Iterates the redirect chain. Split out from [`resolve_confluence_redirects`] +/// so tests can exercise it directly against a plain-HTTP wiremock server; +/// each hop's `Location` is still validated (HTTPS, no userinfo, same +/// origin) before it's trusted, which transitively guarantees every hop +/// after the first is HTTPS. +async fn follow_redirect_chain(url: Url, token: &str) -> Result { + let client = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(SHORT_URL_TIMEOUT) + .user_agent(USER_AGENT) + .build() + .context("build HTTP client for short URL expansion")?; + + let mut current = url; + for _ in 0..MAX_REDIRECT_HOPS { + if !is_confluence_redirect_url(¤t) { + return Ok(current.to_string()); + } + current = fetch_and_validate_redirect(&client, ¤t, token).await?; + } + if !is_confluence_redirect_url(¤t) { + return Ok(current.to_string()); } + bail!("Too many redirects while resolving Confluence short URL: {current}") +} - /// Validates: Requirements 3.1, 3.4, 6.1 - /// Explicitly verifies all padding cases based on input length mod 3: - /// - len % 3 == 0 → no padding - /// - len % 3 == 1 → two `=` padding chars - /// - len % 3 == 2 → one `=` padding char - #[test] - fn base64_encode_padding_cases_by_length_mod_3() { - // len % 3 == 0: no padding (lengths 3, 6) - let no_pad_3 = base64_encode(b"abc"); - assert!( - !no_pad_3.ends_with('='), - "len=3 should have no padding, got: {no_pad_3}" - ); - assert_eq!(no_pad_3, "YWJj"); +/// Sends one request to a redirect-issuing URL and validates its `Location`. +async fn fetch_and_validate_redirect(client: &Client, url: &Url, token: &str) -> Result { + let response = client + .get(url.clone()) + .headers(auth_headers(token)) + .send() + .await + .with_context(|| format!("HTTP request failed: {url}"))?; - let no_pad_6 = base64_encode(b"abcdef"); - assert!( - !no_pad_6.ends_with('='), - "len=6 should have no padding, got: {no_pad_6}" + let status = response.status(); + if !status.is_redirection() { + bail!( + "Confluence short URL did not redirect: {} {} {url}", + status.as_u16(), + status.canonical_reason().unwrap_or(""), ); - assert_eq!(no_pad_6, "YWJjZGVm"); + } - // len % 3 == 1: two padding chars (lengths 1, 4) - let two_pad_1 = base64_encode(b"a"); - assert!( - two_pad_1.ends_with("=="), - "len=1 should have == padding, got: {two_pad_1}" - ); - assert_eq!(two_pad_1, "YQ=="); + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow!("Redirect response missing Location header: {url}"))? + .to_owned(); - let two_pad_4 = base64_encode(b"abcd"); - assert!( - two_pad_4.ends_with("=="), - "len=4 should have == padding, got: {two_pad_4}" - ); - assert_eq!(two_pad_4, "YWJjZA=="); + validate_redirect_target(url, &location) +} - // len % 3 == 2: one padding char (lengths 2, 5) - let one_pad_2 = base64_encode(b"ab"); - assert!( - one_pad_2.ends_with('=') && !one_pad_2.ends_with("=="), - "len=2 should have single = padding, got: {one_pad_2}" - ); - assert_eq!(one_pad_2, "YWI="); +/// Resolves a `Location` against the URL it came from and refuses any target +/// that could leak the token: non-HTTPS, embedded userinfo, or a different origin. +fn validate_redirect_target(source: &Url, location: &str) -> Result { + let target = source + .join(location) + .with_context(|| format!("Invalid Location header: {location}"))?; - let one_pad_5 = base64_encode(b"abcde"); - assert!( - one_pad_5.ends_with('=') && !one_pad_5.ends_with("=="), - "len=5 should have single = padding, got: {one_pad_5}" - ); - assert_eq!(one_pad_5, "YWJjZGU="); + if target.scheme() != "https" { + bail!("Refusing to follow short URL redirect to a non-HTTPS target: {target}"); } + if !target.username().is_empty() || target.password().is_some() { + bail!("Refusing to follow short URL redirect containing userinfo: {target}"); + } + if target.host_str() != source.host_str() + || target.port_or_known_default() != source.port_or_known_default() + { + bail!("Refusing to follow short URL redirect to a different origin: {target}"); + } + Ok(target) +} - // Feature: cloud-api-token-auth, Property 1: base64 encoding correctness - // Validates: Requirements 3.1, 3.4, 6.1 - - /// Programmatic property coverage: verify all single-byte values (0x00..=0xFF) - /// produce output containing only valid base64 characters and the expected length. - #[test] - fn base64_encode_property_all_single_bytes() { - let valid_chars = |s: &str| -> bool { - s.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') - }; +// Helper for callers that need a configured HTTP client. +pub fn build_http_client() -> Result { + Client::builder() + .user_agent(USER_AGENT) + .build() + .context("build HTTP client") +} - for byte in 0x00u8..=0xFF { - let input = [byte]; - let output = base64_encode(&input); - - // Single-byte input → output length must be 4 (4 * ceil(1/3) = 4) - assert_eq!( - output.len(), - 4, - "byte 0x{byte:02X}: expected output length 4, got {}", - output.len() - ); - - // Output must only contain valid base64 characters - assert!( - valid_chars(&output), - "byte 0x{byte:02X}: output contains invalid base64 chars: {output}" - ); - } - } +// Hold a writeable path buffer for the assets dir to keep clippy happy. +fn _unused(_p: PathBuf) {} - /// Programmatic property coverage: verify inputs of length 0..=4 produce correct - /// output length and only valid base64 characters, covering all padding cases. - #[test] - fn base64_encode_property_lengths_0_through_4() { - let valid_chars = |s: &str| -> bool { - s.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') - }; +#[allow(dead_code)] +pub(crate) fn _decode_attribute(s: &str) -> String { + decode_html_attribute(s) +} - // Expected output lengths: 4 * ceil(input_len / 3) - // len 0 → 0, len 1 → 4, len 2 → 4, len 3 → 4, len 4 → 8 - let expected_output_lengths: [usize; 5] = [0, 4, 4, 4, 8]; - - // Use representative byte patterns for each length - let test_patterns: &[&[u8]] = &[ - &[], // length 0 - &[0x00], // length 1 (min byte) - &[0x7F, 0x80], // length 2 (boundary bytes) - &[0xFF, 0x00, 0xAB], // length 3 (mixed) - &[0x01, 0x02, 0x03, 0x04], // length 4 - ]; - - for pattern in test_patterns { - let output = base64_encode(pattern); - let expected_len = expected_output_lengths[pattern.len()]; - - assert_eq!( - output.len(), - expected_len, - "input len {}: expected output length {expected_len}, got {}. input: {pattern:?}", - pattern.len(), - output.len() - ); - - assert!( - valid_chars(&output), - "input len {}: output contains invalid base64 chars: {output}. input: {pattern:?}", - pattern.len() - ); - } +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; - // Additional patterns per length to cover more byte diversity - let additional_patterns: &[&[u8]] = &[ - // More length 1 examples - &[0xFF], - &[0x41], // 'A' - &[0x80], - // More length 2 examples - &[0x00, 0x00], - &[0xFF, 0xFF], - &[0x41, 0x42], - // More length 3 examples - &[0x00, 0x00, 0x00], - &[0xFF, 0xFF, 0xFF], - &[0x41, 0x42, 0x43], - // More length 4 examples - &[0x00, 0x00, 0x00, 0x00], - &[0xFF, 0xFF, 0xFF, 0xFF], - &[0xDE, 0xAD, 0xBE, 0xEF], - ]; - - for pattern in additional_patterns { - let output = base64_encode(pattern); - let expected_len = expected_output_lengths[pattern.len()]; - - assert_eq!( - output.len(), - expected_len, - "input len {}: expected output length {expected_len}, got {}. input: {pattern:?}", - pattern.len(), - output.len() - ); - - assert!( - valid_chars(&output), - "input len {}: output contains invalid base64 chars: {output}. input: {pattern:?}", - pattern.len() - ); - } + #[tokio::test] + async fn resolve_page_id_extracts_pageid_query_param() { + let client = Client::new(); + let url = + "https://confluence.example.com/pages/viewpage.action?pageId=1082335934&spaceKey=DEMO"; + let id = resolve_page_id_from_url(&client, url, "https://confluence.example.com", "token") + .await + .unwrap(); + assert_eq!(id, "1082335934"); } - // Feature: cloud-api-token-auth — auth_headers / binary_auth_headers tests - // Validates: Requirements 3.2, 4.2 - - #[test] - fn auth_headers_bearer_sets_authorization_and_accept() { - let headers = auth_headers("Bearer my-token"); - assert_eq!( - headers.get(AUTHORIZATION).unwrap().to_str().unwrap(), - "Bearer my-token" - ); - assert_eq!( - headers.get(ACCEPT).unwrap().to_str().unwrap(), - "application/json" - ); + #[tokio::test] + async fn resolve_page_id_extracts_pageid_from_spaces_path() { + let client = Client::new(); + let url = "https://confluence.example.com/wiki/spaces/DEMO/pages/9876543/My+Page"; + let id = resolve_page_id_from_url(&client, url, "https://confluence.example.com", "token") + .await + .unwrap(); + assert_eq!(id, "9876543"); } - #[test] - fn auth_headers_basic_sets_authorization_and_accept() { - let headers = auth_headers("Basic dXNlcjpwYXNz"); - assert_eq!( - headers.get(AUTHORIZATION).unwrap().to_str().unwrap(), - "Basic dXNlcjpwYXNz" - ); - assert_eq!( - headers.get(ACCEPT).unwrap().to_str().unwrap(), - "application/json" - ); + #[tokio::test] + async fn resolve_page_id_extracts_pageid_from_spaces_path_no_title() { + let client = Client::new(); + let url = "https://confluence.example.com/wiki/spaces/DEMO/pages/1111111"; + let id = resolve_page_id_from_url(&client, url, "https://confluence.example.com", "token") + .await + .unwrap(); + assert_eq!(id, "1111111"); } - #[test] - fn binary_auth_headers_bearer_sets_authorization_without_accept() { - let headers = binary_auth_headers("Bearer my-token"); - assert_eq!( - headers.get(AUTHORIZATION).unwrap().to_str().unwrap(), - "Bearer my-token" - ); - assert!( - headers.get(ACCEPT).is_none(), - "binary_auth_headers must not set Accept header" - ); + #[tokio::test] + async fn resolve_page_id_priority_pageid_over_space_title() { + let client = Client::new(); + let url = "https://confluence.example.com/pages/viewpage.action?pageId=1082335934&spaceKey=DEMO&title=foo"; + let id = resolve_page_id_from_url(&client, url, "https://confluence.example.com", "token") + .await + .unwrap(); + assert_eq!(id, "1082335934"); } - #[test] - fn binary_auth_headers_basic_sets_authorization_without_accept() { - let headers = binary_auth_headers("Basic dXNlcjpwYXNz"); - assert_eq!( - headers.get(AUTHORIZATION).unwrap().to_str().unwrap(), - "Basic dXNlcjpwYXNz" - ); + #[tokio::test] + async fn resolve_page_id_errors_for_unknown_url() { + let client = Client::new(); + let url = "https://confluence.example.com/unknown/path"; + let err = resolve_page_id_from_url(&client, url, "https://confluence.example.com", "token") + .await + .unwrap_err(); assert!( - headers.get(ACCEPT).is_none(), - "binary_auth_headers must not set Accept header" + err.to_string() + .contains("Cannot determine page ID from URL") ); } - // ── resolve_auth() unit tests ────────────────────────────────────── - // - // Feature: cloud-api-token-auth - // Validates: Requirements 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5, 4.1, 4.3, 7.2, 7.3 - // - // Environment variables are process-global, so these tests must not run - // concurrently. We use a static Mutex to serialize access. - - use std::sync::Mutex; - - static AUTH_ENV_LOCK: Mutex<()> = Mutex::new(()); - - const PAT_VAR: &str = "CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN"; - const USER_VAR: &str = "CONFLUENCE2MD_USERNAME"; - const TOKEN_VAR: &str = "CONFLUENCE2MD_API_TOKEN"; - - /// Helper: clear all auth env vars, returning the lock guard. - /// - /// # Safety - /// Env var manipulation is unsafe in Rust 2024 edition because it is not - /// thread-safe. We serialize access via AUTH_ENV_LOCK so concurrent - /// modification cannot occur within our test suite. - fn clear_auth_env() -> std::sync::MutexGuard<'static, ()> { - let guard = AUTH_ENV_LOCK.lock().unwrap(); - // SAFETY: guarded by AUTH_ENV_LOCK — no concurrent env mutation. - unsafe { - std::env::remove_var(PAT_VAR); - std::env::remove_var(USER_VAR); - std::env::remove_var(TOKEN_VAR); - } - guard - } - - #[test] - fn resolve_auth_pat_set_alone_returns_bearer() { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { std::env::set_var(PAT_VAR, "my-pat-value") }; - - let result = resolve_auth().unwrap(); - assert_eq!(result, "Bearer my-pat-value"); + #[tokio::test] + async fn resolve_page_id_rejects_non_https_short_url() { + let client = Client::new(); + let url = "http://confluence.example.com/x/AbCdEf"; + let err = resolve_page_id_from_url(&client, url, "http://confluence.example.com", "token") + .await + .unwrap_err(); + assert!(err.to_string().contains("must use HTTPS")); } - #[test] - fn resolve_auth_cloud_vars_set_no_pat_returns_basic() { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { - std::env::set_var(USER_VAR, "user@example.com"); - std::env::set_var(TOKEN_VAR, "my-api-token"); - } + #[tokio::test] + async fn resolve_page_id_looks_up_via_api_for_space_and_title() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/api/content")) + .and(query_param("spaceKey", "DEMO")) + .and(query_param("type", "page")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"results":[{"id":"555444"}]}"#), + ) + .mount(&server) + .await; - let result = resolve_auth().unwrap(); - // base64("user@example.com:my-api-token") = "dXNlckBleGFtcGxlLmNvbTpteS1hcGktdG9rZW4=" - assert_eq!(result, "Basic dXNlckBleGFtcGxlLmNvbTpteS1hcGktdG9rZW4="); + let client = Client::new(); + let url = format!( + "{}/pages/viewpage.action?spaceKey=DEMO&title=My+Page", + server.uri() + ); + let id = resolve_page_id_from_url(&client, &url, &server.uri(), "token") + .await + .unwrap(); + assert_eq!(id, "555444"); } - #[test] - fn resolve_auth_both_cloud_and_pat_set_returns_bearer_priority() { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { - std::env::set_var(PAT_VAR, "my-pat"); - std::env::set_var(USER_VAR, "user@example.com"); - std::env::set_var(TOKEN_VAR, "my-api-token"); - } + #[tokio::test] + async fn resolve_page_id_looks_up_via_api_for_classic_display_url() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/api/content")) + .and(query_param("spaceKey", "DEMO")) + .and(query_param("title", "My Page Title")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"results":[{"id":"333222"}]}"#), + ) + .mount(&server) + .await; - let result = resolve_auth().unwrap(); - assert_eq!(result, "Bearer my-pat"); + let client = Client::new(); + let url = format!("{}/display/DEMO/My+Page+Title", server.uri()); + let id = resolve_page_id_from_url(&client, &url, &server.uri(), "token") + .await + .unwrap(); + assert_eq!(id, "333222"); } - #[test] - fn resolve_auth_no_vars_set_returns_error_listing_both_options() { - let _guard = clear_auth_env(); + #[tokio::test] + async fn resolve_page_id_handles_percent_encoded_title() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/rest/api/content")) + .and(query_param("spaceKey", "SAMPLE")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"results":[{"id":"777888"}]}"#), + ) + .mount(&server) + .await; - let err = resolve_auth().unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN"), - "error should mention PAT var: {msg}" - ); - assert!( - msg.contains("CONFLUENCE2MD_USERNAME") || msg.contains("CONFLUENCE2MD_API_TOKEN"), - "error should mention Cloud vars: {msg}" + let client = Client::new(); + let url = format!( + "{}/pages/viewpage.action?spaceKey=SAMPLE&title=SampleManager_%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0%E8%A8%AD%E8%A8%88%E6%9B%B8_V1.00", + server.uri() ); + let id = resolve_page_id_from_url(&client, &url, &server.uri(), "token") + .await + .unwrap(); + assert_eq!(id, "777888"); } - #[test] - fn resolve_auth_only_username_set_errors_naming_missing_api_token() { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { std::env::set_var(USER_VAR, "user@example.com") }; + #[tokio::test] + async fn rewrite_html_rewrites_image_src_when_url_contains_apostrophe() { + let server = MockServer::start().await; + // Confluence embeds the raw page title (with apostrophe) in the URL. + let img_path = "/download/attachments/123/My%20team's%20page/image.png"; + Mock::given(method("GET")) + .and(path(img_path)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"\x89PNG\r\n\x1a\n")) + .mount(&server) + .await; - let err = resolve_auth().unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("CONFLUENCE2MD_API_TOKEN"), - "error should name missing API token var: {msg}" - ); - } + let html = format!(r#""#, server.uri(), img_path); - #[test] - fn resolve_auth_only_api_token_set_errors_naming_missing_username() { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { std::env::set_var(TOKEN_VAR, "my-api-token") }; + let tmp_dir = std::env::temp_dir().join(format!( + "confluence2md_test_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + )); + let assets_dir = tmp_dir.join("assets"); + let client = Client::new(); + let mut used = HashSet::new(); + let result = download_images_and_rewrite_html( + &client, + &html, + DownloadImagesOptions { + base_url: &server.uri(), + personal_access_token: "token", + assets_abs_dir: &assets_dir, + markdown_image_prefix: "assets", + used_names: &mut used, + }, + ) + .await + .unwrap(); - let err = resolve_auth().unwrap_err(); - let msg = err.to_string(); assert!( - msg.contains("CONFLUENCE2MD_USERNAME"), - "error should name missing username var: {msg}" + !result.contains(&server.uri()), + "src should be rewritten to local path, got: {result}" ); - } - - #[test] - fn resolve_auth_pat_whitespace_only_returns_error() { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { std::env::set_var(PAT_VAR, " \t ") }; - - let err = resolve_auth().unwrap_err(); - let msg = err.to_string(); assert!( - msg.contains("whitespace") || msg.contains("empty"), - "error should mention whitespace/empty PAT: {msg}" + result.contains("assets"), + "src should point into assets dir, got: {result}" ); } - #[test] - fn resolve_auth_username_whitespace_only_with_valid_api_token_returns_error() { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { - std::env::set_var(USER_VAR, " "); - std::env::set_var(TOKEN_VAR, "valid-token"); - } + #[tokio::test] + async fn fetch_confluence_page_preserves_content_json_response() { + let server = MockServer::start().await; + let body = r#"{"title":"Saved Page","body":{"storage":{"value":"

storage

"},"export_view":{"value":"

export

"}},"_links":{"webui":"/pages/123"}}"#; + Mock::given(method("GET")) + .and(path("/rest/api/content/123")) + .and(query_param("expand", "body.storage,body.export_view")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; - let err = resolve_auth().unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("USERNAME") || msg.contains("username"), - "error should mention username issue: {msg}" - ); + let client = Client::new(); + let page = fetch_confluence_page(&client, "123", &server.uri(), "token") + .await + .unwrap(); + + assert_eq!(page.title, "Saved Page"); + assert_eq!(page.content_json, body); + assert_eq!(page.export_html, "

export

"); + assert_eq!(page.storage_html.as_deref(), Some("

storage

")); } + // Regression test for: is_local_markdown_asset fails when the markdown_image_prefix + // contains an apostrophe because the old code used PATH_SEGMENT encoding (which + // encodes `'` → `%27`) while to_markdown_asset_path uses URI_COMPONENT encoding + // (which keeps `'` as a literal). The mismatch caused local draw.io / image assets + // to be treated as remote URLs and re-downloaded. #[test] - fn resolve_auth_api_token_whitespace_only_with_valid_username_returns_error() { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { - std::env::set_var(USER_VAR, "user@example.com"); - std::env::set_var(TOKEN_VAR, " \t"); - } - - let err = resolve_auth().unwrap_err(); - let msg = err.to_string(); + fn is_local_markdown_asset_recognizes_apostrophe_in_prefix() { + let prefix = "confluence2md's_test_assets"; + let src = to_markdown_asset_path(prefix, "single.drawio.png"); + // With the old PATH_SEGMENT encoding, encoded_prefix would contain %27 instead + // of the literal apostrophe used by to_markdown_asset_path, so starts_with + // would return false and this assertion would fail. assert!( - msg.contains("API_TOKEN") || msg.contains("api_token") || msg.contains("API token"), - "error should mention API token issue: {msg}" + is_local_markdown_asset(&src, prefix), + "local asset not recognized (apostrophe in prefix): {src}" ); } - // Feature: cloud-api-token-auth, Property 2: PAT priority and Bearer header format - // **Validates: Requirements 2.1, 2.4, 4.1, 4.2** #[test] - fn resolve_auth_pat_priority_and_bearer_format_programmatic() { - let long_pat = "a".repeat(100); - let pat_values_owned: Vec<&str> = vec![ - "x", - &long_pat, - "abc!@#$%^&*()_+-=[]{}", - "tökën-pät", - "token with spaces", - ]; - - for pat_val in &pat_values_owned { - // Case A: PAT set alone (no cloud vars) - { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { - std::env::set_var(PAT_VAR, pat_val); - } - - let result = resolve_auth(); - assert!( - result.is_ok(), - "PAT alone should succeed for pat={pat_val:?}, got err: {:?}", - result.unwrap_err() - ); - let header = result.unwrap(); - assert!( - header.starts_with("Bearer "), - "Header must start with 'Bearer ' for pat={pat_val:?}, got: {header:?}" - ); - let remainder = &header["Bearer ".len()..]; - assert_eq!( - remainder, *pat_val, - "Remainder after 'Bearer ' must equal exact PAT value for pat={pat_val:?}" - ); - } - - // Case B: PAT set WITH cloud vars also set (PAT takes priority) - { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { - std::env::set_var(PAT_VAR, pat_val); - std::env::set_var(USER_VAR, "user@example.com"); - std::env::set_var(TOKEN_VAR, "token123"); - } - - let result = resolve_auth(); - assert!( - result.is_ok(), - "PAT with cloud vars should succeed for pat={pat_val:?}, got err: {:?}", - result.unwrap_err() - ); - let header = result.unwrap(); - assert!( - header.starts_with("Bearer "), - "Header must start with 'Bearer ' when PAT has priority for pat={pat_val:?}, got: {header:?}" - ); - let remainder = &header["Bearer ".len()..]; - assert_eq!( - remainder, *pat_val, - "Remainder after 'Bearer ' must equal exact PAT value (priority over cloud) for pat={pat_val:?}" - ); - } - } + fn is_local_markdown_asset_recognizes_plain_prefix() { + let prefix = "my_page_assets"; + let src = to_markdown_asset_path(prefix, "image.png"); + assert!(is_local_markdown_asset(&src, prefix)); } - // Feature: cloud-api-token-auth, Property 3: Cloud Basic auth header format - // **Validates: Requirements 1.5, 2.2, 3.1, 3.4** - // - // For any pair of non-empty, non-whitespace-only values for username and - // api_token, when PAT is not set, resolve_auth() returns "Basic " - // where == base64_encode("{username}:{api_token}".as_bytes()). - // - // This test loops over ASCII and non-ASCII username/token pairs, including - // colons in the token (edge case for the separator), and verifies: - // - Result is Ok - // - Result starts with "Basic " - // - The base64 portion only contains valid base64 chars [A-Za-z0-9+/=] - // - The base64 portion matches base64_encode("{username}:{api_token}".as_bytes()) #[test] - fn resolve_auth_basic_header_format_property_coverage() { - let long_user = "a".repeat(50) + "@example.com"; - let long_token = "b".repeat(100); - - let pairs: Vec<(&str, &str)> = vec![ - // Normal ASCII - ("user@example.com", "simple-token"), - // Unicode username - ("üser@example.com", "token123"), - // Unicode token - ("user@example.com", "tökën-123"), - // Both unicode - ("ü@ëx.com", "tök:ën"), - // Colon in token (edge case for the separator) - ("user@example.com", "token:with:colons"), - // Special chars - ("user+tag@example.com", "abc!@#$%^&*()"), - ]; - - // Also include long values (owned strings) - let owned_pairs: Vec<(String, String)> = vec![(long_user.clone(), long_token.clone())]; - - // Combine into a single iteration - let all_pairs: Vec<(&str, &str)> = pairs - .iter() - .copied() - .chain(owned_pairs.iter().map(|(u, t)| (u.as_str(), t.as_str()))) - .collect(); - - for (username, api_token) in &all_pairs { - let _guard = clear_auth_env(); - // SAFETY: guarded by AUTH_ENV_LOCK. - unsafe { - std::env::set_var(USER_VAR, username); - std::env::set_var(TOKEN_VAR, api_token); - } - - let result = resolve_auth(); - assert!( - result.is_ok(), - "resolve_auth() should succeed for ({username:?}, {api_token:?}), got: {:?}", - result.err() - ); - - let header_value = result.unwrap(); - - // Must start with "Basic " - assert!( - header_value.starts_with("Basic "), - "Header for ({username:?}, {api_token:?}) should start with 'Basic ', got: {header_value}" - ); - - // Extract the base64 portion - let b64_portion = &header_value["Basic ".len()..]; - - // Verify base64 alphabet: only [A-Za-z0-9+/=] - assert!( - b64_portion - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='), - "Base64 portion for ({username:?}, {api_token:?}) contains invalid chars: {b64_portion}" - ); - - // Verify correctness: re-encode expected input and compare - let expected_input = format!("{username}:{api_token}"); - let expected_b64 = base64_encode(expected_input.as_bytes()); - assert_eq!( - b64_portion, expected_b64, - "Base64 mismatch for ({username:?}, {api_token:?}): got {b64_portion}, expected {expected_b64}" - ); - } + fn is_local_markdown_asset_rejects_remote_url() { + assert!(!is_local_markdown_asset( + "https://example.com/image.png", + "my_page_assets" + )); } // ── Short URL expansion ──────────────────────────────────────── diff --git a/src/lib.rs b/src/lib.rs index a772c84..3be51ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ //! while following Rust conventions (snake_case, idiomatic `Result` returns, //! types co-located with the owning module). +pub mod auth; pub mod confluence; pub mod drawio; pub mod html;