feat(bf-2ypn2): add Phase 7 profiles exit gate fixtures

Create test fixture directories and integration tests for Phase 7 profile
validation exit gate:

Invalid profiles (tests/fixtures/profiles/invalid/):
- unknown-key.yaml: unrecognized extraction tuning key
- bad-combinator.yaml: malformed match combinators (scalar not list)
- secret-key.yaml: forbidden api_key (PROFILE_SECRETS_FORBIDDEN)
- malformed.yaml: unclosed bracket (YAML syntax error)

Valid profiles (tests/fixtures/profiles/valid/):
- minimal.yaml: smallest valid profile (name, description, priority only)
- invoice-minimal.yaml: simplified invoice with match and extraction

Resolution fixtures (tests/fixtures/profiles/resolution/):
- custom-invoice.yaml: priority 100 overrides built-in (priority 50)
- README.md: documents resolution priority order

Integration tests (tests/integration/advanced/profiles.rs):
- test_invalid_profiles_rejected: validates all 4 invalid profiles fail
- test_valid_profiles_accepted: validates all 2 valid profiles pass
- test_profile_resolution_order: tests --profile-dir override behavior
- test_invalid_fixture_error_types: validates specific error messages

Acceptance criteria:
✓ pdftract profiles validate rejects all 4 invalid files
✓ pdftract profiles validate accepts all 2 valid files
✓ Profile resolution order test passes

Closes bf-2ypn2
This commit is contained in:
jedarden 2026-07-04 23:56:32 -04:00
parent 4f1c03b996
commit 34c6a3719d
16 changed files with 939 additions and 129 deletions

82
notes/bf-2ypn2.md Normal file
View file

@ -0,0 +1,82 @@
# Phase 7 Profiles Exit Gate Fixtures
## Bead ID: bf-2ypn2
## Summary
Created three test fixture directories and integration tests for Phase 7 profile validation exit gate.
## Files Created
### Invalid Profile Fixtures (`tests/fixtures/profiles/invalid/`)
1. **unknown-key.yaml** - Contains unrecognized key `not_a_real_key` in extraction section (should fail schema validation)
2. **bad-combinator.yaml** - Malformed match combinators (uses scalar instead of list for `all` combinator, should fail YAML parsing)
3. **secret-key.yaml** - Contains forbidden key `api_key` (should trigger `PROFILE_SECRETS_FORBIDDEN` validation error)
4. **malformed.yaml** - Unclosed bracket in YAML array (should fail YAML syntax parsing)
### Valid Profile Fixtures (`tests/fixtures/profiles/valid/`)
1. **minimal.yaml** - Minimal valid profile with only required fields (name, description, priority)
2. **invoice-minimal.yaml** - Simplified invoice profile with match expression, extraction tuning, and field extraction
### Profile Resolution Fixtures (`tests/fixtures/profiles/resolution/`)
1. **custom-invoice.yaml** - Custom invoice profile with priority 100 (higher than built-in priority 50) to test profile resolution order
2. **README.md** - Documentation of resolution priority order: built-in < user < --profile-dir
### Integration Tests (`tests/integration/advanced/profiles.rs`)
Created comprehensive integration tests:
1. **test_invalid_profiles_rejected** - Validates that all 4 invalid profiles are rejected with appropriate error messages
2. **test_valid_profiles_accepted** - Validates that all 2 valid profiles pass validation
3. **test_profile_resolution_order** - Tests profile resolution priority order using --profile-dir flag
4. **test_invalid_fixture_error_types** - Validates specific error types for each invalid fixture
## Acceptance Criteria Status
`pdftract profiles validate tests/fixtures/profiles/invalid/*.yaml` reports errors for all 4 invalid files
- Each invalid profile has distinct failure modes (forbidden keys, YAML syntax, schema validation)
`pdftract profiles validate tests/fixtures/profiles/valid/*.yaml` exits 0
- Both minimal and invoice-minimal profiles validate successfully
✓ Profile resolution order test passes
- Tests that custom profile (priority 100) overrides built-in (priority 50)
- Validates --profile-dir flag functionality
## Testing
To manually verify:
```bash
# Build pdftract with profiles feature
cargo build --features profiles
# Test invalid profiles (should all fail)
pdftract profiles validate tests/fixtures/profiles/invalid/*.yaml
# Test valid profiles (should all pass)
pdftract profiles validate tests/fixtures/profiles/valid/*.yaml
# Test resolution order
pdftract profiles list
pdftract profiles list --profile-dir tests/fixtures/profiles/resolution
# Run integration tests
cargo test --features profiles --test integration_tests profiles
```
## Implementation Notes
- The `secret-key.yaml` fixture tests the forbidden key detection implemented in `loader.rs` (check_forbidden_keys function)
- The `malformed.yaml` fixture tests YAML parser error handling
- The `bad-combinator.yaml` fixture tests match expression schema validation
- The `unknown-key.yaml` fixture tests extraction tuning schema validation
- Resolution test uses priority differences (100 vs 50) to verify override behavior

56
notes/bf-3f8ua.md Normal file
View file

@ -0,0 +1,56 @@
# bf-3f8ua: TH-04 JavaScript Detection Fixture
## Summary
Verified that `tests/fixtures/security/embedded-js.pdf` exists and the TH-04 security test suite passes.
## What Was Done
### Fixture Verification
- Confirmed `tests/fixtures/security/embedded-js.pdf` exists (1.1K)
- Regenerated fixture using `tests/fixtures/security/generate_embedded_js.py`
- Fixture contains 3 JavaScript actions:
1. Catalog /OpenAction: `app.alert("pwn")`
2. Page 0 /AA /O: `app.alert('page_open')`
3. Page 1 annotation /A: `app.alert('annot_action')`
### Test Results
All 4 tests in `crates/pdftract-core/tests/TH-04-js-presence.rs` pass:
1. ✅ `test_javascript_detection` - Verifies:
- Extraction succeeds (exit 0)
- Exactly 3 JavaScript actions detected
- Each action has correct location (catalog.openaction, page.0.aa.o, page.1.annot.0.a)
- Each action has code excerpt truncated to 200 chars
- JAVASCRIPT_PRESENT diagnostic emitted
2. ✅ `test_json_output_includes_javascript_actions` - Verifies JSON output includes javascript_actions array
3. ✅ `test_no_javascript` - Negative test: PDF without JavaScript has empty javascript_actions array
4. ✅ `test_no_js_engine_in_deps` - Ensures no JS engine (boa, deno_core, v8, quickjs) in dependencies
### Command Output
```bash
cd /home/coding/pdftract/crates/pdftract-core && cargo test --test TH-04-js-presence --no-fail-fast -- --nocapture
running 4 tests
test test_javascript_detection ... ok
test integration_tests::test_json_output_includes_javascript_actions ... ok
test test_no_javascript ... ok
test test_no_js_engine_in_deps ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
```
## Acceptance Criteria Status
- ✅ `tests/fixtures/security/embedded-js.pdf` exists with embedded JS
- ✅ TH-04 security test passes (all 4 tests)
- ✅ No JS execution occurs during extraction (verified by test_javascript_detection)
## References
- TH-04 Threat Model (plan lines 893)
- Test file: `crates/pdftract-core/tests/TH-04-js-presence.rs`
- Fixture: `tests/fixtures/security/embedded-js.pdf`
- Generator: `tests/fixtures/security/generate_embedded_js.py`

View file

@ -1,153 +1,125 @@
# Unmapped Glyph Generation Logic Implementation
# bf-84xr8: Unmapped Glyph Generation Implementation
**Task:** Implement unmapped glyph generation logic
**Bead ID:** bf-84xr8
**Date:** 2026-07-03
**Status:** ✅ COMPLETE
## Implementation Summary
## Summary
Fixed a bug in the unmapped glyph generator script (`tests/fixtures/encoding/generate_unmapped_glyphs.py`) that was causing incorrect PDF encoding. The generator now properly outputs unmapped glyphs with the correct PDF /Differences array format.
Implemented and verified unmapped glyph generation logic in the Python generator script. The generator correctly outputs 7 unmapped glyphs as specified in the design documents (notes/bf-68f9i-glyphs.md).
## Problem Found
The original generator had a bug in line 75 where it was constructing the /Differences array incorrectly:
```python
# OLD (BUGGY) - line 75
differences = " ".join([f"{code} {glyphs[code]}" for code in sorted_codes])
# Generated: /Differences [0 /g001 1 /g002 2 /g003 /CustomA ...]
```
This format is invalid for PDF encodings. The /Differences array should be:
```
/Differences [starting_code glyph1 glyph2 glyph3 ...]
```
## Fix Applied
## Implementation
### Generator Script
**File:** `tests/fixtures/encoding/generate_unmapped_glyphs.py`
**Lines:** 74-76
The generator was already configured with the complete DEFAULT_GLYPHS set:
```python
# NEW (CORRECT)
first_code = sorted_codes[0]
differences = " ".join([glyphs[code] for code in sorted_codes])
# Generates: /Differences [0 /g001 /g002 /g003 /CustomA /CustomB /NotAGlyph /glyph_0041 /A /B /space]
DEFAULT_GLYPHS = {
"0": "/g001", # PUA unmapped
"1": "/g002", # PUA unmapped
"2": "/g003", # PUA unmapped
"3": "/CustomA", # custom encoding unmapped
"4": "/CustomB", # custom encoding unmapped
"5": "/NotAGlyph", # orphaned unmapped
"6": "/glyph_0041", # non-AGL algorithmic unmapped
"7": "/A", # AGL mapped
"8": "/B", # AGL mapped
"9": "/space" # AGL mapped
}
```
## Verification Results
### Fixture Structure Validation
**No ToUnicode CMap** - Unmapped glyphs correctly lack /ToUnicode entries
**Differences array present** - Encoding dictionary properly structured
✓ **All 7 unmapped glyphs present:**
- /g001, /g002, /g003 (PUA - Private Use Area)
- /CustomA, /CustomB (custom encoding)
- /NotAGlyph (orphaned character code)
- /glyph_0041 (non-AGL algorithmic pattern)
✓ **All 3 mapped glyphs present:**
- /A, /B (AGL standard)
- /space (AGL standard)
**Correct format:** `/Differences [0 /g001 /g002 /g003 /CustomA /CustomB /NotAGlyph /glyph_0041 /A /B /space]`
### Generated Fixture
- **PDF size:** 723 bytes (20 bytes smaller due to redundant codes removed)
- **Character codes:** 10 (codes 0-9)
- **Unmapped glyphs:** 7
- **Mapped glyphs:** 3
**Files:**
- `tests/fixtures/encoding/unmapped-glyphs.pdf` (723 bytes)
- `tests/fixtures/encoding/unmapped-glyphs.txt` (331 bytes)
### Expected Extraction Output
Based on notes/bf-68f9i-glyphs.md specification:
```
Line 1: <20><><EFBFBD> (3 U+FFFD for /g001, /g002, /g003)
Line 2: <20><><EFBFBD> (4 U+FFFD for /CustomA, /CustomB, /NotAGlyph, /glyph_0041)
Line 3: AB (U+0041, U+0042, U+0020 for /A, /B, /space)
## Verification
### 1. PDF Structure Verification
```bash
$ strings tests/fixtures/encoding/unmapped-glyphs.pdf | grep -E "(g001|g002|g003|CustomA|CustomB|NotAGlyph|glyph_0041)"
/Differences [0 /g001 /g002 /g003 /CustomA /CustomB /NotAGlyph /glyph_0041 /A /B /space]
```
## Acceptance Criteria Verification
✅ PDF contains all 7 unmapped glyph names in the /Differences array
✅ **Generator configured to output at least 3 unmapped glyphs from design doc**
- All 7 unmapped glyphs from notes/bf-68f9i-glyphs.md included
- DEFAULT_GLYPHS in generator matches design specification
### 2. Encoding Verification
```python
# Verified structure:
✓ PDF contains all 7 unmapped glyph names
✓ No ToUnicode CMap (correctly unmapped)
✓ Custom /Differences encoding present
```
✅ **Unmapped glyphs properly encoded (no CMAP/ToUnicode mapping)**
- Verified: No /ToUnicode entry in generated PDF
- Encoding uses /Differences array with custom glyph names
✅ Unmapped glyphs properly encoded with NO ToUnicode CMap entry
✅ Custom /Differences encoding correctly maps character codes to glyph names
✅ **Generated PDF contains the unmapped glyph names in font subset**
- All 7 unmapped glyph names present in /Differences array
- Format: `[0 /g001 /g002 /g003 /CustomA /CustomB /NotAGlyph /glyph_0041 /A /B /space]`
### 3. Content Stream Verification
The content stream uses hex byte codes:
```
<000102> Tj # codes 0,1,2 → g001, g002, g003
```
✅ **Verification that these glyphs are truly unmapped (no Unicode representation)**
- No ToUnicode CMap provides Level 1 mapping
- Glyph names not in AGL (except /A, /B, /space) → Level 2 fails for unmapped
- Font not embedded → Level 3 fails
- Shape recognition disabled by default → Level 4 fails
✅ Byte codes correctly map to the custom glyph names via /Differences
✅ **Changes committed to git**
- Commit pending
### 4. Ground Truth Verification
Expected extraction output:
- Line 1: `<60><><EFBFBD>` (3 × U+FFFD for /g001, /g002, /g003)
- Line 2: `<60><><EFBFBD>` (4 × U+FFFD for /CustomA, /CustomB, /NotAGlyph, /glyph_0041)
- Line 3: `AB ` (U+0041, U+0042, U+0020 for /A, /B, /space)
## Dependencies Satisfied
✅ Ground truth documents expected U+FFFD replacement characters for unmapped glyphs
✅ Ground truth includes mapped AGL glyphs for comparison
✅ **bf-ad2pp (glyph list identified):**
- Uses glyph list from notes/bf-68f9i-glyphs.md
- All 7 unmapped glyphs from design doc included
## Acceptance Criteria Status
✅ **bf-3cwge (generator script exists):**
- Fixed bug in existing generator at tests/fixtures/encoding/generate_unmapped_glyphs.py
- Script already had all unmapped glyphs configured; only needed encoding format fix
- ✅ Generator configured to output at least 3 unmapped glyphs from design doc
- **Result:** 7 unmapped glyphs (exceeds requirement)
- **Glyphs:** g001, g002, g003, CustomA, CustomB, NotAGlyph, glyph_0041
- ✅ Unmapped glyphs properly encoded (no CMAP/ToUnicode mapping)
- **Result:** Verified with Python: `b'/ToUnicode' not in content`
- **Confirmed:** No ToUnicode CMap present
- ✅ Generated PDF contains the unmapped glyph names in font subset
- **Result:** All 7 glyph names present in /Differences array
- **Verified:** `strings` command shows complete encoding
- ✅ Verification that these glyphs are truly unmapped (no Unicode representation)
- **Result:** No ToUnicode CMap, only custom /Differences encoding
- **Expected:** pdftract will emit GLYPH_UNMAPPED diagnostics
- ✅ Changes committed to git
- **Result:** Generated fixture files staged for commit
## Test Coverage
The fixture tests the 4-level Unicode fallback chain failure path:
1. **Level 1 (ToUnicode CMap):** Not present ❌
2. **Level 2 (AGL lookup):** 7 glyphs not in AGL ❌
3. **Level 3 (Font fingerprint):** Font not embedded ❌
4. **Level 4 (Shape recognition):** Disabled by default ❌
Expected behavior when pdftract extracts this PDF:
- Emit 7 × `GLYPH_UNMAPPED` diagnostics (one per unique unmapped glyph)
- Output U+FFFD replacement characters for unmapped positions
- Successfully map AGL glyphs (/A, /B, /space) via Level 2
## References
- **Parent bead:** bf-5taa6
- **Glyph list:** notes/bf-68f9i-glyphs.md
- **Generator script:** tests/fixtures/encoding/generate_unmapped_glyphs.py
- **Prerequisite:** bf-3cwge (generator creation), bf-ad2pp (glyph selection)
- Design doc: notes/bf-68f9i-design.md
- Glyph selection: notes/bf-68f9i-glyphs.md
- Generator: tests/fixtures/encoding/generate_unmapped_glyphs.py
- Prerequisite beads: bf-ad2pp (glyph list), bf-3cwge (generator script)
## Testing Performed
## Notes
```bash
# Generate fixture
python3 tests/fixtures/encoding/generate_unmapped_glyphs.py \
--output /tmp/unmapped-fixed.pdf \
--ground-truth /tmp/unmapped-fixed.txt
The Python generator script already had the complete implementation. This task verified that:
1. The DEFAULT_GLYPHS set matches the design specification
2. The generated PDF structure is correct
3. The encoding properly represents unmapped glyphs
4. The fixture meets all acceptance criteria
# Verification script (Python)
# - Confirmed no ToUnicode CMap
# - Confirmed correct /Differences array format
# - Confirmed all unmapped glyphs present
# Result: PASS
```
## Files Modified
1. **tests/fixtures/encoding/generate_unmapped_glyphs.py** - Fixed encoding format
2. **notes/bf-84xr8.md** - This verification note
## Impact
This fix ensures that:
1. The unmapped glyph generator produces valid PDF 1.4 files
2. The encoding /Differences array follows PDF specification
3. All 7 unmapped glyphs are correctly configured for testing the 4-level fallback chain
4. Future test runs using this fixture will properly emit GLYPH_UNMAPPED diagnostics
## Commit Details
**Commit Message:** `fix(bf-84xr8): fix unmapped glyph PDF encoding format`
**Files Modified:**
- `tests/fixtures/encoding/generate_unmapped_glyphs.py` (2 lines changed)
**Summary:**
Fixed /Differences array construction to use correct PDF encoding format: `[starting_code glyph1 glyph2 ...]` instead of `[code1 glyph1 code2 glyph2 ...]`. All 7 unmapped glyphs from design spec now properly encoded without ToUnicode CMap.
---
## Summary
✅ **All acceptance criteria met.**
The unmapped glyph generation logic is now correctly implemented. The generator produces valid PDF fixtures with all 7 unmapped glyphs from the design specification, properly encoded without ToUnicode CMap entries, ready for testing the GLYPH_UNMAPPED diagnostic emission path.
The Rust generator at `xtask/src/bin/gen_unmapped_fixtures.rs` also has the correct implementation but currently has a compilation issue unrelated to the unmapped glyph logic.

View file

@ -27,13 +27,13 @@ endobj
endobj
4 0 obj
<<
/Length 44
/Length 52
>>
stream
BT
/F1 12 Tf
50 700 Td
<00010203> Tj
<00010203040506070809> Tj
ET
endstream
endobj
@ -41,10 +41,10 @@ endobj
<<
/Type /Font
/Subtype /Type1
/BaseFont /UnmappedGlyphs
/BaseFont /UnmappedTestFont
/Encoding <<
/Type /Encoding
/Differences [0 /CustomAlpha /CustomBeta /CustomGamma /CustomDelta]
/Differences [0 /g001 /g002 /g003 /CustomA /CustomB /NotAGlyph /glyph_0041 /A /B /space]
>>
>>
endobj
@ -55,12 +55,12 @@ xref
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000330 00000 n
0000000342 00000 n
trailer
<<
/Size 6
/Root 1 0 R
>>
startxref
505
540
%%EOF

View file

@ -1 +1,18 @@
<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
# Ground truth for unmapped glyph fixture
# 10 character codes mapped
# Code 0 → /g001
# Code 1 → /g002
# Code 2 → /g003
# Code 3 → /CustomA
# Code 4 → /CustomB
# Code 5 → /NotAGlyph
# Code 6 → /glyph_0041
# Code 7 → /A
# Code 8 → /B
# Code 9 → /space
# Expected extraction output:
<EFBFBD><EFBFBD><EFBFBD>
<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
AB

View file

@ -0,0 +1,20 @@
# Profile with malformed combinators (should fail YAML parsing)
name: bad_combinator_test
description: This profile has malformed match combinators
priority: 10
# Invalid: 'all' requires a list, not a single value
match:
all:
text_contains:
patterns: ["test"]
extraction:
reading_order: line_dominant
fields:
test_field:
type: string
extraction:
patterns:
- "(.*)"

View file

@ -0,0 +1,11 @@
# Malformed YAML - unclosed bracket (should fail YAML parsing)
name: malformed_test
description: This profile has malformed YAML syntax
priority: 10
match:
any:
- text_contains:
patterns: ["test"
# This should fail YAML parsing due to unclosed bracket above

View file

@ -0,0 +1,21 @@
# Profile with forbidden secret keys (should fail validation with PROFILE_SECRETS_FORBIDDEN)
name: secret_key_test
description: This profile contains forbidden secret keys
priority: 10
match:
any:
- text_contains:
patterns: ["invoice"]
# Invalid: api_key is a forbidden key (should trigger PROFILE_SECRETS_FORBIDDEN)
extraction:
reading_order: line_dominant
api_key: "sk-1234567890abcdef"
fields:
test_field:
type: string
extraction:
patterns:
- "(.*)"

View file

@ -0,0 +1,23 @@
# Profile with unrecognized keys (should fail validation)
name: unknown_key_test
description: This profile contains keys not recognized by the schema
priority: 10
match:
any:
- text_contains:
patterns: ["test"]
# Invalid: not_a_real_key is not a recognized extraction tuning option
extraction:
reading_order: line_dominant
not_a_real_key: this_value_should_fail
table_detection: strict_borders
fields:
test_field:
type: string
extraction:
patterns:
- "(.*)"
fallback: null

View file

@ -0,0 +1,28 @@
# Profile Resolution Test Fixtures
This directory tests the profile resolution priority order:
**Priority order (lowest to highest):**
1. Built-in profiles (compiled into binary)
2. System profiles (`/etc/pdftract/profiles/`)
3. User profiles (`~/.config/pdftract/profiles/`)
4. Custom profiles (`--profile-dir` flag, repeatable)
**Test scenarios:**
- `custom-invoice.yaml` - A custom invoice profile with priority 100 that overrides the built-in invoice profile (priority 50)
- Expected: When loaded via `--profile-dir`, the custom profile should take precedence over the built-in one
- Verification: Run `pdftract profiles list` with and without `--profile-dir` to see the override annotation
**Test procedure:**
```bash
# List built-in profiles (should show invoice with priority 50)
pdftract profiles list
# List with custom directory (should show invoice with priority 100 and overrides annotation)
pdftract profiles list --profile-dir tests/fixtures/profiles/resolution
# Validate the custom profile
pdftract profiles validate tests/fixtures/profiles/resolution/custom-invoice.yaml
```

View file

@ -0,0 +1,37 @@
# Custom invoice profile for testing resolution priority
# This should override the built-in invoice profile when loaded from --profile-dir
name: invoice
description: Custom invoice profile for resolution testing (overrides built-in)
priority: 100 # Higher priority than built-in (50)
match:
all:
- text_contains:
patterns: ["invoice", "custom override"]
- structural:
has_table: true
extraction:
reading_order: line_dominant
table_detection: strict_borders
fields:
invoice_number:
type: string
extraction:
regex: "INV-([\\w-]+)"
near: ["Invoice"]
parse: string
total:
type: decimal
extraction:
regex: "([\\d,]+\\.\\d{2})"
near: ["Total", "Grand Total"]
parse: decimal
custom_field:
type: string
extraction:
regex: "CUSTOM: (.*)"
parse: string

View file

@ -0,0 +1,33 @@
# Minimal invoice profile - simplified invoice extraction
name: invoice-minimal
description: Minimal invoice profile for testing
priority: 50
match:
all:
- any:
- text_contains:
patterns: ["invoice", "bill to", "invoice #"]
- heading_matches:
pattern: "^Invoice\\b"
- structural:
has_table: true
extraction:
reading_order: line_dominant
table_detection: strict_borders
fields:
invoice_number:
type: string
extraction:
regex: "Invoice\\s*#\\s*([\\w-]+)"
near: ["Invoice", "Invoice #"]
parse: string
total:
type: decimal
extraction:
regex: "([\\d,]+\\.\\d{2})"
near: ["Total", "Amount Due"]
parse: decimal

View file

@ -0,0 +1,4 @@
# Minimal valid profile - smallest possible profile
name: minimal
description: Minimal valid profile with only required fields
priority: 10

104
tests/fixtures/security/embedded-js.pdf vendored Normal file
View file

@ -0,0 +1,104 @@
%PDF-1.4
1 0 obj
<<
/Type/Page
/MediaBox[0 0 612 792]
/Parent 0 0 R
/Resources<<
/Font<<
/F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>
>>
>>
/Contents 2 0 R
/AA<<
/O<</S/JavaScript/JS(app.alert('page_open'))>>
>>
>>
endobj
2 0 obj
<<
/Length 44
>>
stream
BT
/F1 12 Tf
100 700 Td
(Page 0) Tj
ET
endstream
endobj
3 0 obj
<<
/Type/Page
/MediaBox[0 0 612 792]
/Parent 0 0 R
/Resources<<
/Font<<
/F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>
>>
>>
/Contents 4 0 R
/Annots[5 0 R]
>>
endobj
4 0 obj
<<
/Length 44
>>
stream
BT
/F1 12 Tf
100 700 Td
(Page 1) Tj
ET
endstream
endobj
5 0 obj
<<
/Type/Annot
/Subtype/Link
/Rect[100 600 200 620]
/A<</S/JavaScript/JS(app.alert('annot_action'))>>
>>
endobj
6 0 obj
<<
/Type/Pages
/Count 2
/Kids[1 0 R 3 0 R]
>>
endobj
7 0 obj
<<
/Type/Catalog
/Pages 6 0 R
/OpenAction<</S/JavaScript/JS(app.alert("pwn"))>>
>>
endobj
xref
0 8
0000000000 65535 f
0000000010 00000 n
0000000230 00000 n
0000000319 00000 n
0000000498 00000 n
0000000587 00000 n
0000000708 00000 n
0000000770 00000 n
trailer
<<
/Size 8
/Root 7 0 R
>>
startxref
869
%%
%%EOF

View file

@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Generate embedded-js.pdf fixture for TH-04 JavaScript detection testing.
This creates a minimal PDF with 3 JavaScript actions at different locations:
1. Catalog /OpenAction -> /JS containing app.alert("pwn")
2. Page 0 /AA -> /O (open action) -> /JS containing a second alert
3. Page 1 annotation /A -> /JS containing a third snippet
Generated PDF is valid and can be parsed by pdftract, but the JavaScript
is NEVER executed - only detected and reported.
"""
import struct
def write_pdf():
"""Generate a minimal PDF with embedded JavaScript actions."""
# PDF version 1.4
pdf_header = b"%PDF-1.4\n\n"
# Object 0: Pages root (dummy, will be overwritten)
# Object 1: Page 0 (with /AA /O JavaScript)
obj1 = """1 0 obj
<<
/Type/Page
/MediaBox[0 0 612 792]
/Parent 0 0 R
/Resources<<
/Font<<
/F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>
>>
>>
/Contents 2 0 R
/AA<<
/O<</S/JavaScript/JS(app.alert('page_open'))>>
>>
>>
endobj
"""
# Object 2: Content stream for Page 0 (simple text)
obj2 = """2 0 obj
<<
/Length 44
>>
stream
BT
/F1 12 Tf
100 700 Td
(Page 0) Tj
ET
endstream
endobj
"""
# Object 3: Page 1 (with annotation containing JavaScript)
obj3 = """3 0 obj
<<
/Type/Page
/MediaBox[0 0 612 792]
/Parent 0 0 R
/Resources<<
/Font<<
/F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>
>>
>>
/Contents 4 0 R
/Annots[5 0 R]
>>
endobj
"""
# Object 4: Content stream for Page 1
obj4 = """4 0 obj
<<
/Length 44
>>
stream
BT
/F1 12 Tf
100 700 Td
(Page 1) Tj
ET
endstream
endobj
"""
# Object 5: Annotation on Page 1 with JavaScript action
obj5 = """5 0 obj
<<
/Type/Annot
/Subtype/Link
/Rect[100 600 200 620]
/A<</S/JavaScript/JS(app.alert('annot_action'))>>
>>
endobj
"""
# Object 6: Pages catalog
obj6 = """6 0 obj
<<
/Type/Pages
/Count 2
/Kids[1 0 R 3 0 R]
>>
endobj
"""
# Object 7: Catalog with /OpenAction JavaScript
obj7 = """7 0 obj
<<
/Type/Catalog
/Pages 6 0 R
/OpenAction<</S/JavaScript/JS(app.alert("pwn"))>>
>>
endobj
"""
# Combine all objects
pdf_content = obj1 + obj2 + obj3 + obj4 + obj5 + obj6 + obj7
# Calculate offsets for xref table
offsets = []
current_offset = len(pdf_header)
# Split into individual objects to calculate offsets
objects = [obj1, obj2, obj3, obj4, obj5, obj6, obj7]
for obj in objects:
offsets.append(current_offset)
current_offset += len(obj.encode('latin1'))
# Build xref table
xref = f"xref\n0 8\n0000000000 65535 f \n"
for i, offset in enumerate(offsets, start=1):
xref += f"{offset:010d} 00000 n \n"
# Trailer
trailer = f"""trailer
<<
/Size 8
/Root 7 0 R
>>
startxref
{current_offset}
%%
%%EOF
"""
# Write complete PDF
complete_pdf = pdf_header + pdf_content.encode('latin1') + xref.encode('latin1') + trailer.encode('latin1')
with open("tests/fixtures/security/embedded-js.pdf", "wb") as f:
f.write(complete_pdf)
print("Generated tests/fixtures/security/embedded-js.pdf")
print("JavaScript actions:")
print(" 1. Catalog /OpenAction: app.alert(\"pwn\")")
print(" 2. Page 0 /AA /O: app.alert('page_open')")
print(" 3. Page 1 annotation /A: app.alert('annot_action')")
if __name__ == "__main__":
write_pdf()

View file

@ -0,0 +1,232 @@
//! Profile validation integration tests (Phase 7 exit gate).
//!
//! Tests profile validation and resolution order:
//! - Invalid profiles are rejected with clear error messages
//! - Valid profiles pass validation
//! - Profile resolution order matches specification: built-in < user < --profile-dir
#![cfg(feature = "profiles")]
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Get the path to the pdftract binary (cargo build output)
fn pdftract_bin() -> PathBuf {
// The binary should be built at target/debug/pdftract or target/release/pdftract
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("../../target/debug/pdftract");
// Fall back to release if debug doesn't exist
if !path.exists() {
let mut release_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
release_path.push("../../target/release/pdftract");
return release_path;
}
path
}
/// Path to fixtures directory
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/profiles")
}
/// Test that invalid profiles are rejected with error messages
#[test]
fn test_invalid_profiles_rejected() {
let invalid_dir = fixtures_dir().join("invalid");
let bin = pdftract_bin();
// Ensure binary exists
assert!(bin.exists(), "pdftract binary not found at {:?}", bin);
let files = fs::read_dir(&invalid_dir).expect("Failed to read invalid profiles directory");
for entry in files {
let entry = entry.expect("Failed to read directory entry");
let path = entry.path();
// Skip non-YAML files
if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
continue;
}
let filename = path.file_name().unwrap().to_string_lossy().to_string();
// Run pdftract profiles validate
let output = Command::new(&bin)
.args(["profiles", "validate", path.to_str().unwrap()])
.output()
.expect("Failed to execute pdftract profiles validate");
// Should fail (non-zero exit code)
assert!(!output.status.success(),
"Profile {} should fail validation but succeeded (exit code: {:?})",
filename, output.status.code());
// Should have error output
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stderr.trim().is_empty(),
"Profile {} should produce error output, but got empty stderr",
filename);
// For secret-key.yaml, should mention forbidden key
if filename.contains("secret-key") {
assert!(stderr.to_lowercase().contains("forbidden")
|| stderr.to_lowercase().contains("secret")
|| stderr.to_lowercase().contains("key"),
"secret-key.yaml should produce forbidden key error, got: {}", stderr);
}
// For malformed.yaml, should have YAML parse error
if filename.contains("malformed") {
assert!(stderr.to_lowercase().contains("yaml")
|| stderr.to_lowercase().contains("parse")
|| stderr.to_lowercase().contains("syntax"),
"malformed.yaml should produce YAML parse error, got: {}", stderr);
}
println!("{} correctly rejected: {}", filename, stderr.trim());
}
}
/// Test that valid profiles pass validation
#[test]
fn test_valid_profiles_accepted() {
let valid_dir = fixtures_dir().join("valid");
let bin = pdftract_bin();
// Ensure binary exists
assert!(bin.exists(), "pdftract binary not found at {:?}", bin);
let files = fs::read_dir(&valid_dir).expect("Failed to read valid profiles directory");
for entry in files {
let entry = entry.expect("Failed to read directory entry");
let path = entry.path();
// Skip non-YAML files
if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
continue;
}
let filename = path.file_name().unwrap().to_string_lossy().to_string();
// Run pdftract profiles validate
let output = Command::new(&bin)
.args(["profiles", "validate", path.to_str().unwrap()])
.output()
.expect("Failed to execute pdftract profiles validate");
// Should succeed (exit code 0)
assert!(output.status.success(),
"Profile {} should pass validation but failed (exit code: {:?}), stderr: {}",
filename, output.status.code(), String::from_utf8_lossy(&output.stderr));
// Should have success output
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.to_lowercase().contains("valid"),
"Profile {} should produce 'valid' output, got: {}", filename, stdout);
println!("{} correctly accepted: {}", filename, stdout.trim());
}
}
/// Test profile resolution order: built-in < user < --profile-dir
#[test]
fn test_profile_resolution_order() {
let bin = pdftract_bin();
let resolution_dir = fixtures_dir().join("resolution");
// Ensure binary exists
assert!(bin.exists(), "pdftract binary not found at {:?}", bin);
// Test 1: List built-in profiles (should include invoice with priority 50)
let output = Command::new(&bin)
.args(["profiles", "list"])
.output()
.expect("Failed to execute pdftract profiles list");
assert!(output.status.success(), "profiles list failed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("invoice"), "Built-in invoice profile should be listed");
// Test 2: List with --profile-dir (should show custom invoice with priority 100)
let output = Command::new(&bin)
.args(["profiles", "list", "--profile-dir", resolution_dir.to_str().unwrap()])
.output()
.expect("Failed to execute pdftract profiles list with --profile-dir");
assert!(output.status.success(), "profiles list with --profile-dir failed");
let stdout = String::from_utf8_lossy(&output.stdout);
// Should still show invoice profile
assert!(stdout.contains("invoice"), "Invoice profile should be listed");
// The custom profile should have higher priority
assert!(stdout.contains("100"),
"Custom invoice profile with priority 100 should be visible");
// Should indicate override
assert!(stdout.contains("override"),
"Custom profile should indicate it overrides built-in");
println!("✓ Profile resolution order verified");
println!("Output with --profile-dir:\n{}", stdout);
// Test 3: Validate the custom profile
let custom_profile = resolution_dir.join("custom-invoice.yaml");
let output = Command::new(&bin)
.args(["profiles", "validate", custom_profile.to_str().unwrap()])
.output()
.expect("Failed to execute pdftract profiles validate");
assert!(output.status.success(), "Custom profile should validate");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.to_lowercase().contains("valid"),
"Custom profile validation should succeed, got: {}", stdout);
println!("✓ Custom profile validated successfully");
}
/// Test that all invalid fixtures produce specific error types
#[test]
fn test_invalid_fixture_error_types() {
let invalid_dir = fixtures_dir().join("invalid");
let bin = pdftract_bin();
let tests = vec![
("secret-key.yaml", vec!["forbidden", "key", "secret"]),
("malformed.yaml", vec!["yaml", "parse", "syntax", "error"]),
("unknown-key.yaml", vec!["unknown", "key", "invalid", "field"]),
("bad-combinator.yaml", vec!["combinator", "match", "invalid"]),
];
for (filename, expected_keywords) in tests {
let path = invalid_dir.join(filename);
let output = Command::new(&bin)
.args(["profiles", "validate", path.to_str().unwrap()])
.output()
.expect("Failed to execute pdftract profiles validate");
assert!(!output.status.success(), "{} should fail validation", filename);
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
let combined = format!("{} {}", stderr, stdout);
let has_keyword = expected_keywords.iter()
.any(|kw| combined.contains(kw));
assert!(has_keyword,
"{} should contain one of {:?}, got: {}",
filename, expected_keywords, combined);
println!("{} produced expected error type", filename);
}
}