diff --git a/src/confluence.rs b/src/confluence.rs index e0b100e..a44eae2 100644 --- a/src/confluence.rs +++ b/src/confluence.rs @@ -15,8 +15,8 @@ use tracing::{debug, warn}; use url::Url; use crate::utils::{ - HeaderHints, decode_html_attribute, ensure_dir, get_file_name_from_url_or_headers, resolve_url, - to_markdown_asset_path, + HeaderHints, URI_COMPONENT, decode_html_attribute, ensure_dir, + get_file_name_from_url_or_headers, resolve_url, to_markdown_asset_path, }; // ── Public types ─────────────────────────────────────────────────── @@ -444,18 +444,19 @@ pub async fn download_images_and_rewrite_html( } static REPLACE_RE: Lazy = - Lazy::new(|| Regex::new(r#"(?is)(]*\bsrc=)(["'])(.*?)(["'])"#).unwrap()); + Lazy::new(|| Regex::new(r#"(?is)(]*\bsrc=)(?:"([^"]*)"|'([^']*)')"#).unwrap()); let result = REPLACE_RE.replace_all(html, |caps: ®ex::Captures<'_>| { let prefix = &caps[1]; - let quote_open = &caps[2]; - let src = &caps[3]; - let quote_close = &caps[4]; - if quote_open != quote_close { + let (quote, src) = if let Some(m) = caps.get(2) { + ('"', m.as_str()) + } else if let Some(m) = caps.get(3) { + ('\'', m.as_str()) + } else { return caps[0].to_owned(); - } + }; match src_to_local.get(src) { - Some(local) => format!("{prefix}{quote_open}{local}{quote_close}"), + Some(local) => format!("{prefix}{quote}{local}{quote}"), None => caps[0].to_owned(), } }); @@ -464,7 +465,7 @@ pub async fn download_images_and_rewrite_html( } fn is_local_markdown_asset(src: &str, markdown_image_prefix: &str) -> bool { - let encoded_prefix = utf8_percent_encode(markdown_image_prefix, PATH_SEGMENT).to_string(); + let encoded_prefix = utf8_percent_encode(markdown_image_prefix, &URI_COMPONENT).to_string(); src.starts_with(&format!("{markdown_image_prefix}/")) || src.starts_with(&format!("{encoded_prefix}%2F")) } @@ -708,6 +709,53 @@ mod tests { assert_eq!(id, "777888"); } + #[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 html = format!(r#""#, server.uri(), img_path); + + 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(); + + assert!( + !result.contains(&server.uri()), + "src should be rewritten to local path, got: {result}" + ); + assert!( + result.contains("assets"), + "src should point into assets dir, got: {result}" + ); + } + #[tokio::test] async fn fetch_confluence_page_preserves_content_json_response() { let server = MockServer::start().await; @@ -729,4 +777,37 @@ mod tests { 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 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!( + is_local_markdown_asset(&src, prefix), + "local asset not recognized (apostrophe in prefix): {src}" + ); + } + + #[test] + 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)); + } + + #[test] + fn is_local_markdown_asset_rejects_remote_url() { + assert!(!is_local_markdown_asset( + "https://example.com/image.png", + "my_page_assets" + )); + } } diff --git a/src/utils.rs b/src/utils.rs index 4b307c9..7da377a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -313,7 +313,7 @@ pub fn make_assets_info(page_id: &str, title: &str, output_path: &Path) -> Asset // `encodeURIComponent` percent-encodes everything that is not in the unreserved // set `A-Z a-z 0-9 - _ . ! ~ * ' ( )`. Construct the inverse `AsciiSet`. -const URI_COMPONENT: AsciiSet = NON_ALPHANUMERIC +pub const URI_COMPONENT: AsciiSet = NON_ALPHANUMERIC .remove(b'-') .remove(b'_') .remove(b'.')