| 1 | use super::*; |
| 2 | use crate::tools::spec::ToolContext; |
| 3 | use std::path::PathBuf; |
| 4 | |
| 5 | struct ArtifactRootRestore(Option<PathBuf>); |
| 6 | |
| 7 | impl Drop for ArtifactRootRestore { |
| 8 | fn drop(&mut self) { |
| 9 | crate::artifacts::set_test_artifact_sessions_root(self.0.take()); |
| 10 | } |
| 11 | } |
| 12 | |
| 13 | fn runtime() -> tokio::runtime::Runtime { |
| 14 | tokio::runtime::Builder::new_current_thread() |
| 15 | .enable_all() |
| 16 | .build() |
| 17 | .expect("test runtime") |
| 18 | } |
| 19 | |
| 20 | #[test] |
| 21 | fn raw_pdf_production_path_preserves_exact_bytes_without_extractor() { |
| 22 | let _lock = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD |
| 23 | .lock() |
| 24 | .unwrap_or_else(|error| error.into_inner()); |
| 25 | let temporary = tempfile::tempdir().expect("artifact root"); |
| 26 | let prior = |
| 27 | crate::artifacts::set_test_artifact_sessions_root(Some(temporary.path().join("sessions"))); |
| 28 | let _restore = ArtifactRootRestore(prior); |
| 29 | let bytes = b"%PDF-1.7\nraw fixture that is intentionally not parseable\n%%EOF"; |
| 30 | let missing = temporary.path().join("definitely-not-pdftotext"); |
| 31 | let document = runtime() |
| 32 | .block_on(extract_fetched_document( |
| 33 | Format::Raw, |
| 34 | "https://example.com/raw.pdf", |
| 35 | "application/pdf", |
| 36 | bytes, |
| 37 | true, |
| 38 | None, |
| 39 | PdfTextCommand::test(missing.as_os_str(), Duration::from_millis(50), None), |
| 40 | )) |
| 41 | .expect("signed raw PDF must bypass the missing extractor"); |
| 42 | let (content, artifact) = render_extracted( |
| 43 | "https://example.com/raw.pdf", |
| 44 | "application/pdf", |
| 45 | Format::Raw, |
| 46 | document, |
| 47 | bytes, |
| 48 | &ToolContext::new("."), |
| 49 | ) |
| 50 | .expect("raw PDF preservation must not require pdftotext"); |
| 51 | let artifact = artifact.expect("raw PDF artifact"); |
| 52 | assert!(content.contains("PDF response saved"), "{content}"); |
| 53 | assert_eq!(std::fs::read(artifact.absolute_path).unwrap(), bytes); |
| 54 | } |
| 55 | |
| 56 | #[test] |
| 57 | fn raw_pdf_spoofs_and_contradictory_media_mime_create_no_artifact() { |
| 58 | let _lock = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD |
| 59 | .lock() |
| 60 | .unwrap_or_else(|error| error.into_inner()); |
| 61 | let temporary = tempfile::tempdir().expect("artifact root"); |
| 62 | let sessions = temporary.path().join("sessions"); |
| 63 | let prior = crate::artifacts::set_test_artifact_sessions_root(Some(sessions.clone())); |
| 64 | let _restore = ArtifactRootRestore(prior); |
| 65 | let missing = temporary.path().join("definitely-not-pdftotext"); |
| 66 | let request = PdfTextCommand::test(missing.as_os_str(), Duration::from_millis(50), None); |
| 67 | let runtime = runtime(); |
| 68 | |
| 69 | for (url, content_type, bytes, expected) in [ |
| 70 | ( |
| 71 | "https://example.com/download", |
| 72 | "application/pdf", |
| 73 | b"plain text pretending to be a PDF".as_slice(), |
| 74 | "PDF signature", |
| 75 | ), |
| 76 | ( |
| 77 | "https://example.com/spoof.pdf", |
| 78 | "text/plain", |
| 79 | b"plain text pretending to be a PDF".as_slice(), |
| 80 | "PDF signature", |
| 81 | ), |
| 82 | ( |
| 83 | "https://example.com/signed", |
| 84 | "image/png", |
| 85 | b"%PDF-1.7\n%%EOF".as_slice(), |
| 86 | "did not match its PDF bytes", |
| 87 | ), |
| 88 | ] { |
| 89 | let error = runtime |
| 90 | .block_on(extract_fetched_document( |
| 91 | Format::Raw, |
| 92 | url, |
| 93 | content_type, |
| 94 | bytes, |
| 95 | true, |
| 96 | None, |
| 97 | request, |
| 98 | )) |
| 99 | .expect_err("invalid PDF response must fail before raw preservation"); |
| 100 | assert!(error.to_string().contains(expected), "{error}"); |
| 101 | } |
| 102 | assert!( |
| 103 | !sessions.exists(), |
| 104 | "rejected bytes must not create artifacts" |
| 105 | ); |
| 106 | } |
| 107 | |
| 108 | #[tokio::test] |
| 109 | async fn fetched_pdf_missing_helper_is_a_failed_typed_outcome() { |
| 110 | let temporary = tempfile::tempdir().expect("tempdir"); |
| 111 | let missing = temporary.path().join("definitely-not-pdftotext"); |
| 112 | let error = extract_fetched_document( |
| 113 | Format::Text, |
| 114 | "https://example.com/document.pdf", |
| 115 | "application/pdf", |
| 116 | b"%PDF-1.7\n%%EOF", |
| 117 | true, |
| 118 | None, |
| 119 | PdfTextCommand::test(missing.as_os_str(), Duration::from_secs(1), None), |
| 120 | ) |
| 121 | .await |
| 122 | .expect_err("missing helper must fail the fetched PDF call"); |
| 123 | let payload = match &error.error { |
| 124 | ToolError::NotAvailable { message } => { |
| 125 | serde_json::from_str::<Value>(message).expect("structured unavailable payload") |
| 126 | } |
| 127 | other => panic!("unexpected error: {other:?}"), |
| 128 | }; |
| 129 | assert_eq!(payload["type"], "binary_unavailable"); |
| 130 | assert_eq!( |
| 131 | crate::tools::spec::ToolExecutionOutcome::from_legacy(Err(error.into())).status, |
| 132 | crate::tools::spec::ToolTerminalStatus::Failed |
| 133 | ); |
| 134 | } |
| 135 | |
| 136 | #[cfg(unix)] |
| 137 | #[tokio::test(flavor = "current_thread")] |
| 138 | async fn fetched_pdf_host_text_matches_default_and_raw_bypasses_host() { |
| 139 | use crate::extension_host::{ExtensionHostManager, ExtensionHostOptions, TestManagerGuard}; |
| 140 | use crate::features::{Feature, Features}; |
| 141 | use std::os::unix::fs::PermissionsExt; |
| 142 | use std::sync::Arc; |
| 143 | let _home = crate::test_support::SealedHome::new(); |
| 144 | let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(false); |
| 145 | let Some(node) = crate::extension_host::tests::node_for_tests("fetched_pdf_host") else { |
| 146 | return; |
| 147 | }; |
| 148 | let root = tempfile::tempdir().unwrap(); |
| 149 | let binary = root.path().join("fake-pdftotext"); |
| 150 | std::fs::write(&binary, "#!/bin/sh\nprintf 'page one\\fpage two\\n'\n").unwrap(); |
| 151 | std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700)).unwrap(); |
| 152 | let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions { |
| 153 | runtime: crate::config::ExtensionHostRuntime::Node, |
| 154 | node_override: Some(node), |
| 155 | root: Some(root.path().join("host")), |
| 156 | ..ExtensionHostOptions::default() |
| 157 | })); |
| 158 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 159 | let rust = ToolContext::new(root.path()); |
| 160 | let mut flags = Features::with_defaults(); |
| 161 | flags.enable(Feature::PdfHost); |
| 162 | let host = ToolContext::new(root.path()).with_features(flags); |
| 163 | let bytes = b"%PDF-1.7\nfixture\n%%EOF"; |
| 164 | for format in [Format::Text, Format::Markdown] { |
| 165 | let expected = extract_fetched_document( |
| 166 | format, |
| 167 | "https://example.com/fixture.pdf", |
| 168 | "application/pdf", |
| 169 | bytes, |
| 170 | true, |
| 171 | None, |
| 172 | PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(10), None) |
| 173 | .with_context(&rust), |
| 174 | ) |
| 175 | .await |
| 176 | .unwrap(); |
| 177 | let actual = extract_fetched_document( |
| 178 | format, |
| 179 | "https://example.com/fixture.pdf", |
| 180 | "application/pdf", |
| 181 | bytes, |
| 182 | true, |
| 183 | None, |
| 184 | PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(10), None) |
| 185 | .with_context(&host), |
| 186 | ) |
| 187 | .await |
| 188 | .unwrap(); |
| 189 | assert_eq!(actual.text, expected.text); |
| 190 | assert_eq!(actual.markdown, expected.markdown); |
| 191 | assert_eq!(actual.pdf_pages, expected.pdf_pages); |
| 192 | } |
| 193 | manager.shutdown().await; |
| 194 | drop(_manager); |
| 195 | // A signed raw response deliberately has no extraction consumer, even with |
| 196 | // the Host flag selected and an unusable runtime/parser. |
| 197 | let unavailable = Arc::new(ExtensionHostManager::new(ExtensionHostOptions { |
| 198 | runtime: crate::config::ExtensionHostRuntime::Node, |
| 199 | node_override: Some(root.path().join("missing-node")), |
| 200 | root: Some(root.path().join("absent-host")), |
| 201 | ..ExtensionHostOptions::default() |
| 202 | })); |
| 203 | let _manager = TestManagerGuard::install(unavailable); |
| 204 | let missing = root.path().join("missing-parser"); |
| 205 | let document = extract_fetched_document( |
| 206 | Format::Raw, |
| 207 | "https://example.com/fixture.pdf", |
| 208 | "application/pdf", |
| 209 | bytes, |
| 210 | true, |
| 211 | None, |
| 212 | PdfTextCommand::test(missing.as_os_str(), Duration::from_secs(1), None).with_context(&host), |
| 213 | ) |
| 214 | .await |
| 215 | .unwrap(); |
| 216 | assert_eq!(document.kind, DocumentKind::Pdf); |
| 217 | assert!(document.text.is_empty()); |
| 218 | assert!(document.pdf_pages.is_none()); |
| 219 | } |
| 220 | |
| 221 | #[tokio::test(flavor = "current_thread")] |
| 222 | async fn raw_and_non_success_catches_do_not_hide_a_selected_host_failure() { |
| 223 | let _home = crate::test_support::SealedHome::new(); |
| 224 | let root = tempfile::tempdir().unwrap(); |
| 225 | let manager = std::sync::Arc::new(crate::extension_host::ExtensionHostManager::new( |
| 226 | crate::extension_host::ExtensionHostOptions { |
| 227 | runtime: crate::config::ExtensionHostRuntime::Node, |
| 228 | node_override: Some(root.path().join("missing-node")), |
| 229 | root: Some(root.path().join("host")), |
| 230 | ..crate::extension_host::ExtensionHostOptions::default() |
| 231 | }, |
| 232 | )); |
| 233 | let _manager = crate::extension_host::TestManagerGuard::install(manager); |
| 234 | let mut flags = crate::features::Features::with_defaults(); |
| 235 | flags.enable(crate::features::Feature::WebExtractHost); |
| 236 | let context = ToolContext::new(root.path()).with_features(flags); |
| 237 | for (format, success) in [(Format::Raw, true), (Format::Text, false)] { |
| 238 | let error=extract_fetched_document(format,"https://example.com/private", "text/html",b"<body><main>Five meaningful words survive this valid complete document content.</main></body>",success,None,PdfTextCommand::system(Some(&context))).await.unwrap_err(); |
| 239 | assert!( |
| 240 | !error.content(), |
| 241 | "operational Host failure cannot become raw decoded text" |
| 242 | ); |
| 243 | } |
| 244 | } |
| 245 |