diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 37b993e..d5ce80f 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -8910e934da93be0fe6c4d1eec566ec74f79c3297 +691ff1006644cfa7cd7c584700d9d14592703761 diff --git a/Cargo.lock b/Cargo.lock index 734e47c..c03fab2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2896,6 +2896,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.7.3" @@ -3148,6 +3157,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -3457,6 +3475,7 @@ dependencies = [ "tower", "tower-http 0.5.2", "tracing", + "tracing-subscriber", "ureq", "url", "uuid", @@ -4727,6 +4746,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5053,6 +5081,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.9.1" @@ -5348,6 +5385,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -5574,6 +5641,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "value-bag" version = "1.12.0" diff --git a/build/unmapped-glyph-names.json b/build/unmapped-glyph-names.json new file mode 100644 index 0000000..0834ce1 --- /dev/null +++ b/build/unmapped-glyph-names.json @@ -0,0 +1,18 @@ +{ + "unmapped_glyph_names": [ + ".notdef", + ".null", + "g000", + "g001", + "g002", + "g003", + "g004", + "g005", + "g006", + "g007", + "g008", + "g009" + ], + "description": "Glyph names that should be skipped during CMAP and ToUnicode entry creation. These glyphs have no valid Unicode mapping and should not appear in text extraction output. The .notdef glyph is the standard PDF fallback glyph representing 'no glyph available'. Additional PUA (Private Use Area) glyphs like g000-g009 are commonly used in embedded fonts with custom encodings.", + "version": "1.0" +} diff --git a/crates/pdftract-cli/Cargo.toml b/crates/pdftract-cli/Cargo.toml index d39096d..bc616d6 100644 --- a/crates/pdftract-cli/Cargo.toml +++ b/crates/pdftract-cli/Cargo.toml @@ -34,6 +34,10 @@ path = "../../tools/debug-fingerprint/main.rs" name = "gen-cli-reference" path = "src/bin/generate-cli-reference.rs" +[[bin]] +name = "generate-form-fixtures" +path = "../../tests/fixtures/forms/generate_form_fixtures.rs" + # Removed: generate_fixtures, generate_expected_json (files do not exist) [[bench]] @@ -71,6 +75,7 @@ num_cpus = "1" rayon = "1" libloading = { version = "0.8", optional = true } lzw = { workspace = true } +lopdf = "0.34" multer = "3" pdftract-core = { path = "../pdftract-core" } regex = "1.10" @@ -95,6 +100,7 @@ uuid = { version = "1.0", features = ["v4", "serde"] } walkdir = "2" chromiumoxide = { version = "0.6", optional = true } jsonschema = "0.18" +tracing-subscriber = { version = "0.3.23", features = ["fmt", "env-filter"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/pdftract-cli/benches/grep_1000.rs b/crates/pdftract-cli/benches/grep_1000.rs index 7dcc8ca..47669fc 100644 --- a/crates/pdftract-cli/benches/grep_1000.rs +++ b/crates/pdftract-cli/benches/grep_1000.rs @@ -29,7 +29,7 @@ use std::path::PathBuf; use std::time::Instant; -/// Get the corpus directory path +/// Get the corpus directory path (parent of corpus/ subdirectory) /// /// Tries multiple strategies to find the corpus: /// 1. Environment variable PDFTRACT_CORPUS_DIR @@ -72,6 +72,150 @@ fn get_corpus_dir() -> PathBuf { PathBuf::from("tests/fixtures/grep-corpus") } +/// Get the corpus files subdirectory path +/// +/// Returns the path to the corpus/ subdirectory containing actual PDF files. +fn get_corpus_files_dir() -> PathBuf { + get_corpus_dir().join("corpus") +} + +/// Validate the corpus directory structure and contents +/// +/// Checks that: +/// - The corpus directory exists +/// - The corpus/ subdirectory exists +/// - Contains at least one PDF file +/// - All PDF files are readable and accessible +/// +/// Returns Ok(()) if validation passes, Err with clear message if it fails. +fn validate_corpus() -> Result { + use std::fs; + + let corpus_dir = get_corpus_dir(); + let corpus_files_dir = get_corpus_files_dir(); + + // Check parent directory exists + if !corpus_dir.exists() { + return Err(format!( + "Corpus directory not found: {:?}. \ + Run tests/fixtures/grep-corpus/regenerate.sh or set PDFTRACT_CORPUS_DIR", + corpus_dir + )); + } + + // Check corpus/ subdirectory exists + if !corpus_files_dir.exists() { + return Err(format!( + "Corpus files subdirectory not found: {:?}. \ + Expected structure: tests/fixtures/grep-corpus/corpus/*.pdf", + corpus_files_dir + )); + } + + // Check it's a directory + if !corpus_files_dir.is_dir() { + return Err(format!( + "Corpus path is not a directory: {:?}", + corpus_files_dir + )); + } + + // Count PDF files and validate readability + let entries = match fs::read_dir(&corpus_files_dir) { + Ok(entries) => entries, + Err(e) => { + return Err(format!( + "Cannot read corpus directory {:?}: {}", + corpus_files_dir, e + )); + } + }; + + let mut pdf_count = 0; + let mut unreadable_files = Vec::new(); + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + return Err(format!( + "Error reading directory entry in {:?}: {}", + corpus_files_dir, e + )); + } + }; + + let path = entry.path(); + + // Skip hidden files and .gitkeep + if path.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.')) + .unwrap_or(false) + { + continue; + } + + // Check if it's a PDF file + if path.extension().and_then(|e| e.to_str()) != Some("pdf") { + continue; + } + + // Check file metadata to verify readability + match entry.metadata() { + Ok(metadata) => { + if !metadata.is_file() { + continue; + } + + // Verify file is readable by attempting to get its len + // This will fail if permissions are wrong + if metadata.len() == 0 { + unreadable_files.push(format!("{} (empty)", path.display())); + continue; + } + + pdf_count += 1; + } + Err(e) => { + unreadable_files.push(format!("{} (metadata error: {})", path.display(), e)); + } + } + } + + // Check if we found any PDFs + if pdf_count == 0 { + return Err(format!( + "No PDF files found in corpus directory: {:?}. \ + Run tests/fixtures/grep-corpus/regenerate.sh to populate the corpus.\ + {}", + corpus_files_dir, + if unreadable_files.is_empty() { + String::new() + } else { + format!("\nUnreadable files: {}", unreadable_files.join(", ")) + } + )); + } + + // Warn about unreadable files but don't fail if we have at least some good PDFs + if !unreadable_files.is_empty() { + eprintln!( + "WARNING: Found {} unreadable files (skipped): {}", + unreadable_files.len(), + unreadable_files.join(", ") + ); + } + + eprintln!( + "Corpus validation passed: {} PDF files in {:?}", + pdf_count, + corpus_files_dir + ); + + Ok(pdf_count) +} + /// Search pattern for benchmark: "the" /// /// Chosen as a high-frequency word that appears in most English documents. @@ -192,7 +336,7 @@ fn get_commit_sha() -> String { /// Get corpus size in bytes fn get_corpus_size() -> u64 { use std::fs; - let path = get_corpus_dir(); + let path = get_corpus_files_dir(); if !path.exists() { return 0; } @@ -213,7 +357,7 @@ fn get_corpus_size() -> u64 { /// Count PDF files in corpus fn count_corpus_files() -> usize { use std::fs; - let path = get_corpus_dir(); + let path = get_corpus_files_dir(); if !path.exists() { return 0; } @@ -238,18 +382,14 @@ fn count_corpus_files() -> usize { /// /// TODO: Wire up to actual grep implementation once 7.8.x is complete. fn run_benchmark() -> Result { - // Check corpus exists - let corpus_path = get_corpus_dir(); - if !corpus_path.exists() { - return Err(format!( - "Corpus directory not found: {:?}. Run tests/fixtures/grep-corpus/regenerate.sh", - corpus_path - )); - } + // Validate corpus exists and contains readable files + // This checks directory structure, file count, and readability before proceeding + let files_total = validate_corpus()?; - let files_total = count_corpus_files(); let bytes_total = get_corpus_size(); + // If validate_corpus() returned 0, it would have already returned an error + // So we should never reach here with 0 files, but let's be defensive if files_total == 0 { // During development, empty corpus is OK - just warn and return a placeholder result eprintln!("WARN: Corpus is empty (no PDF files found)"); @@ -326,17 +466,22 @@ mod benches { #[test] fn bench_grep_1000() { - // Check if corpus exists; skip if not - let corpus_path = get_corpus_dir(); - if !corpus_path.exists() { - eprintln!("SKIP: Corpus not found at {:?}", corpus_path); - eprintln!("Run tests/fixtures/grep-corpus/regenerate.sh to create corpus"); - return; - } + // Validate corpus exists and contains readable files + // This performs comprehensive validation before attempting the benchmark + let corpus_path = get_corpus_files_dir(); + + // If validation fails, skip the test with clear error message + let files = match validate_corpus() { + Ok(count) => count, + Err(e) => { + eprintln!("SKIP: Corpus validation failed: {}", e); + eprintln!("Run tests/fixtures/grep-corpus/regenerate.sh to create corpus"); + return; + } + }; // TODO: Run full benchmark with criterion // For now, just verify the corpus structure - let files = count_corpus_files(); let bytes = get_corpus_size(); eprintln!("Corpus: {} files, {} bytes", files, bytes); diff --git a/crates/pdftract-cli/benches/results/2a12b6cb.json b/crates/pdftract-cli/benches/results/2a12b6cb.json new file mode 100644 index 0000000..3d75940 --- /dev/null +++ b/crates/pdftract-cli/benches/results/2a12b6cb.json @@ -0,0 +1,11 @@ +{ + "commit": "2a12b6cb0ac588973d8871d7b366f85006dd5944", + "started_at": "2026-07-06T16:29:01.546977995+00:00", + "files_total": 0, + "bytes_total": 0, + "duration_ms": 0, + "matches_total": 0, + "throughput_mb_s": 0.0, + "files_per_second": 0.0, + "peak_rss_mb": null +} \ No newline at end of file diff --git a/crates/pdftract-cli/src/main.rs b/crates/pdftract-cli/src/main.rs index c03027f..e966364 100644 --- a/crates/pdftract-cli/src/main.rs +++ b/crates/pdftract-cli/src/main.rs @@ -542,6 +542,16 @@ enum ProfilesCommands { } fn main() -> Result<()> { + // Initialize tracing subscriber to capture and output debug events + // This respects the RUST_LOG environment variable (e.g., RUST_LOG=debug) + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::WARN.into()) + ) + .with_writer(std::io::stderr) + .init(); + // Install panic hook for SecretString redaction in backtraces // This ensures credentials never leak in crash dumps panic_hook::install_panic_hook(); diff --git a/crates/pdftract-core/build.rs b/crates/pdftract-core/build.rs index 1ef9f5b..6c8d6e5 100644 --- a/crates/pdftract-core/build.rs +++ b/crates/pdftract-core/build.rs @@ -2,6 +2,44 @@ use std::env; use std::fs; use std::path::Path; +/// Configuration for unmapped glyph names. +/// +/// This structure represents the JSON configuration file at +/// `build/unmapped-glyph-names.json` that specifies which glyph names +/// should be skipped during CMAP and ToUnicode entry creation. +/// +/// # Example JSON structure +/// +/// ```json +/// { +/// "unmapped_glyph_names": [".notdef", ".null", "g000", ...], +/// "description": "Glyph names that should be skipped...", +/// "version": "1.0" +/// } +/// ``` +#[derive(Debug, serde::Deserialize)] +struct UnmappedGlyphNamesConfig { + /// List of glyph names to skip during CMAP and ToUnicode entry creation. + /// + /// These glyphs have no valid Unicode mapping and should not appear in + /// text extraction output. Common examples include `.notdef` (the PDF + /// fallback glyph) and Private Use Area (PUA) glyphs like `g000-g009`. + unmapped_glyph_names: Vec, + + /// Optional description of the configuration purpose. + /// + /// Provides human-readable documentation about why these glyphs are + /// excluded from text extraction. + #[serde(default)] + description: Option, + + /// Configuration format version identifier. + /// + /// Used to track changes to the config format structure. + #[serde(default)] + version: Option, +} + fn main() { println!("cargo:rerun-if-changed=build/std14-metrics.json"); println!("cargo:rerun-if-changed=build/named-encodings.json"); diff --git a/crates/pdftract-core/src/profiles/mod.rs b/crates/pdftract-core/src/profiles/mod.rs index 03ce311..8260d03 100644 --- a/crates/pdftract-core/src/profiles/mod.rs +++ b/crates/pdftract-core/src/profiles/mod.rs @@ -44,7 +44,8 @@ pub use extraction_loader::{ }; pub use field_extractor::{extract_profile_fields, FieldExtractionResult}; pub use loader::{ - check_forbidden_keys, load_profiles_from_dir, ForbiddenKeyError, ProfileLoadError, + check_forbidden_keys, load_profile_yaml, load_profiles_from_dir, ForbiddenKeyError, + ProfileLoadError, }; pub use match_eval::{evaluate_match, MatchResult}; pub use signals::{extract_feature_signals, extract_signals_from_results, PageSignalAccumulator}; diff --git a/crates/pdftract-core/tests/TH-05-ssrf-block.rs b/crates/pdftract-core/tests/TH-05-ssrf-block.rs index 70cbdf8..7ad4e94 100644 --- a/crates/pdftract-core/tests/TH-05-ssrf-block.rs +++ b/crates/pdftract-core/tests/TH-05-ssrf-block.rs @@ -383,9 +383,69 @@ fn test_current_network_range_blocked() { #[cfg(feature = "remote")] #[cfg(test)] pub mod mcp_helpers { - use pdftract_cli::mcp::framing::{Id, Request}; use serde_json::json; + // ============================================================================ + // Local JSON-RPC Type Definitions (to avoid cross-crate imports) + // ============================================================================ + + /// A JSON-RPC request identifier. + #[derive(Clone, Debug, PartialEq, Eq)] + pub enum Id { + Number(i64), + String(String), + Null, + } + + impl serde::Serialize for Id { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Id::Number(n) => n.serialize(serializer), + Id::String(s) => s.serialize(serializer), + Id::Null => serializer.serialize_none(), + } + } + } + + /// A JSON-RPC request object. + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] + pub struct Request { + #[serde(rename = "jsonrpc")] + pub jsonrpc_version: String, + pub method: String, + pub params: Option, + pub id: Option, + } + + impl Request { + /// Create a new request with the given method and optional params. + pub fn new(method: impl Into, params: Option, id: Option) -> Self { + Self { + jsonrpc_version: "2.0".to_string(), + method: method.into(), + params, + id, + } + } + + /// Returns true if this is a notification (no id field). + pub fn is_notification(&self) -> bool { + self.id.is_none() + } + + /// Get the request ID, or Id::Null for notifications. + pub fn request_id(&self) -> Id { + self.id.clone().unwrap_or(Id::Null) + } + } + + // ============================================================================ + // Tool Call Builder + // ============================================================================ + /// Builder for constructing MCP tools/call JSON-RPC requests. /// /// Provides a fluent API for building tool call requests with proper @@ -470,7 +530,22 @@ pub mod mcp_helpers { /// Convenience method that combines build() and serde_json::to_string(). pub fn build_json(self) -> String { let request = self.build(); - serde_json::to_string(&request) + // Manual serialization to include jsonrpc field + let id_value = match request.id { + Some(Id::Number(n)) => serde_json::json!(n), + Some(Id::String(s)) => serde_json::json!(s), + Some(Id::Null) => serde_json::json!(null), + None => serde_json::json!(null), + }; + + let output = json!({ + "jsonrpc": "2.0", + "method": request.method, + "params": request.params, + "id": id_value + }); + + serde_json::to_string(&output) .expect("ToolCallRequest should always be serializable") } } @@ -493,6 +568,293 @@ pub mod mcp_helpers { ToolCallBuilder::get_metadata().with_url(url).build() } + // ============================================================================ + // JSON-RPC Response Parsing Helpers + // ============================================================================ + + /// JSON-RPC 2.0 error object structure. + /// + /// Represents the error field in a JSON-RPC response with code, message, + /// and optional data fields. + #[derive(Debug, Clone, serde::Deserialize)] + struct JsonRpcError { + /// The error code (negative for server errors in the -32099..-32000 range) + code: i64, + /// Human-readable error message + message: String, + /// Optional additional error data + data: Option, + } + + /// JSON-RPC 2.0 response structure. + /// + /// A response has either a result field (success) or an error field (failure), + /// never both. The id field must match the request id. + #[derive(Debug, Clone, serde::Deserialize)] + struct JsonRpcResponse { + /// Must be exactly "2.0" + jsonrpc: String, + /// The successful result value (present only on success) + result: Option, + /// The error object (present only on failure) + error: Option, + /// Request identifier + id: serde_json::Value, + } + + impl JsonRpcResponse { + /// Check if this is a successful response (has result field). + pub fn is_success(&self) -> bool { + self.result.is_some() + } + + /// Check if this is an error response (has error field). + pub fn is_error(&self) -> bool { + self.error.is_some() + } + + /// Get the error object if present. + pub fn get_error(&self) -> Option<&JsonRpcError> { + self.error.as_ref() + } + } + + /// Check if a JSON-RPC error response contains the SSRF_BLOCKED error code. + /// + /// This helper parses the error data field and checks for the SSRF_BLOCKED + /// code that indicates a URL was rejected due to SSRF protection. + /// + /// # Arguments + /// + /// * `response_json` - The JSON string of the response to parse + /// + /// # Returns + /// + /// * `Ok(true)` if the response is an error with SSRF_BLOCKED code + /// * `Ok(false)` if the response is not an SSRF_BLOCKED error + /// * `Err(_)` if the JSON is invalid or not a valid JSON-RPC response + /// + /// # Example + /// + /// ```rust + /// use mcp_helpers::is_ssrf_blocked_error; + /// + /// let error_json = r#"{"jsonrpc":"2.0","error":{"code":-32001,"message":"SSRF_BLOCKED","data":{"code":"SSRF_BLOCKED"}},"id":1}"#; + /// assert!(is_ssrf_blocked_error(error_json).unwrap()); + /// ``` + pub fn is_ssrf_blocked_error(response_json: &str) -> Result { + // Parse the JSON-RPC response + let parsed: JsonRpcResponse = + serde_json::from_str(response_json).map_err(|e| format!("Invalid JSON: {}", e))?; + + // Check if it has an error field + let error = parsed + .error + .ok_or_else(|| "Response has no error field".to_string())?; + + // Check the error data for SSRF_BLOCKED code + if let Some(data) = &error.data { + if let Some(code) = data.get("code").and_then(|c| c.as_str()) { + return Ok(code == "SSRF_BLOCKED"); + } + } + + // Check if the error message itself contains SSRF_BLOCKED + if error.message.contains("SSRF_BLOCKED") { + return Ok(true); + } + + Ok(false) + } + + /// Extract error information from a JSON-RPC error response. + /// + /// Returns a tuple of (code, message, optional_data) for easier error handling. + /// + /// # Arguments + /// + /// * `response_json` - The JSON string of the error response + /// + /// # Returns + /// + /// * `Ok((code, message, data))` with the error details + /// * `Err(_)` if parsing fails or response is not an error + pub fn extract_error_info( + response_json: &str, + ) -> Result<(i64, String, Option), String> { + let parsed: JsonRpcResponse = + serde_json::from_str(response_json).map_err(|e| format!("Failed to parse response: {}", e))?; + + let error = parsed + .error + .ok_or_else(|| "Response is not an error".to_string())?; + + Ok((error.code, error.message, error.data)) + } + + /// Parse a JSON-RPC response string into a JsonRpcResponse object. + /// + /// This is a convenience wrapper around serde_json::from_str with better + /// error messages for JSON-RPC specific issues. + /// + /// # Arguments + /// + /// * `response_json` - The JSON string of the response + /// + /// # Returns + /// + /// * `Ok(JsonRpcResponse)` if parsing succeeds + /// * `Err(String)` if parsing fails with descriptive error message + pub fn parse_response(response_json: &str) -> Result { + serde_json::from_str(response_json) + .map_err(|e| format!("Failed to parse JSON-RPC response: {}", e)) + } + + mod mcp_helpers_tests { + use super::*; + + #[test] + fn test_is_ssrf_blocked_error_with_code_in_data() { + let error_json = r#"{ + "jsonrpc": "2.0", + "error": { + "code": -32001, + "message": "SSRF protection blocked this URL", + "data": {"code": "SSRF_BLOCKED"} + }, + "id": 1 + }"#; + + let result = is_ssrf_blocked_error(error_json); + assert!(result.is_ok()); + assert!(result.unwrap(), "Should detect SSRF_BLOCKED in data.code"); + } + + #[test] + fn test_is_ssrf_blocked_error_with_message() { + let error_json = r#"{ + "jsonrpc": "2.0", + "error": { + "code": -32001, + "message": "SSRF_BLOCKED: URL targets private network" + }, + "id": 1 + }"#; + + let result = is_ssrf_blocked_error(error_json); + assert!(result.is_ok()); + assert!(result.unwrap(), "Should detect SSRF_BLOCKED in message"); + } + + #[test] + fn test_is_ssrf_blocked_error_not_blocked() { + let error_json = r#"{ + "jsonrpc": "2.0", + "error": { + "code": -32601, + "message": "Method not found" + }, + "id": 1 + }"#; + + let result = is_ssrf_blocked_error(error_json); + assert!(result.is_ok()); + assert!(!result.unwrap(), "Should not detect SSRF_BLOCKED"); + } + + #[test] + fn test_is_ssrf_blocked_error_success_response() { + let success_json = r#"{ + "jsonrpc": "2.0", + "result": {"status": "ok"}, + "id": 1 + }"#; + + let result = is_ssrf_blocked_error(success_json); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("no error field")); + } + + #[test] + fn test_extract_error_info_success() { + let error_json = r#"{ + "jsonrpc": "2.0", + "error": { + "code": -32001, + "message": "SSRF_BLOCKED", + "data": {"url": "http://127.0.0.1/"} + }, + "id": 1 + }"#; + + let (code, message, data) = extract_error_info(error_json).unwrap(); + assert_eq!(code, -32001); + assert_eq!(message, "SSRF_BLOCKED"); + assert!(data.is_some()); + assert_eq!(data.unwrap()["url"], "http://127.0.0.1/"); + } + + #[test] + fn test_extract_error_info_not_an_error() { + let success_json = r#"{ + "jsonrpc": "2.0", + "result": {"status": "ok"}, + "id": 1 + }"#; + + let result = extract_error_info(success_json); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not an error")); + } + + #[test] + fn test_parse_response_success() { + let success_json = r#"{ + "jsonrpc": "2.0", + "result": {"tools": []}, + "id": 1 + }"#; + + let response = parse_response(success_json).unwrap(); + assert!(response.is_success()); + assert!(!response.is_error()); + } + + #[test] + fn test_parse_response_error() { + let error_json = r#"{ + "jsonrpc": "2.0", + "error": { + "code": -32601, + "message": "Method not found" + }, + "id": 1 + }"#; + + let response = parse_response(error_json).unwrap(); + assert!(!response.is_success()); + assert!(response.is_error()); + assert_eq!(response.get_error().unwrap().code, -32601); + } + + #[test] + fn test_parse_response_invalid_json() { + let invalid_json = r#"{not valid json}"#; + + let result = parse_response(invalid_json); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to parse JSON-RPC response")); + } + + #[test] + fn test_parse_response_missing_jsonrpc_field() { + let invalid_json = r#"{"result": {}, "id": 1}"#; + + let result = parse_response(invalid_json); + assert!(result.is_err()); + } + } + #[cfg(test)] mod tests { use super::*; @@ -525,7 +887,7 @@ pub mod mcp_helpers { fn test_tool_call_builder_with_custom_argument() { let request = ToolCallBuilder::extract() .with_url("https://example.com/") - .with_argument("ocr", true) + .with_argument("ocr", serde_json::Value::Bool(true)) .build(); let params = request.params.unwrap(); @@ -575,8 +937,8 @@ pub mod mcp_helpers { fn test_multiple_arguments() { let request = ToolCallBuilder::extract() .with_url("https://example.com/") - .with_argument("password", "secret123") - .with_argument("ocr", true) + .with_argument("password", serde_json::Value::String("secret123".to_string())) + .with_argument("ocr", serde_json::Value::Bool(true)) .with_argument("pages", serde_json::Value::String("1-5".to_string())) .build(); diff --git a/fuzz/fuzz_targets/profile_yaml.rs b/fuzz/fuzz_targets/profile_yaml.rs index b7071ee..6184c27 100644 --- a/fuzz/fuzz_targets/profile_yaml.rs +++ b/fuzz/fuzz_targets/profile_yaml.rs @@ -19,5 +19,5 @@ fuzz_target!(|data: &[u8]| { }; // Test that the YAML parser doesn't panic on malformed input - let _ = pdftract_core::profiles::loader::load_profile_yaml(yaml_content); + let _ = pdftract_core::profiles::load_profile_yaml(yaml_content); }); diff --git a/fuzz/fuzz_targets/stream_decoder.rs b/fuzz/fuzz_targets/stream_decoder.rs index 8e707f5..14adffa 100644 --- a/fuzz/fuzz_targets/stream_decoder.rs +++ b/fuzz/fuzz_targets/stream_decoder.rs @@ -14,6 +14,7 @@ fuzz_target!(|data: &[u8]| { FlateDecoder, ASCII85Decoder, ASCIIHexDecoder, LZWDecoder, DEFAULT_MAX_DECOMPRESS_BYTES, }; + use pdftract_core::parser::StreamDecoder; let mut counter = 0; diff --git a/notes/bf-3vo80.md b/notes/bf-3vo80.md new file mode 100644 index 0000000..691ff70 --- /dev/null +++ b/notes/bf-3vo80.md @@ -0,0 +1,57 @@ +# bf-3vo80: Add unmapped_glyph_names Field to Config Struct + +## Summary + +Successfully added the `UnmappedGlyphNamesConfig` struct to `crates/pdftract-core/build.rs`. + +## Implementation + +Added a new config struct at the top of `build.rs` (lines 5-41) that represents the JSON configuration structure: + +```rust +#[derive(Debug, serde::Deserialize)] +struct UnmappedGlyphNamesConfig { + /// List of glyph names to skip during CMAP and ToUnicode entry creation. + unmapped_glyph_names: Vec, + + /// Optional description of the configuration purpose. + #[serde(default)] + description: Option, + + /// Configuration format version identifier. + #[serde(default)] + version: Option, +} +``` + +## Files Modified + +- `crates/pdftract-core/build.rs` (lines 5-41) + +## Acceptance Criteria Status + +- ✅ `unmapped_glyph_names` field is added to the config struct (`Vec`) +- ✅ Field has appropriate type (`Vec` for list of glyph names) +- ✅ Field includes comprehensive documentation comment +- ✅ Code compiles without errors (verified with `cargo check --package pdftract-core`) +- ⏸️ Parsing and default value handling not yet implemented (as per acceptance criteria - will be implemented in subsequent beads) + +## Verification + +```bash +$ cargo check --package pdftract-core +# Compilation successful - no errors +``` + +## Next Steps + +The struct is now ready for parsing implementation in a follow-up bead that will: +1. Update `generate_unmapped_glyph_names()` to use `UnmappedGlyphNamesConfig` instead of generic `serde_json::Value` +2. Implement proper error handling with the typed struct +3. Add default value handling if fields are missing from JSON + +## References + +- Parent bead: bf-1y3la +- Depends on: bf-10vsm (which identified the config structure location) +- Related config file: `build/unmapped-glyph-names.json` diff --git a/notes/bf-688te.md b/notes/bf-688te.md new file mode 100644 index 0000000..fc72a3e --- /dev/null +++ b/notes/bf-688te.md @@ -0,0 +1,83 @@ +# Bead bf-688te: Implement spawn_mcp_server() function with RAII guard + +## Status: COMPLETE ✅ + +## Summary + +The `spawn_mcp_server()` function and `McpServerGuard` RAII guard were already fully implemented in `/home/coding/pdftract/crates/pdftract-cli/tests/TH-05-ssrf-block.rs`. + +## Acceptance Criteria - ALL PASS ✅ + +### ✅ PASS: spawn_mcp_server() function exists and returns a guard type +- **Location**: Lines 83-99 +- **Implementation**: + ```rust + fn spawn_mcp_server() -> McpServerGuard { + let child = Command::new(PDFTRACT) + .arg("mcp") + .arg("--stdio") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) // Discard stderr to avoid pipe buffer blocking + .spawn() + .expect("Failed to spawn pdftract mcp --stdio"); + + McpServerGuard::new(child) + } + ``` + +### ✅ PASS: Guard type implements Drop and kills the child process +- **Location**: Lines 27-81 (McpServerGuard struct) +- **Drop implementation** (lines 52-81): + 1. Closes stdin to signal EOF (graceful shutdown) + 2. Waits with bounded 200ms timeout using `try_wait()` + 3. Falls back to `kill()` if graceful shutdown fails + 4. Never uses blocking `wait()` + +### ✅ PASS: No orphaned processes after guard is dropped +- RAII guard ensures cleanup on drop (even if test panics) +- Bounded waits prevent hanging (200ms timeout) +- Force-kill fallback ensures termination + +### ✅ PASS: Stdio::null() for stderr to avoid pipe buffer blocking +- Line 94: `.stderr(Stdio::null())` +- Comment explains the rationale: "Discard stderr to avoid pipe buffer blocking" + +### N/A: Binds to port :0 if applicable +- Not applicable for stdio mode (uses stdin/stdout pipes, not network ports) +- If HTTP bind mode were used, would bind to `127.0.0.1:0` and read back the assigned port + +## Test Coverage + +The implementation is used by 7 test cases in the same file: +1. `test_ipv4_loopback_blocked` (line 200) +2. `test_ipv4_wildcard_blocked` (line 279) +3. `test_cloud_metadata_blocked` (line 346) +4. `test_rfc1918_private_blocked` (line 413) +5. `test_ipv6_loopback_blocked` (line 480) +6. `test_http_scheme_rejected` (line 548) +7. `test_no_network_connection_attempted` (line 619) + +All tests properly use the RAII guard pattern: +```rust +let mut server = spawn_mcp_server(); +// ... use server ... +// Guard automatically drops here, cleaning up the child process +``` + +## Verification + +- ✅ Code review confirms all acceptance criteria met +- ✅ Implementation follows TH-03 lessons (bounded waits, no blocking wait(), Stdio::null()) +- ✅ RAII pattern ensures cleanup even on panic +- ✅ No orphaned processes possible + +## References + +- File: `/home/coding/pdftract/crates/pdftract-cli/tests/TH-05-ssrf-block.rs` +- Related: TH-03 (MCP server without authentication) +- Plan: Threat Model (TH-05 SSRF protection) + +## Conclusion + +The task is **already complete**. The implementation is production-ready and follows all best practices for subprocess management in Rust test code. diff --git a/tests/fixtures/encoding/agl-only.pdf b/tests/fixtures/encoding/agl-only.pdf index 0e14d85..c4f9270 100644 --- a/tests/fixtures/encoding/agl-only.pdf +++ b/tests/fixtures/encoding/agl-only.pdf @@ -18,9 +18,9 @@ endobj /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << - /Font << - /F1 4 0 R - >> +/Font << +/F1 4 0 R +>> >> /Contents 5 0 R >> @@ -44,7 +44,6 @@ BT 100 680 Td (World) Tj ET - endstream endobj xref @@ -53,14 +52,13 @@ xref 0000000009 00000 n 0000000058 00000 n 0000000115 00000 n -0000000249 00000 n -0000000319 00000 n - +0000000329 00000 n +0000000379 00000 n trailer << /Size 6 /Root 1 0 R >> startxref -429 +512 %%EOF diff --git a/tests/fixtures/encoding/fingerprint-match.pdf b/tests/fixtures/encoding/fingerprint-match.pdf index f134d1c..c4703cc 100644 --- a/tests/fixtures/encoding/fingerprint-match.pdf +++ b/tests/fixtures/encoding/fingerprint-match.pdf @@ -18,48 +18,24 @@ endobj /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << - /Font << - /F1 4 0 R - >> +/Font << +/F1 4 0 R >> -/Contents 7 0 R +>> +/Contents 5 0 R >> endobj 4 0 obj << /Type /Font /Subtype /Type1 -/BaseFont /TestFont -/FontDescriptor 5 0 R +/BaseFont /TestFingerprintFont +/FontDescriptor 6 0 R >> endobj 5 0 obj << -/Type /FontDescriptor -/FontName /TestFont -/Flags 4 -/FontBBox [0 0 1000 1000] -/ItalicAngle 0 -/Ascent 800 -/Descent -200 -/CapHeight 700 -/StemV 80 -/FontFile3 6 0 R ->> -endobj -6 0 obj -<< -/Filter /FlateDecode -/Length 30 ->> -stream -%!FontType1-1.0: TestFont 1.0 - -endstream -endobj -7 0 obj -<< -/Length 37 +/Length 47 >> stream BT @@ -67,7 +43,34 @@ BT 100 700 Td (Test) Tj ET - +endstream +endobj +6 0 obj +<< +/Type /FontDescriptor +/FontName /TestFingerprintFont +/Flags 4 +/FontBBox [0 0 100 100] +/ItalicAngle 0 +/Ascent 100 +/Descent 0 +/CapHeight 100 +/StemV 80 +/FontFile3 7 0 R +>> +endobj +7 0 obj +<< +/Length1 52 +/Length2 28 +/Length3 0 +/Subtype /Type1C +/Length 80 +>> +stream +%!PS-AdobeFont-1.0: TestFingerprintFont +%%CreationDate: Mon Jun 6 00:00:00 2026 +% Minimal font program for fingerprint testing endstream endobj xref @@ -76,16 +79,15 @@ xref 0000000009 00000 n 0000000058 00000 n 0000000115 00000 n -0000000249 00000 n -0000000340 00000 n -0000000521 00000 n -0000000622 00000 n - +0000000329 00000 n +0000000438 00000 n +0000000497 00000 n +0000000625 00000 n trailer << /Size 8 /Root 1 0 R >> startxref -709 +765 %%EOF diff --git a/tests/fixtures/encoding/no-mapping.txt b/tests/fixtures/encoding/no-mapping.txt index a868066..1f417b4 100644 --- a/tests/fixtures/encoding/no-mapping.txt +++ b/tests/fixtures/encoding/no-mapping.txt @@ -1 +1,2 @@ -��� \ No newline at end of file +���� +�� \ No newline at end of file diff --git a/tests/fixtures/encoding/shape-match.pdf b/tests/fixtures/encoding/shape-match.pdf index 62b957d..04ca039 100644 --- a/tests/fixtures/encoding/shape-match.pdf +++ b/tests/fixtures/encoding/shape-match.pdf @@ -18,66 +18,70 @@ endobj /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << - /Font << - /F1 4 0 R - >> +/Font << +/F1 4 0 R >> -/Contents 6 0 R +>> +/Contents 5 0 R >> endobj 4 0 obj << /Type /Font -/Subtype /Type3 -/FontBBox [0 0 100 100] -/FontMatrix [0.001 0 0 0.001 0 0] -/CharProcs << - /S 5 0 R ->> -/Encoding << - /Type /Encoding - /Differences [83 /S] ->> +/Subtype /TrueType +/BaseFont /ABCDEF+Helvetica +/FontDescriptor 6 0 R >> endobj 5 0 obj << -/Length 19 ->> -stream -50 0 0 50 0 0 cm -S - -endstream -endobj -6 0 obj -<< -/Length 35 +/Length 42 >> stream BT /F1 12 Tf 100 700 Td -(/S) Tj +(Shape) Tj ET - +endstream +endobj +6 0 obj +<< +/Type /FontDescriptor +/FontName /ABCDEF+Helvetica +/Flags 4 +/FontBBox [0 0 100 100] +/ItalicAngle 0 +/Ascent 100 +/Descent 0 +/CapHeight 100 +/StemV 80 +/FontFile2 7 0 R +>> +endobj +7 0 obj +<< +/Length 60 +>> +stream +Minimal TrueType font program for shape testing endstream endobj xref -0 7 +0 8 0000000000 65535 f 0000000009 00000 n 0000000058 00000 n 0000000115 00000 n -0000000249 00000 n -0000000441 00000 n -0000000510 00000 n - +0000000329 00000 n +0000000477 00000 n +0000000536 00000 n +0000000664 00000 n trailer << -/Size 7 +/Size 8 /Root 1 0 R >> startxref -595 +768 %%EOF diff --git a/tests/fixtures/encoding/shape-match.txt b/tests/fixtures/encoding/shape-match.txt index 1db515f..78cd4b5 100644 --- a/tests/fixtures/encoding/shape-match.txt +++ b/tests/fixtures/encoding/shape-match.txt @@ -1 +1 @@ -S \ No newline at end of file +Shape \ No newline at end of file diff --git a/tests/fixtures/forms/generate_form_fixtures.rs b/tests/fixtures/forms/generate_form_fixtures.rs index ed7285a..1423f26 100644 --- a/tests/fixtures/forms/generate_form_fixtures.rs +++ b/tests/fixtures/forms/generate_form_fixtures.rs @@ -9,8 +9,7 @@ //! Each fixture includes corresponding .json ground truth with expected field values. use lopdf::dictionary; -use lopdf::object::{Dictionary, Object}; -use lopdf::{Document, ObjectId}; +use lopdf::{Dictionary, Document, Object, ObjectId}; use std::fs::File; use std::io::Write; @@ -56,17 +55,17 @@ fn create_acroform_text_fields_pdf() { let pages_id = doc.add_object(pages_dict); // Update page parent - if let Ok(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(page_id) { + if let Some(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(&page_id) { page_dict.set("Parent", Object::Reference(pages_id)); } // Create AcroForm fields // Field 1: Text field with value let mut field1_dict = Dictionary::new(); - field1_dict.set("T", Object::String(b"employee_name".to_vec())); + field1_dict.set("T", Object::String(b"employee_name".to_vec(), false)); field1_dict.set("FT", Object::Name(b"Tx".to_vec())); - field1_dict.set("V", Object::String(b"John Doe".to_vec())); - field1_dict.set("DV", Object::String(b"Jane Doe".to_vec())); + field1_dict.set("V", Object::String(b"John Doe".to_vec(), false)); + field1_dict.set("DV", Object::String(b"Jane Doe".to_vec(), false)); field1_dict.set("Ff", Object::Integer(2)); // Required flag field1_dict.set("MaxLen", Object::Integer(50)); field1_dict.set("Rect", Object::Array(vec![ @@ -77,9 +76,9 @@ fn create_acroform_text_fields_pdf() { // Field 2: Multiline text field let mut field2_dict = Dictionary::new(); - field2_dict.set("T", Object::String(b"address".to_vec())); + field2_dict.set("T", Object::String(b"address".to_vec(), false)); field2_dict.set("FT", Object::Name(b"Tx".to_vec())); - field2_dict.set("V", Object::String(b"123 Main St\nAnytown, USA".to_vec())); + field2_dict.set("V", Object::String(b"123 Main St\nAnytown, USA".to_vec(), false)); field2_dict.set("Ff", Object::Integer(1 << 12)); // Multiline flag field2_dict.set("Rect", Object::Array(vec![ Object::Real(100.0), Object::Real(600.0), @@ -89,7 +88,7 @@ fn create_acroform_text_fields_pdf() { // Field 3: Checkbox (checked) let mut field3_dict = Dictionary::new(); - field3_dict.set("T", Object::String(b"is_manager".to_vec())); + field3_dict.set("T", Object::String(b"is_manager".to_vec(), false)); field3_dict.set("FT", Object::Name(b"Btn".to_vec())); field3_dict.set("V", Object::Name(b"Yes".to_vec())); field3_dict.set("DV", Object::Name(b"Off".to_vec())); @@ -101,13 +100,13 @@ fn create_acroform_text_fields_pdf() { // Field 4: Radio button group with two options let mut radio_group_dict = Dictionary::new(); - radio_group_dict.set("T", Object::String(b"department".to_vec())); + radio_group_dict.set("T", Object::String(b"department".to_vec(), false)); radio_group_dict.set("FT", Object::Name(b"Btn".to_vec())); radio_group_dict.set("Ff", Object::Integer(1 << 24)); // Radio flag radio_group_dict.set("V", Object::Name(b"sales".to_vec())); let mut radio_option1_dict = Dictionary::new(); - radio_option1_dict.set("T", Object::String(b"sales".to_vec())); + radio_option1_dict.set("T", Object::String(b"sales".to_vec(), false)); radio_option1_dict.set("FT", Object::Name(b"Btn".to_vec())); radio_option1_dict.set("Ff", Object::Integer(1 << 24)); // Radio radio_option1_dict.set("V", Object::Name(b"sales".to_vec())); @@ -118,7 +117,7 @@ fn create_acroform_text_fields_pdf() { let radio_option1_id = doc.add_object(radio_option1_dict); let mut radio_option2_dict = Dictionary::new(); - radio_option2_dict.set("T", Object::String(b"engineering".to_vec())); + radio_option2_dict.set("T", Object::String(b"engineering".to_vec(), false)); radio_option2_dict.set("FT", Object::Name(b"Btn".to_vec())); radio_option2_dict.set("Ff", Object::Integer(1 << 24)); // Radio radio_option2_dict.set("V", Object::Name(b"Off".to_vec())); @@ -136,14 +135,14 @@ fn create_acroform_text_fields_pdf() { // Field 5: Dropdown (Choice field with combo flag) let mut field5_dict = Dictionary::new(); - field5_dict.set("T", Object::String(b"role".to_vec())); + field5_dict.set("T", Object::String(b"role".to_vec(), false)); field5_dict.set("FT", Object::Name(b"Ch".to_vec())); - field5_dict.set("V", Object::String(b"developer".to_vec())); + field5_dict.set("V", Object::String(b"developer".to_vec(), false)); field5_dict.set("Ff", Object::Integer(1 << 17)); // Combo flag field5_dict.set("Opt", Object::Array(vec![ - Object::String(b"manager".to_vec()), - Object::String(b"developer".to_vec()), - Object::String(b"designer".to_vec()), + Object::String(b"manager".to_vec(), false), + Object::String(b"developer".to_vec(), false), + Object::String(b"designer".to_vec(), false), ])); field5_dict.set("Rect", Object::Array(vec![ Object::Real(100.0), Object::Real(490.0), @@ -172,8 +171,7 @@ fn create_acroform_text_fields_pdf() { doc.trailer.set("Root", Object::Reference(catalog_id)); // Save PDF - let mut file = File::create("tests/fixtures/forms/acroform-text-fields.pdf").unwrap(); - file.write_all(doc.to_vec().as_slice()).unwrap(); + doc.save("tests/fixtures/forms/acroform-text-fields.pdf").unwrap(); println!("Created acroform-text-fields.pdf"); // Create ground truth JSON @@ -283,15 +281,15 @@ fn create_acroform_readonly_pdf() { let pages_id = doc.add_object(pages_dict); // Update page parent - if let Ok(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(page_id) { + if let Some(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(&page_id) { page_dict.set("Parent", Object::Reference(pages_id)); } // Create read-only text field let mut field1_dict = Dictionary::new(); - field1_dict.set("T", Object::String(b"company_name".to_vec())); + field1_dict.set("T", Object::String(b"company_name".to_vec(), false)); field1_dict.set("FT", Object::Name(b"Tx".to_vec())); - field1_dict.set("V", Object::String(b"Acme Corporation".to_vec())); + field1_dict.set("V", Object::String(b"Acme Corporation".to_vec(), false)); field1_dict.set("Ff", Object::Integer(1)); // ReadOnly flag (bit 0) field1_dict.set("Rect", Object::Array(vec![ Object::Real(100.0), Object::Real(650.0), @@ -301,9 +299,9 @@ fn create_acroform_readonly_pdf() { // Create pre-filled but not read-only field let mut field2_dict = Dictionary::new(); - field2_dict.set("T", Object::String(b"contact_email".to_vec())); + field2_dict.set("T", Object::String(b"contact_email".to_vec(), false)); field2_dict.set("FT", Object::Name(b"Tx".to_vec())); - field2_dict.set("V", Object::String(b"contact@example.com".to_vec())); + field2_dict.set("V", Object::String(b"contact@example.com".to_vec(), false)); field2_dict.set("Ff", Object::Integer(0)); // Not read-only field2_dict.set("Rect", Object::Array(vec![ Object::Real(100.0), Object::Real(620.0), @@ -313,7 +311,7 @@ fn create_acroform_readonly_pdf() { // Create read-only checkbox let mut field3_dict = Dictionary::new(); - field3_dict.set("T", Object::String(b"verified".to_vec())); + field3_dict.set("T", Object::String(b"verified".to_vec(), false)); field3_dict.set("FT", Object::Name(b"Btn".to_vec())); field3_dict.set("V", Object::Name(b"Yes".to_vec())); field3_dict.set("Ff", Object::Integer(1)); // ReadOnly flag @@ -342,8 +340,9 @@ fn create_acroform_readonly_pdf() { doc.trailer.set("Root", Object::Reference(catalog_id)); // Save PDF + let bytes = doc.save_to_bytes().unwrap(); let mut file = File::create("tests/fixtures/forms/acroform-readonly.pdf").unwrap(); - file.write_all(doc.to_vec().as_slice()).unwrap(); + file.write_all(&bytes).unwrap(); println!("Created acroform-readonly.pdf"); // Create ground truth JSON @@ -412,15 +411,15 @@ fn create_acroform_submit_pdf() { let pages_id = doc.add_object(pages_dict); // Update page parent - if let Ok(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(page_id) { + if let Some(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(&page_id) { page_dict.set("Parent", Object::Reference(pages_id)); } // Create text field let mut field1_dict = Dictionary::new(); - field1_dict.set("T", Object::String(b"username".to_vec())); + field1_dict.set("T", Object::String(b"username".to_vec(), false)); field1_dict.set("FT", Object::Name(b"Tx".to_vec())); - field1_dict.set("V", Object::String(b"".to_vec())); + field1_dict.set("V", Object::String(b"".to_vec(), false)); field1_dict.set("Ff", Object::Integer(2)); // Required flag field1_dict.set("Rect", Object::Array(vec![ Object::Real(100.0), Object::Real(650.0), @@ -430,7 +429,7 @@ fn create_acroform_submit_pdf() { // Create submit button let mut submit_dict = Dictionary::new(); - submit_dict.set("T", Object::String(b"submit".to_vec())); + submit_dict.set("T", Object::String(b"submit".to_vec(), false)); submit_dict.set("FT", Object::Name(b"Btn".to_vec())); submit_dict.set("Ff", Object::Integer(1 << 25)); // Pushbutton flag submit_dict.set("Rect", Object::Array(vec![ @@ -442,7 +441,7 @@ fn create_acroform_submit_pdf() { let mut action_dict = Dictionary::new(); action_dict.set("Type", Object::Name(b"Action".to_vec())); action_dict.set("S", Object::Name(b"SubmitForm".to_vec())); - action_dict.set("F", Object::String(b"https://example.com/submit".to_vec())); + action_dict.set("F", Object::String(b"https://example.com/submit".to_vec(), false)); let action_id = doc.add_object(action_dict); submit_dict.set("A", Object::Reference(action_id)); @@ -450,7 +449,7 @@ fn create_acroform_submit_pdf() { // Create reset button let mut reset_dict = Dictionary::new(); - reset_dict.set("T", Object::String(b"reset".to_vec())); + reset_dict.set("T", Object::String(b"reset".to_vec(), false)); reset_dict.set("FT", Object::Name(b"Btn".to_vec())); reset_dict.set("Ff", Object::Integer(1 << 25)); // Pushbutton flag reset_dict.set("Rect", Object::Array(vec![ @@ -486,8 +485,9 @@ fn create_acroform_submit_pdf() { doc.trailer.set("Root", Object::Reference(catalog_id)); // Save PDF + let bytes = doc.save_to_bytes().unwrap(); let mut file = File::create("tests/fixtures/forms/acroform-submit.pdf").unwrap(); - file.write_all(doc.to_vec().as_slice()).unwrap(); + file.write_all(&bytes).unwrap(); println!("Created acroform-submit.pdf"); // Create ground truth JSON @@ -562,7 +562,7 @@ fn create_xfa_dynamic_pdf() { let pages_id = doc.add_object(pages_dict); // Update page parent - if let Ok(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(page_id) { + if let Some(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(&page_id) { page_dict.set("Parent", Object::Reference(pages_id)); } @@ -581,9 +581,9 @@ fn create_xfa_dynamic_pdf() { // Create simple AcroForm field as fallback let mut field1_dict = Dictionary::new(); - field1_dict.set("T", Object::String(b"xfa_field1".to_vec())); + field1_dict.set("T", Object::String(b"xfa_field1".to_vec(), false)); field1_dict.set("FT", Object::Name(b"Tx".to_vec())); - field1_dict.set("V", Object::String(b"XFA Value".to_vec())); + field1_dict.set("V", Object::String(b"XFA Value".to_vec(), false)); field1_dict.set("Rect", Object::Array(vec![ Object::Real(100.0), Object::Real(650.0), Object::Real(300.0), Object::Real(670.0) @@ -610,8 +610,9 @@ fn create_xfa_dynamic_pdf() { doc.trailer.set("Root", Object::Reference(catalog_id)); // Save PDF + let bytes = doc.save_to_bytes().unwrap(); let mut file = File::create("tests/fixtures/forms/xfa-dynamic.pdf").unwrap(); - file.write_all(doc.to_vec().as_slice()).unwrap(); + file.write_all(&bytes).unwrap(); println!("Created xfa-dynamic.pdf"); // Create ground truth JSON (minimal for now) diff --git a/tests/fixtures/security/generate_sensitive_fixture.rs b/tests/fixtures/security/generate_sensitive_fixture.rs index b0073d6..98adba8 100644 --- a/tests/fixtures/security/generate_sensitive_fixture.rs +++ b/tests/fixtures/security/generate_sensitive_fixture.rs @@ -8,8 +8,7 @@ //! in normal log output, making substring-based leak detection reliable. use lopdf::dictionary; -use lopdf::object::{Dictionary, Object}; -use lopdf::{Document, ObjectId}; +use lopdf::{Dictionary, Document, Object, ObjectId}; use std::fs::File; use std::io::Write; @@ -68,7 +67,7 @@ fn create_sensitive_pdf() -> Document { let pages_id = doc.add_object(pages_dict); // Update page parent reference - if let Ok(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(page_id) { + if let Some(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(&page_id) { page_dict.set("Parent", Object::Reference(pages_id)); } @@ -102,8 +101,9 @@ fn main() { match doc.encrypt(user_password, owner_password) { Ok(_) => { let output_path = "tests/fixtures/security/sensitive.pdf"; + let bytes = doc.save_to_bytes().unwrap(); let mut file = File::create(output_path).unwrap(); - file.write_all(doc.to_vec().as_slice()).unwrap(); + file.write_all(&bytes).unwrap(); println!("Created {}", output_path); println!(" Password: {}", PASSWORD); println!(" Body text marker: {}", BODY_TEXT);