diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59391b3b..071076ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,9 @@ name: CI on: push: branches: [main] - pull_request: - branches: [main] + # Every pull request target, including immutable stack branches, receives the + # same exact-head, synthetic-merge, and buyer-readiness acceptance evidence. + pull_request: {} permissions: contents: read @@ -17,14 +18,87 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact checked-out revision + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - name: Use preinstalled Temurin JDK 21 # Uses the runner image's bundled JDK instead of actions/setup-java to # keep every workflow dependency hash-pinned (Scorecard Pinned-Dependencies). run: | echo "JAVA_HOME=$JAVA_HOME_21_X64" >> "$GITHUB_ENV" echo "$JAVA_HOME_21_X64/bin" >> "$GITHUB_PATH" - - name: Run tests - run: mvn -B --no-transfer-progress test + - name: Run tests and coverage acceptance gates + shell: bash + run: | + if ! mvn -B --no-transfer-progress verify; then + if [[ -f target/site/jacoco/jacoco.csv ]]; then + echo "::group::JaCoCo CSV diagnostics" + cat target/site/jacoco/jacoco.csv + echo "::endgroup::" + fi + if [[ -f target/site/jacoco/jacoco.xml ]]; then + echo "::group::JaCoCo uncovered line diagnostics" + python3 - <<'PY' + import xml.etree.ElementTree as ET + from pathlib import Path + + report = Path("target/site/jacoco/jacoco.xml") + root = ET.parse(report).getroot() + gaps = [] + for package in root.findall("package"): + package_name = package.get("name", "") + for source_file in package.findall("sourcefile"): + source_name = source_file.get("name", "") + source_path = f"{package_name}/{source_name}" if package_name else source_name + for line in source_file.findall("line"): + missed_instructions = int(line.get("mi", "0")) + missed_branches = int(line.get("mb", "0")) + if missed_instructions or missed_branches: + gaps.append( + ( + source_path, + int(line.get("nr", "0")), + missed_instructions, + missed_branches, + ) + ) + + for source_path, line_number, missed_instructions, missed_branches in gaps: + print( + f"{source_path}:{line_number}: " + f"missed_instructions={missed_instructions} " + f"missed_branches={missed_branches}" + ) + PY + echo "::endgroup::" + fi + exit 1 + fi + python3 scripts/verify_maven_test_reports.py + + merge-compatibility: + name: Maven merge compatibility + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + - name: Verify merge revision + env: + EXPECTED_SHA: ${{ github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Use preinstalled Temurin JDK 21 + run: | + echo "JAVA_HOME=$JAVA_HOME_21_X64" >> "$GITHUB_ENV" + echo "$JAVA_HOME_21_X64/bin" >> "$GITHUB_PATH" + - name: Verify merged result + run: | + mvn -B --no-transfer-progress verify + python3 scripts/verify_maven_test_reports.py script-checks: name: Buyer-readiness script tests @@ -33,6 +107,11 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact checked-out revision + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index c2178971..ea6ae6eb 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -45,6 +45,14 @@ jobs: - TenantClaimsFuzzTest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Verify exact checked-out revision + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - name: Set up JDK 21 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 diff --git a/AGENTS.md b/AGENTS.md index ae0a7b55..2434ab8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,19 @@ including mandatory quality and security merge gates. ## Mandatory merge gates -- `mvn -DskipTests compile` must pass with warning/deprecated budget = 0. -- `mvn test` must pass. -- JaCoCo coverage for production package must remain 100% line/branch. -- JavaDoc gate must pass (`mvn -q -DskipTests javadoc:javadoc`) with no warnings/errors. +- `mvn -B --no-transfer-progress verify` is the authoritative local and CI + acceptance command. Do not substitute `compile`, `test`, or a predecessor + head result for this exact-head lifecycle. +- Java 21 compilation must pass with warning and deprecation budget = 0. +- Every test must pass with zero failures, errors, and skips. +- JaCoCo coverage for the `com.clearfolio.viewer.*` production package must + remain 100% statement/line and branch coverage, expressed as zero missed + production lines and branches. +- The verify lifecycle must generate public Javadocs with Maven Javadoc Plugin + 3.12.0, `doclint=all`, `failOnError=true`, and `failOnWarnings=true`. Public + record components, constructors, methods, enum values, fields, parameters, + return values, and thrown failures must be understandable without reading the + implementation. - Markdown lint for changed docs must pass. - Security evidence must be attached on PR (SAST/code-scanning checks). - CodeQL Java/Kotlin analysis must remain enabled through repository default @@ -31,10 +40,15 @@ including mandatory quality and security merge gates. `python3 scripts/summarize_buyer_readiness.py --manifest docs/diligence/2026-07-03-buyer-data-room-manifest.json --output docs/diligence/2026-07-03-buyer-readiness-scorecard.md --summary docs/qa/evidence/2026-07-02-krw2b-sale-readiness/buyer-readiness-scorecard-summary.json --check`. - Figma Slides generation payload check must pass: `python3 scripts/check_figma_deck_payload.py --payload docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json --summary docs/qa/evidence/2026-07-02-krw2b-sale-readiness/figma-deck-payload-check.json`. -- `mvn test` includes `DependencyPolicyTest`, which prevents reintroducing the - broad `tika-parsers-standard-package`, default Logback starter, or excluded - Jakarta annotation dependency unless a future PR updates the license policy, - SBOM evidence, attribution package, and buyer diligence docs together. +- `mvn verify` includes `DependencyPolicyTest`, which prevents reintroducing the + broad `tika-parsers-standard-package`, default Logback starter, excluded + Jakarta annotation dependency, an unreviewed Netty version, or a weakened + public-Javadoc gate unless a future PR updates the corresponding security, + license, SBOM, attribution, acceptance, and buyer-diligence evidence together. +- CI, Security Scan, SAST Semgrep, every fuzz target, required organization + reviews, and branch protection must all pass on the exact current PR head. + Queued, pending, cancelled, skipped-required, stale-head, or predecessor-head + evidence is not passing. ## Change management rule @@ -58,7 +72,7 @@ Codex, Cursor, opencode, …) working in this repo. then **remediate**: - This is a Maven / Spring Boot app — findings are almost always vulnerable Java dependencies. Fix by bumping the offending artifact (or its managed - version) in `pom.xml`; re-run `mvn -DskipTests compile` and `mvn test`. + version) in `pom.xml`; re-run `mvn -B --no-transfer-progress verify`. - There is currently no `Dockerfile` or k8s manifest here; if one is added, trivy will also flag image/IaC misconfigs — fix those at the source. - For a genuine false positive only, add a narrow, **documented** @@ -108,11 +122,11 @@ Codex, Cursor, opencode, …) working in this repo. DOM-decomposes emails and files into a persisted knowledge graph. Each component is a standalone program that must ALSO work as a git submodule of the hub, grown separately and together. -- Sibling components: **waf-ids-ai-soc** (WAF / IDS / AI SOC / LB / APIM), +- Sibling components: **wardnet** (WAF / IDS / AI SOC / LB / APIM), **pg-erd-cloud** (ERD tool), **contextual-orchestrator** (LLM cost/perf/upstream-LB gateway, beyond LiteLLM), **codec-carver** (STT / omni-modal speech-video codec), **fast-mlsirm** (LLM-as-a-Judge calibration + - evaluation-item quality, using aFIPC FIPC + kaefa item-fit), **feelanet-adfs** + evaluation-item quality, using aFIPC FIPC + kaefa item-fit), **keyverse** (passwordless SSO — OIDC/SCIM/ADFS/LDAP/FIDO2/OAuth2.1, eliminate passwords), **newsdom-api** (PDF→DOM sidecar), and **semantic-data-portal** (upper ontology / catalog / governance plane with its own graph engine). diff --git a/CHANGELOG.md b/CHANGELOG.md index 373a661a..1187deb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,27 +1,47 @@ -## [Unreleased] -### Added -- **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다. - -### Changed -- PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. - # Changelog ## [Unreleased] -### 추가된 기능 (Added) +### Added + +- **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다. - **관리자용 단건 작업 삭제 및 재시도 API 추가** - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. - - **비동기 버튼 로딩 피드백 및 상태 복원 개선** - - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..." 이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. + - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..."이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. +### Changed + +- PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. +- CI가 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하며, 합성 merge revision은 별도 호환성 작업에서 검증하도록 분리했습니다. +- Maven `verify` 단계에서 JaCoCo production line 및 branch missed count가 각각 0인지 강제하고, 실패 시 누락 위치 진단을 출력하도록 했습니다. +- Maven `verify` 이후 Surefire 보고서가 존재하고 실행 테스트 수가 1개 이상이며 skipped·failure·error 수가 모두 0인지 검증합니다. Failsafe 보고서가 생성된 경우 동일한 규칙을 적용하며, 보고서 누락·손상·음수 카운트·전체 skip·실패 결과는 exact-head CI와 merge-compatibility 모두에서 fail closed 처리합니다. +- Maven `verify` 단계에서 Java 21 public Javadocs를 `doclint=all`로 생성하고 warning 또는 error가 하나라도 발생하면 실패하도록 했습니다. 공개 record 구성요소, 생성자, enum 값, 필드와 매개변수 문서를 초보자도 코드 분석 없이 이해할 수 있는 수준으로 보완했습니다. +- Jazzer fuzzing도 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하도록 강화했습니다. +- CycloneDX Maven Plugin 2.9.1의 정확한 `outputFormat`/`outputName` 사용자 속성으로 생성한 61개 구성요소 SBOM과 제3자 고지문을 buyer evidence에 반영했습니다. 생성 source head, UTC 시각, artifact/archive/SBOM/attribution 해시, 17개 Netty 구성요소의 purl·bom-ref·dependency-edge 정합성, 로컬 생성 증거와 공유 가능한 데이터룸 증거의 경계를 ADR 및 실행 가능한 drift test로 고정했습니다. + +### Security + +- `GET /api/v1/convert/jobs/{jobId}/download`가 리소스 조회 전에 전용 `artifact:read` 권한을 검증하고, PDF 저장소 접근 전에 작업의 tenant 소유권을 확인하도록 강화했습니다. `job:read`만으로는 문서 바이트를 읽을 수 없으며, 인증 누락·권한 누락·교차 tenant UUID 접근은 각각 fail closed 처리되고 교차 tenant 요청은 리소스 존재를 숨기는 `404`를 반환합니다. +- Maven XML 테스트 보고서 검증기는 각 `testsuite`의 `tests`, `skipped`, `failures`, `errors` 속성을 모두 필수 증거로 요구합니다. 누락된 결과 수를 암묵적으로 0으로 간주하지 않고 fail closed 처리하며, 각 속성 누락 회귀 테스트를 추가했습니다. +- Maven XML 테스트 보고서 검증기는 UTF-8만 허용하고 UTF-8 BOM은 수용하며, NUL 바이트·DTD·엔터티 선언을 파싱 전에 거부합니다. UTF-16 같은 대체 인코딩으로 위험 선언을 바이트 검사에서 숨기는 우회와 외부 엔터티 읽기·엔터티 확장형 서비스 거부를 회귀 테스트로 차단했습니다. +- Maven XML 테스트 보고서 검증기는 파일당 16 MiB 상한을 적용하고 한 번의 제한된 읽기로 실제 입력 크기를 검증합니다. 테스트 코드가 보고서 파일을 교체하거나 확장해도 크기 사전검사와 파싱 사이의 경쟁 조건을 이용할 수 없습니다. +- Spring Boot 3.5.16이 관리하던 Netty `4.1.135.Final` 전이 의존성 전체를 Spring Boot의 공식 `netty.version` 속성을 통해 `4.1.136.Final`로 정렬했습니다. 실제 POM을 읽는 회귀 테스트와 보안 ADR을 추가해 개별 Netty 모듈의 혼합 버전 및 향후 무의식적 downgrade를 차단했습니다. +- 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 정책 재정의 서명이 비활성화된 경우에만 전용 키 부재를 비상관 `unavailable` 표식으로 표현하며, 원문이나 비키 해시로 폴백하지 않습니다. +- 정책 재정의 서명 키를 활성화하면서 전용 감사 가명화 키를 누락하면 Spring 시작과 `DefaultDocumentValidationService`의 독립·모듈식 직접 생성을 모두 거부하도록 강화했습니다. 관리자 예외를 승인하면서 승인자별 상관 가능한 감사 증거를 남기지 못하는 구성을 모든 실행 모드에서 fail closed로 차단하고, 두 키의 최소 강도와 용도 분리를 유지합니다. +- 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. +- 경로·쿼리 파라미터 타입 변환 실패 응답에서 사용자가 제출한 거부 값을 고정된 `[redacted]` 표식으로 대체해 오류 응답을 통한 개인정보·비밀값 반사를 차단했습니다. 값이 실제로 없었던 경우에만 `null` 진단을 유지합니다. + +### Fixed + +- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정했습니다. ## [0.1.0] - 2026-06-25 ### 추가된 기능 (Added) + - **비동기 버튼 로딩 상태 UX 개선 (Async Button Loading States)** - 문서 제출(`submitDocument`), 데모 데이터 로드(`loadDemoData`), 실패 작업 재시도(`retryActiveJob`) 등 비동기 요청을 수행하는 버튼들에 대해 처리 중 명시적인 로딩 상태(Loading, Submitting, Retrying 등)를 추가했습니다. - 사용자의 중복 클릭을 방지하기 위해 작업 중에는 버튼이 비활성화되도록 수정했습니다. @@ -37,9 +57,11 @@ - 관련 `AdminJobListResponse` DTO 모델과 이를 처리하는 Repository 및 Service 계층의 `findAll`/`getAllJobs` 메서드를 추가했습니다. ### 테스트 커버리지 (Tests) + - 신규 구현된 Repository, Service, Controller 계층에 대한 유닛 테스트(Unit Tests)를 작성하여 JaCoCo 기준 라인 및 브랜치 커버리지 100%를 달성했습니다. ### 보안 (Security) + - **의존성 취약점 일괄 정리 (trivy-fs / osv-scan 대응)**: Spring Boot 부모 POM을 `3.5.0`에서 `3.5.16`으로 올려 Spring Framework, Netty, Reactor Netty, logback 관련 다수의 HIGH/MEDIUM 권고를 해소했습니다. - Jackson 계열을 `jackson-bom` import로 `2.22.1`에 고정하여 jackson-databind case-insensitive deserialization bypass 권고(GHSA-5jmj-h7xm-6q6v / CVE-2026-54515)를 제거했습니다. - Apache Tika 표준 파서를 통해 유입되던 전이 의존성을 `dependencyManagement`로 고정했습니다: junrar `7.6.0`(경로 순회 RCE/파일 쓰기), commons-io `2.20.0`(XmlStreamReader DoS), commons-lang3 `3.18.0`, BouncyCastle `bcprov-jdk18on 1.84` 및 `bcpkix-jdk18on 1.84`(CRITICAL/Medium). 전체 347개 테스트 통과를 확인했습니다. @@ -48,6 +70,3 @@ - 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다. - logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다. - 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다. - -### Fixed -- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 diff --git a/docs/diagrams/submit-flow.md b/docs/diagrams/submit-flow.md index 7b51e3cd..bccde0f3 100644 --- a/docs/diagrams/submit-flow.md +++ b/docs/diagrams/submit-flow.md @@ -64,7 +64,7 @@ sequenceDiagram V->>P: getBlockedExtensions() alt Override headers valid V-->>V: validate override=true + token + approver - V-->>V: emit audit-safe log(extension, approver, tokenFingerprint) + V-->>V: emit audit-safe log(extension, approverFingerprint, tokenFingerprint) V-->>Svc: validation ok else Override missing/invalid V-->>Svc: UnsupportedDocumentFormatException or IllegalArgumentException @@ -94,6 +94,8 @@ sequenceDiagram end ``` +`approverFingerprint` is the versioned, domain-separated keyed audit pseudonym. The raw approver identifier is accepted only as validation input and is never emitted by the audit-safe log. + ## Exception paths covered - Missing or empty file diff --git a/docs/diagrams/submit-policy-adapter-flow.md b/docs/diagrams/submit-policy-adapter-flow.md index bc65dfe7..18cca561 100644 --- a/docs/diagrams/submit-policy-adapter-flow.md +++ b/docs/diagrams/submit-policy-adapter-flow.md @@ -29,7 +29,7 @@ sequenceDiagram EH-->>C: 400 UNSUPPORTED_FORMAT else extension blocked and override=true alt token/approver valid - Val-->>Val: audit-safe log(extension, approverId, tokenFingerprint) + Val-->>Val: audit-safe log(extension, approverFingerprint, tokenFingerprint) Val-->>Svc: validation ok Svc->>Repo: findOrStoreByContentHash(job) Svc->>W: enqueue(jobId) when created @@ -60,6 +60,8 @@ sequenceDiagram end ``` +`approverFingerprint` is the versioned, domain-separated keyed audit pseudonym; the raw approver identifier is never written to the audit-safe log. + ## Deterministic adapter baseline - `pdf -> PDF_JS` diff --git a/docs/engineering/acceptance-criteria.md b/docs/engineering/acceptance-criteria.md index b0befcee..4b1a3261 100644 --- a/docs/engineering/acceptance-criteria.md +++ b/docs/engineering/acceptance-criteria.md @@ -1,8 +1,11 @@ # Engineering Acceptance Criteria -Last updated: 2026-02-21 +Last updated: 2026-08-09 -This document is the canonical acceptance policy for the current Clearfolio Viewer delivery baseline. +This document is the canonical acceptance policy for the current Clearfolio +Viewer delivery baseline. Historical evidence snapshots remain useful for +provenance, but the required source of truth is the exact pull-request head and +its protected GitHub Checks. ## Mandatory AC list (exact) @@ -14,50 +17,185 @@ This document is the canonical acceptance policy for the current Clearfolio View 6. deprecated 0 7. 1-day schedule+security verification +The labels above are stable governance identifiers. Their executable meanings +are defined by the fail-closed gates below; changing a label requires an ADR and +a coordinated update to `AGENTS.md`, `CLAUDE.md`, and both architecture maps. + ## Runtime stance -- Non-blocking web runtime is implemented with WebFlux (`spring-boot-starter-webflux`) in current code. -- Servlet/MVC runtime is not the selected implementation for this repository baseline. +- Non-blocking web runtime is implemented with WebFlux + (`spring-boot-starter-webflux`). +- Servlet/MVC runtime is not the selected implementation for this repository + baseline. +- Document conversion remains asynchronous; HTTP request handlers submit work + and expose status, retry, viewer, and artifact workflows rather than waiting + for conversion completion. ## Delivery context chain - `Clearfolio Viewer <-> internal WAS -> Azure On-premise Gateway -> Power Platform -> mobile/tablet` -- Current implementation in this repo covers the Clearfolio Viewer side of the contract and state gating. +- This repository owns the Clearfolio Viewer side of the contract and its state, + authorization, document, artifact, and operational gates. + +## Required local acceptance commands + +```bash +mvn -B --no-transfer-progress verify +python3 scripts/verify_maven_test_reports.py +``` + +The commands are intentionally shared with CI. A contributor must not substitute +`mvn test`, skip the documentation execution, disable JaCoCo, lower a threshold, +suppress warnings, omit test-report verification, or present evidence containing +skipped or zero executed tests. + +The report gate requires at least one Surefire `TEST-*.xml` report, a positive +total test count, zero skipped tests, zero failures, and zero errors. Every +`testsuite` element must explicitly provide non-negative integer `tests`, +`skipped`, `failures`, and `errors` attributes; an omitted outcome count is +incomplete evidence and fails closed rather than being inferred as zero. When +Failsafe `TEST-*.xml` reports are present, the same rules apply. Missing, +malformed, empty, negative-count, skipped, failing, or error-bearing report +evidence fails closed even when a preceding Maven process returned success. +Each XML report must be UTF-8, may include a UTF-8 byte-order mark, is limited to +16 MiB, and is rejected before parsing when it contains a NUL byte, DTD, or +entity declaration. This prevents alternate encodings from hiding +external-entity or expansion payloads from the pre-parse checks, even when test +code can write report files. ## Mandatory AC evidence mapping -| AC | Gate check | Repro command | Evidence pointers | -| --- | --- | --- | --- | -| coverage | JaCoCo line/branch miss = 0 | `mvn -q -Djacoco.includes=com.clearfolio.viewer.* org.jacoco:jacoco-maven-plugin:0.8.13:prepare-agent test org.jacoco:jacoco-maven-plugin:0.8.13:report` | `docs/qa/evidence/2026-02-21-ac-gates/jacoco.csv` | -| docstring | JavaDoc warnings/errors = none | `mvn -q -DskipTests javadoc:javadoc` | `docs/qa/evidence/2026-02-21-ac-gates/javadoc.log`, `docs/qa/evidence/2026-02-21-ac-gates/javadoc-status.txt` | -| non-blocking web | Request path does not run conversion inline | N/A (code-path verification) | `src/main/java/com/clearfolio/viewer/controller/ConversionController.java`, `src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java` | -| lightweight queue | Bounded queue + retry + dead-letter behavior | N/A (code-path verification) | `src/main/java/com/clearfolio/viewer/config/ConversionExecutorConfig.java`, `src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java` | -| warning 0 | Compile path warning-free (`-Werror`) | `mvn -q -DskipTests compile` | `docs/qa/evidence/2026-02-21-ac-gates/compile.log` | -| deprecated 0 | Deprecated usage blocked by warning gate | `mvn -q -DskipTests compile` | `docs/qa/evidence/2026-02-21-ac-gates/compile.log` | -| 1-day schedule+security verification | Delivery plan and security checks completed | `semgrep --config auto --metrics=off --error --json --output docs/qa/evidence//semgrep.json src/main/java` and GitHub API checks in plan | `docs/plans/2026-02-20-24h-customer-delivery-plan.md`, `docs/qa/evidence/2026-02-21-ac-gates/semgrep.json`, `docs/qa/evidence/2026-02-21-ac-gates/gh-code-scanning-alerts-open.json` | +| AC | Fail-closed gate | Reproduction and evidence | +| --- | --- | --- | +| coverage | JaCoCo 0.8.15 applies bundle-level `LINE` and `BRANCH` `MISSEDCOUNT` limits with a maximum of `0` | `mvn -B --no-transfer-progress verify`; inspect `target/site/jacoco/jacoco.csv` and the exact-head CI `Maven test` job | +| docstring | Maven Javadoc Plugin 3.12.0 runs Java 21 doclint for public production APIs and fails on warnings or errors | `mvn -B --no-transfer-progress verify`; inspect `target/reports/apidocs` and the exact-head CI `Maven test` job | +| non-blocking web | Request paths do not execute document conversion inline | `ConversionController`, `DefaultDocumentConversionService`, and their concurrency/integration tests | +| lightweight queue | Capacity, rejection, retry, processing lease, and dead-letter behavior are executable contracts | `ConversionExecutorConfig`, `DefaultConversionWorker`, repository/state-store tests, and exact-head fuzzing | +| warning 0 | Java compilation uses `-Xlint:all -Werror`; Maven report acceptance rejects skipped and zero-test evidence | `mvn -B --no-transfer-progress verify`, `python3 scripts/verify_maven_test_reports.py`, and the exact-head CI `Maven test` job | +| deprecated 0 | Deprecated API warnings are build failures | `mvn -B --no-transfer-progress verify` in the exact-head CI `Maven test` job | +| 1-day schedule+security verification | Required GitHub Checks must be successful for the exact current head; queued, pending, cancelled, stale-head, or skipped-required outcomes are not passing | Delivery-plan evidence plus the job-scoped exact-head evidence contract below | + +## Exact-head GitHub evidence authority + +The acceptance record is job-scoped. A green workflow name without the relevant +job identity and revision proof is insufficient. + +- **CI / Maven test** — for pull requests, `actions/checkout` must use + `${{ github.event.pull_request.head.sha }}` (or the workflow's equivalent + exact-source expression), and `Verify exact checked-out revision` must prove + `git rev-parse HEAD` equals that source head. The same job executes + `mvn -B --no-transfer-progress verify` and then + `python3 scripts/verify_maven_test_reports.py`. This is the authoritative + source-head build, test, coverage, Javadoc, warning, deprecated-API and test- + report evidence. +- **CI / Maven merge compatibility** — `actions/checkout` must use + `${{ github.sha }}` for the pull-request synthetic merge revision and the job + must prove `git rev-parse HEAD` equals that value before running Maven verify + and test-report validation. Synthetic-merge success demonstrates integration + compatibility only; it never substitutes for source-head evidence. +- **CI / Buyer-readiness script tests** — checkout and explicit revision proof + must bind the script-policy tests to the exact source head before executing + the repository's script-test suite. +- **Security Scan and SAST Semgrep** — the accepted workflow runs must be + associated with the same exact source-head SHA being considered for merge. + A successful run from a predecessor head, synthetic merge only, or another + ref is stale evidence. Job/check conclusions must be complete and successful. +- **fuzz** — every configured matrix target is independent evidence. For the + current workflow this means `ArtifactTokenParserFuzzTest`, + `DocumentValidationFuzzTest`, and `TenantClaimsFuzzTest`; each target checks + out and explicitly verifies the same source-head SHA. One successful matrix + target cannot stand in for a missing, cancelled, skipped, or failed sibling. +- **automated review** — CodeRabbit, OpenCode/Noema, GHAS and other review or + security evidence must identify or be demonstrably bound to the same source + head. Comment/status-only evidence is not a counted independent approval. +- **independent approval** — the formal GitHub review submission must come from + an eligible non-author reviewer under the live repository/ruleset policy and + apply to the unchanged head. A predecessor-head approval, author review, + model verdict, check status, or advisory comment does not count. +- **branch protection / ruleset** — evaluate the live required-check and review + policy against the unchanged expected head immediately before merge. A + historical PR `base.sha` is not the current protected base-ref tip. + +The merge record therefore keeps `source_head_sha`, the PR's historical base +snapshot when useful for provenance, the independently resolved live base tip, +workflow/run identity, job identity, and review identity as separate evidence. +No single green badge collapses those authorities. + +## Methodological rationale for evidence gates + +The exact coverage threshold is a deliberate structural invariant, not a claim +that code coverage alone establishes test effectiveness. Inozemtseva and Holmes +(2014) found that, after controlling for test-suite size, coverage was not +strongly correlated with test-suite effectiveness. Clearfolio therefore keeps +100% owned production line/branch coverage as a fail-closed completeness floor +**and separately requires domain-valid security, lifecycle, concurrency, +fidelity, accessibility, crash/restart, migration/rollback, and release +assertions**. A change must not satisfy the policy by adding execution without a +meaningful behavioral oracle. + +Likewise, a test process returning exit code zero is not sufficient evidence +when report generation, test discovery, skipping, or oracle quality can fail +independently. Barr et al. (2015) describe the software-testing oracle problem: +determining whether observed output is correct is itself a central testing +problem. Clearfolio's report verifier therefore checks that tests actually ran, +that outcome counters are explicit, and that no skipped/failing/error result is +silently promoted to success. These literature references explain the evidence +model; the executable Maven/JUnit/JaCoCo contracts remain the repository's +normative merge gates. + +## Evidence boundaries + +- Local output is diagnostic evidence only. Merge evidence must identify the + exact commit SHA and protected GitHub workflow runs/jobs for that SHA. +- A successful earlier head does not validate a later head. +- Generated reports containing local paths or internal runtime details remain + local unless an explicit privacy and disclosure review approves publication. +- Historical snapshots under `docs/qa/evidence/` must not be described as the + current gate after code, dependencies, tests, or workflows change. ## Optional tracks -- client DB pooler -- PostgreSQL 17 +- client DB pooler; +- PostgreSQL 17. -## DB and queue operating policy (future persistent DB phase) +## Database and queue operating policy for a future persistent phase -- Queue requests should not wait for completion in request path; use status polling/callback pattern. -- Keep DB transactions short; avoid external network calls inside transactions. -- Use timeout/retry and `SKIP LOCKED` for lock-contention-sensitive worker loops. -- Read routing uses provided read-only endpoint/DSN; lock-sensitive or strongly consistent flows stay on primary. -- Pooler detection is best-effort (`SHOW VERSION;` in `pgbouncer`/`pgcat` management DB), fallback state is `unknown`. +- Queue requests must not wait for completion in the request path; use status + polling, callbacks, or an equivalent durable asynchronous contract. +- Keep database transactions short and exclude external network calls from + transaction scope. +- Use bounded timeouts, bounded retries, and `SKIP LOCKED` for + lock-contention-sensitive worker loops. +- Read routing may use a provided read-only endpoint; lock-sensitive or strongly + consistent flows remain on the primary. +- Pooler detection is best-effort (`SHOW VERSION;` in a `pgbouncer` or `pgcat` + management database); the fallback state is `unknown`. +- New database objects must use at least two descriptive words and snake_case by + default. ## Architecture linkage -- Root architecture map: `ARCHITECTURE.md` (updated 2026-02-21). +- Root architecture map: `ARCHITECTURE.md`. - Detailed architecture: `docs/architecture.md`. -## File-level documentation evidence +## References + +Apache Software Foundation. (2026). *Apache Maven Javadoc Plugin 3.12.0: +`javadoc:javadoc`*. Retrieved August 5, 2026, from +https://maven.apache.org/plugins/maven-javadoc-plugin/javadoc-mojo.html + +Apache Software Foundation. (2026). *Surefire reports*. Maven Surefire Plugin. +Retrieved August 6, 2026, from +https://maven.apache.org/surefire/maven-surefire-plugin/examples/reporting.html + +Barr, E. T., Harman, M., McMinn, P., Shahbaz, M., & Yoo, S. (2015). The oracle +problem in software testing: A survey. *IEEE Transactions on Software +Engineering, 41*(5), 507–525. https://doi.org/10.1109/TSE.2014.2372785 + +Inozemtseva, L., & Holmes, R. (2014). Coverage is not strongly correlated with +test suite effectiveness. In *Proceedings of the 36th International Conference +on Software Engineering* (pp. 435–445). Association for Computing Machinery. +https://doi.org/10.1145/2568225.2568271 -| File | Change(add/edit/delete/move) | Intent(의도) | Why(이유) | Risk/Notes | -|---|---|---|---|---| -| `docs/engineering/acceptance-criteria.md` | add | Canonicalize mandatory AC policy and evidence map | Prevent drift across PRD/TRD/plan docs | Keep run-id pointers current when evidence folder rotates | -| `docs/qa/acceptance_evidence_checklist.md` | edit (existing baseline) | Reusable detailed checklist | Preserve command-level reproducibility | Must stay aligned with AGENTS.md gates | -| `docs/qa/evidence/LATEST.md` | edit (existing baseline) | Latest evidence entrypoint | Fast operator lookup | Snapshot only, not historical trend | +JaCoCo. (2026). *JaCoCo Maven plug-in: `jacoco:check`*. Retrieved August 5, +2026, from https://www.jacoco.org/jacoco/trunk/doc/check-mojo.html diff --git a/docs/legal/2026-07-03-third-party-attribution.md b/docs/legal/2026-07-03-third-party-attribution.md index 4be3aeb5..7d691a02 100644 --- a/docs/legal/2026-07-03-third-party-attribution.md +++ b/docs/legal/2026-07-03-third-party-attribution.md @@ -20,26 +20,26 @@ CycloneDX SBOM. It is engineering evidence, not legal advice. | com.fasterxml.jackson.datatype:jackson-datatype-jsr310 | 2.22.1 | Apache-2.0 | `pkg:maven/com.fasterxml.jackson.datatype/jackson-datatype-jsr310@2.22.1?type=jar` | | com.fasterxml.jackson.module:jackson-module-parameter-names | 2.22.1 | Apache-2.0 | `pkg:maven/com.fasterxml.jackson.module/jackson-module-parameter-names@2.22.1?type=jar` | | com.fasterxml:classmate | 1.7.3 | Apache-2.0 | `pkg:maven/com.fasterxml/classmate@1.7.3?type=jar` | -| commons-logging:commons-logging | 1.3.3 | Apache-2.0 | `pkg:maven/commons-logging/commons-logging@1.3.3?type=jar` | +| commons-logging:commons-logging | 1.4.0 | Apache-2.0 | `pkg:maven/commons-logging/commons-logging@1.4.0?type=jar` | | io.micrometer:micrometer-commons | 1.15.12 | Apache-2.0 | `pkg:maven/io.micrometer/micrometer-commons@1.15.12?type=jar` | | io.micrometer:micrometer-observation | 1.15.12 | Apache-2.0 | `pkg:maven/io.micrometer/micrometer-observation@1.15.12?type=jar` | -| io.netty:netty-buffer | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar` | -| io.netty:netty-codec | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar` | -| io.netty:netty-codec-dns | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-dns@4.1.135.Final?type=jar` | -| io.netty:netty-codec-http | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar` | -| io.netty:netty-codec-http2 | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-http2@4.1.135.Final?type=jar` | -| io.netty:netty-codec-socks | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-socks@4.1.135.Final?type=jar` | -| io.netty:netty-common | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar` | -| io.netty:netty-handler | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar` | -| io.netty:netty-handler-proxy | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-handler-proxy@4.1.135.Final?type=jar` | -| io.netty:netty-resolver | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar` | -| io.netty:netty-resolver-dns | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar` | -| io.netty:netty-resolver-dns-classes-macos | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.135.Final?type=jar` | -| io.netty:netty-resolver-dns-native-macos | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar` | -| io.netty:netty-transport | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar` | -| io.netty:netty-transport-classes-epoll | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-classes-epoll@4.1.135.Final?type=jar` | -| io.netty:netty-transport-native-epoll | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar` | -| io.netty:netty-transport-native-unix-common | 4.1.135.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar` | +| io.netty:netty-buffer | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar` | +| io.netty:netty-codec | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar` | +| io.netty:netty-codec-dns | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-dns@4.1.136.Final?type=jar` | +| io.netty:netty-codec-http | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar` | +| io.netty:netty-codec-http2 | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-http2@4.1.136.Final?type=jar` | +| io.netty:netty-codec-socks | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-codec-socks@4.1.136.Final?type=jar` | +| io.netty:netty-common | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar` | +| io.netty:netty-handler | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar` | +| io.netty:netty-handler-proxy | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-handler-proxy@4.1.136.Final?type=jar` | +| io.netty:netty-resolver | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar` | +| io.netty:netty-resolver-dns | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar` | +| io.netty:netty-resolver-dns-classes-macos | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.136.Final?type=jar` | +| io.netty:netty-resolver-dns-native-macos | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar` | +| io.netty:netty-transport | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar` | +| io.netty:netty-transport-classes-epoll | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-classes-epoll@4.1.136.Final?type=jar` | +| io.netty:netty-transport-native-epoll | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar` | +| io.netty:netty-transport-native-unix-common | 4.1.136.Final | Apache-2.0 | `pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar` | | io.projectreactor.netty:reactor-netty-core | 1.2.18 | Apache-2.0 | `pkg:maven/io.projectreactor.netty/reactor-netty-core@1.2.18?type=jar` | | io.projectreactor.netty:reactor-netty-http | 1.2.18 | Apache-2.0 | `pkg:maven/io.projectreactor.netty/reactor-netty-http@1.2.18?type=jar` | | io.projectreactor:reactor-core | 3.7.19 | Apache-2.0 | `pkg:maven/io.projectreactor/reactor-core@3.7.19?type=jar` | @@ -48,9 +48,9 @@ CycloneDX SBOM. It is engineering evidence, not legal advice. | org.apache.logging.log4j:log4j-core | 2.25.4 | Apache-2.0 | `pkg:maven/org.apache.logging.log4j/log4j-core@2.25.4?type=jar` | | org.apache.logging.log4j:log4j-jul | 2.25.4 | Apache-2.0 | `pkg:maven/org.apache.logging.log4j/log4j-jul@2.25.4?type=jar` | | org.apache.logging.log4j:log4j-slf4j2-impl | 2.25.4 | Apache-2.0 | `pkg:maven/org.apache.logging.log4j/log4j-slf4j2-impl@2.25.4?type=jar` | -| org.apache.pdfbox:fontbox | 3.0.3 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/fontbox@3.0.3?type=jar` | -| org.apache.pdfbox:pdfbox | 3.0.3 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar` | -| org.apache.pdfbox:pdfbox-io | 3.0.3 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar` | +| org.apache.pdfbox:fontbox | 3.0.8 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/fontbox@3.0.8?type=jar` | +| org.apache.pdfbox:pdfbox | 3.0.8 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/pdfbox@3.0.8?type=jar` | +| org.apache.pdfbox:pdfbox-io | 3.0.8 | Apache-2.0 | `pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar` | | org.apache.tomcat.embed:tomcat-embed-el | 10.1.55 | Apache-2.0 | `pkg:maven/org.apache.tomcat.embed/tomcat-embed-el@10.1.55?type=jar` | | org.hibernate.validator:hibernate-validator | 8.0.3.Final | Apache-2.0 | `pkg:maven/org.hibernate.validator/hibernate-validator@8.0.3.Final?type=jar` | | org.jboss.logging:jboss-logging | 3.6.3.Final | Apache-2.0 | `pkg:maven/org.jboss.logging/jboss-logging@3.6.3.Final?type=jar` | @@ -72,7 +72,7 @@ CycloneDX SBOM. It is engineering evidence, not legal advice. | org.springframework:spring-jcl | 6.2.19 | Apache-2.0 | `pkg:maven/org.springframework/spring-jcl@6.2.19?type=jar` | | org.springframework:spring-web | 6.2.19 | Apache-2.0 | `pkg:maven/org.springframework/spring-web@6.2.19?type=jar` | | org.springframework:spring-webflux | 6.2.19 | Apache-2.0 | `pkg:maven/org.springframework/spring-webflux@6.2.19?type=jar` | -| org.webjars.npm:pdfjs-dist | 6.0.227 | Apache-2.0 | `pkg:maven/org.webjars.npm/pdfjs-dist@6.0.227?type=jar` | +| org.webjars.npm:pdfjs-dist | 6.1.200 | Apache-2.0 | `pkg:maven/org.webjars.npm/pdfjs-dist@6.1.200?type=jar` | | org.yaml:snakeyaml | 2.4 | Apache-2.0 | `pkg:maven/org.yaml/snakeyaml@2.4?type=jar` | ## Release Note diff --git a/docs/prd/clearfolio-viewer-unified-document-preview-prd.md b/docs/prd/clearfolio-viewer-unified-document-preview-prd.md index d9fe8f4d..b4b8bc76 100644 --- a/docs/prd/clearfolio-viewer-unified-document-preview-prd.md +++ b/docs/prd/clearfolio-viewer-unified-document-preview-prd.md @@ -1,7 +1,7 @@ # PRD: Clearfolio Viewer Unified Document Preview (Internal) Date: 2026-02-23 -Last updated: 2026-02-23 +Last updated: 2026-08-05 Owner: Product Manager Sources: `docs/architecture.md`, `docs/trd-integrated-document-viewer-platform.md`, `docs/prd-integrated-document-viewer-platform.md`, `docs/engineering/acceptance-criteria.md`, `docs/workflow/one-day-delivery-plan.md`, `docs/diagrams/*`, `AGENTS.md` @@ -196,7 +196,7 @@ Minimum claims/scopes (MVP intent): - preview session creation - viewer access (success/fail) - blocked-format attempts - - exception lane approvals (including approver id, token fingerprint, and rationale id if available) + - exception lane approvals (including `approverFingerprint`, token fingerprint, and rationale id if available); the raw approver identifier is never logged - operator-triggered retries ### 10.4 Browser security headers / CSP @@ -306,4 +306,4 @@ Minimum one-day deliverables: - Risk: Office formats (`docx`/`pptx`/`xlsx`) preview quality depends on converter availability; failures could impact perceived “unified” promise if not clearly messaged. - Risk: Gateway-induced header/proxy limitations can constrain token propagation; mitigation is short-lived viewer session tokens and minimized header set. - Risk: Strict no-warnings/no-deprecations gates can slow dependency upgrades; mitigate with explicit upgrade windows and pre-merge checks. -- Risk: Exception lane governance (who can approve, how approvals are issued) can expand scope; mitigate by treating policy token issuance as external and logging only fingerprint + approver id. +- Risk: Exception lane governance (who can approve, how approvals are issued) can expand scope; mitigate by treating policy token issuance as external and logging only the token fingerprint and `approverFingerprint`; the raw approver identifier remains validation input only and is never logged. diff --git a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md index d8f2b78b..a071f400 100644 --- a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md +++ b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/README.md @@ -1,40 +1,49 @@ # KRW 2B Sale-Readiness Evidence -Date: 2026-07-02 -Verification source head SHA before this evidence refresh: -`7df3ac8b8253cd1a445ba7faddbf99bc9a5c5fcd` +Original evidence date: 2026-07-02 +Original verification source head: `7df3ac8b8253cd1a445ba7faddbf99bc9a5c5fcd` +Latest dependency-evidence refresh: 2026-08-05 +Netty SBOM generation source head: `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` + +## Evidence Boundary + +This directory combines a historical sale-readiness snapshot with selected generated artifacts that remain under executable drift contracts. A `Pass` result describes the named artifact and its source revision; it is not automatically transferable to a later source head. + +The committed CycloneDX JSON and generated third-party attribution are shareable buyer data-room evidence. GitHub Actions logs and the one-day generation artifact are transient provenance. Any dependency change must regenerate the SBOM, attribution, hashes, and exact-head acceptance evidence before release. ## Gate Summary | Gate | Result | Evidence | | --- | --- | --- | -| Java runtime | Pass, Java 26.0.1 runtime with Java 21 release-target compile | `java-version.txt`, `compile.log` | -| Compile warnings/deprecations | Pass | `compile.log` | -| Tests + JaCoCo | Pass, 357 tests, `classes=49`, `line_missed=0`, `branch_missed=0` | `mvn-test.log`, `test-jacoco.log`, `jacoco.csv`, `jacoco-status.txt` | -| JavaDoc | Pass, `javadoc_warnings_or_errors=none` | `javadoc.log`, `javadoc-status.txt` | -| Markdown lint | Pass, 0 errors across changed docs | `markdownlint.log` | -| JS syntax | Pass | `node-check.log` | -| SAST | Pass, 0 findings | `semgrep.log`, `semgrep.json` | -| SBOM | Pass, CycloneDX 1.6, 61 components, 0 components without license metadata | `sbom-cyclonedx.log`, `sbom-cyclonedx.json`, `sbom-status.txt` | -| License review | Pass, buyer-release policy checker reports 61 allowed components, 0 review-required components, 0 unlisted violations, and passes `--require-no-review` | `docs/security/2026-07-02-license-allowlist-review.md`, `license-policy-summary.json`, `license-policy-test.log` | -| Third-party attribution | Pass, generated buyer data-room attribution contains all 61 current SBOM components and passes drift check | `docs/legal/2026-07-03-third-party-attribution.md`, `third-party-attribution-check.log` | -| Buyer data-room manifest | Pass, manifest references required buyer evidence artifacts, all local paths exist, and ready gates reference only ready artifacts | `docs/diligence/2026-07-03-buyer-data-room-manifest.json`, `buyer-dataroom-manifest-check.log` | -| Buyer readiness scorecard | Pass, generated scorecard reports 23 artifacts, 8 readiness gates, 38 percent conservative gate readiness, and ready-gate evidence integrity pass from the current data-room manifest | `docs/diligence/2026-07-03-buyer-readiness-scorecard.md`, `buyer-readiness-scorecard-summary.json` | -| Figma Slides generation payload | Pass, payload check reports 11 slides, 4 objectives, and 0 errors; actual Slides generation still requires Figma team or organization plan selection | `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json`, `figma-deck-payload-check.json` | -| Auth/tenant, signed artifacts, and KPI snapshots | Partial, runtime tenant enforcement, optional gateway HMAC tenant-claim validation, production-profile fail-closed startup without signed tenant secret, signed artifact tokens, token revocation, artifact read audit API, optional file-backed artifact-link ledger replay, optional file-backed KPI snapshot ledger replay, and tenant-scoped KPI snapshot export API implemented; OIDC/JWT and centralized durable revocation/audit/analytics persistence pending | `docs/security/2026-07-02-auth-tenant-model.md`, `docs/security/2026-07-02-signed-artifact-link-design.md`, auth/artifact/analytics tests | -| Buyer deployment integration | Pass for buyer sandbox scope; `buyer-demo` Spring profile, gateway-signed header contract, connector API table, OpenAPI connector seed, smoke path, and cutover gates are documented; buyer tenant import and production OIDC/JWT profile remain follow-up | `src/main/resources/application-buyer-demo.yml`, `docs/deployment/2026-07-02-buyer-deployment-integration-playbook.md`, `docs/deployment/clearfolio-buyer-connector.openapi.yaml` | -| Durable job repository design, state-store, lifecycle event, and recovery sweep slice | Partial, code boundary implemented; `ConversionJobStateStore` routes worker success/failure and operator retry transitions, `ConversionJobLifecycleEvent` records process-local append-only transition evidence, and `DefaultConversionWorker` now re-enqueues due submitted jobs plus stale processing leases from available repository state, while SQL persistence remains pending for true process-restart durability | `docs/persistence/2026-07-02-durable-conversion-job-repository-plan.md`, state-store, lifecycle event, and recovery sweep tests | -| Seeded buyer-demo screenshots | Pass for local screenshot scope; seeded desktop and mobile viewports render after `Load demo story`, with no mobile horizontal overflow and uploaded FigJam screenshot nodes `25:1423` and `25:1422` | `seeded-demo-story-verification.md`, `screenshots/seeded-demo-desktop-viewport.png`, `screenshots/seeded-demo-mobile-viewport.png` | -| Buyer diligence closure map | Pass for FigJam handoff scope; added `Clearfolio KRW 2B Buyer Diligence Closure Map`, `Clearfolio Buyer Readiness Scorecard Gate Map`, and `Clearfolio Buyer Diligence Slides Storyboard` on the existing evidence board, and captured Slides generation prerequisites plus deck outline | `docs/design/2026-07-03-buyer-diligence-slides-and-closure-map.md`, `docs/design/2026-07-02-buyer-demo-kpi-figjam-handoff.md` | -| Local smoke | Pass, signed tenant claims plus file-backed artifact/KPI ledgers, KPI snapshot export API, buyer-demo KPI evidence panel, and operator recovery evidence panel | `smoke-local.txt`, `smoke-app.log`, `smoke-ui-root.txt` | -| GitHub PR state | Seeded buyer-demo story branch is refreshed on current `main`; review and queued checks are not treated as blockers | PR body and GitHub UI | +| Java runtime | Pass for original snapshot, Java 26.0.1 runtime with Java 21 release-target compile | `java-version.txt`, `compile.log` | +| Compile warnings/deprecations | Pass for original snapshot | `compile.log` | +| Tests + JaCoCo | Pass for original snapshot, 357 tests, `classes=49`, `line_missed=0`, `branch_missed=0` | `mvn-test.log`, `test-jacoco.log`, `jacoco.csv`, `jacoco-status.txt` | +| JavaDoc | Pass for original snapshot, `javadoc_warnings_or_errors=none` | `javadoc.log`, `javadoc-status.txt` | +| Markdown lint | Pass for original snapshot, 0 errors across changed docs | `markdownlint.log` | +| JS syntax | Pass for original snapshot | `node-check.log` | +| SAST | Pass for original snapshot, 0 findings | `semgrep.log`, `semgrep.json` | +| SBOM | Refreshed 2026-08-05, CycloneDX 1.6, 61 components, 17 Netty components at `4.1.136.Final`, 0 components without license metadata | `sbom-cyclonedx.json`, Netty ADR, permanent drift test | +| License review | Pass for current 61-component generated SBOM; 0 review-required and 0 unlisted violations under buyer-release policy | `docs/security/2026-07-02-license-allowlist-review.md`, `license-policy-summary.json`, `license-policy-test.log` | +| Third-party attribution | Refreshed from the same generated SBOM and protected by byte-for-byte renderer drift validation | `docs/legal/2026-07-03-third-party-attribution.md`, `scripts/test_render_third_party_attribution.py` | +| Buyer data-room manifest | Pass for original snapshot; required local paths existed and ready gates cited only ready artifacts | `docs/diligence/2026-07-03-buyer-data-room-manifest.json`, `buyer-dataroom-manifest-check.log` | +| Buyer readiness scorecard | Pass for original snapshot; 23 artifacts, 8 readiness gates, 38 percent conservative gate readiness | `docs/diligence/2026-07-03-buyer-readiness-scorecard.md`, `buyer-readiness-scorecard-summary.json` | +| Figma Slides generation payload | Pass for payload scope; 11 slides, 4 objectives, 0 errors; actual Slides generation still requires an eligible Figma plan | `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json`, `figma-deck-payload-check.json` | +| Auth/tenant, signed artifacts, and KPI snapshots | Partial; runtime tenant enforcement, signed claims, signed artifact tokens, revocation, audit, and file-backed ledgers exist, while production OIDC/JWT and centralized durable persistence remain pending | Security model, artifact, analytics, and persistence tests | +| Buyer deployment integration | Pass for buyer sandbox scope; connector seed, gateway-signed claims, smoke path, and cutover gates documented | Buyer deployment playbook, connector OpenAPI, buyer-demo profile | +| Durable job repository and recovery slice | Partial; code boundary, state store, lifecycle events, and process-local recovery exist, while SQL process-restart durability remains pending | Persistence plan and repository/state-store tests | +| Seeded buyer-demo screenshots | Pass for local screenshot scope; desktop/mobile seeded story, no mobile overflow | Seeded demo verification and screenshots | +| Buyer diligence closure map | Pass for FigJam handoff scope | Design handoff documentation | +| Local smoke | Pass for original signed-tenant, artifact-ledger, KPI-ledger, viewer, revocation, and recovery scope | `smoke-local.txt`, `smoke-app.log`, `smoke-ui-root.txt` | +| GitHub PR state | Dynamic; queued or waiting review does not stop productive work but is never counted as merge acceptance | Current exact-head PR checks and reviews | ## SAST -Command: +Command used for the original evidence snapshot: ```bash -uvx semgrep --config p/java --metrics=off --error --json --output docs/qa/evidence/2026-07-02-krw2b-sale-readiness/semgrep.json src/main/java src/test/java +uvx semgrep --config p/java --metrics=off --error --json \ + --output docs/qa/evidence/2026-07-02-krw2b-sale-readiness/semgrep.json \ + src/main/java src/test/java ``` Result: @@ -45,53 +54,77 @@ Result: - Findings: 0. - Errors: 0. -Evidence: - -- `semgrep.json` +Evidence: `semgrep.json`. -## SBOM +## SBOM Generation -Command: +### Canonical command ```bash -mvn -DskipTests org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom -Dcyclonedx.skipAttach=true -Dcyclonedx.outputFormat=json -Dcyclonedx.outputName=clearfolio-viewer-sbom +mvn -B --no-transfer-progress -DskipTests \ + org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom \ + -Dcyclonedx.skipAttach=true \ + -DoutputFormat=json \ + -DoutputName=bom ``` -Result: +CycloneDX Maven Plugin 2.9.1 writes the canonical JSON output to `target/bom.json`. `outputFormat` and `outputName` are Maven user properties without a `cyclonedx.` prefix. The earlier evidence command incorrectly prefixed those two properties and is superseded by this contract. + +### Deterministic provenance + +Read-only workflow run `31004040777` generated the accepted dependency evidence at `2026-08-05T12:07:15Z`. + +| Field | Value | +| --- | --- | +| Source head | `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` | +| Generator | `org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom` | +| Artifact ID | `8929593015` | +| Artifact archive SHA-256 | `07a0325e08157f00dda28c58ed4e41af51863cccb2ceea2c4e378ead77dc337f` | +| SBOM SHA-256 | `e138a9263edb40c613d5f159acba8fa89ee848a7cef4b6619e095c48451b095c` | +| Attribution SHA-256 | `e19a3767a545bd059e50003882d8ff2f8a3ff4d3b8fd28d3f305eead61261da9` | +| CycloneDX specification | `1.6` | +| Total components | `61` | +| Netty components | `17` | +| Netty version set | exactly `4.1.136.Final` | +| Components without license metadata | `0` | + +```mermaid +flowchart LR + H[Exact source head] --> R[Maven dependency resolution] + R --> G[CycloneDX 2.9.1] + G --> B[target/bom.json] + B --> V[Component and edge verifier] + V --> A[Attribution renderer] + B --> C[Committed SBOM] + A --> D[Committed attribution] + C --> T[Permanent drift test] + D --> T +``` + +The verifier requires every Netty component version, purl, bom-ref, and dependency edge to resolve to `4.1.136.Final`. It rejects the historical `4.1.135.Final` line, an empty component list, unmatched dependency references, or attribution that cannot be reproduced from the committed JSON. + +### Current generated result - CycloneDX BOM format: 1.6. - Components: 61. - Components without license metadata: 0. - Unique license metadata entries: 3. -- Engineering license review is now documented in - `docs/security/2026-07-02-license-allowlist-review.md`. -- The unused `tika-parsers-standard-package` dependency was removed, which - eliminated Tika transitive review-required components `jhighlight`, `junrar`, - and `juniversalchardet` from the current SBOM. -- Spring Boot's default Logback starter was replaced with - `spring-boot-starter-log4j2`, and `jakarta.annotation-api` is excluded from - the current starter paths. -- The standard-library license policy checker passes buyer-release mode: - 61 allowed components, 0 review-required components, and 0 unlisted - violations with `--require-no-review`. -- The standard-library attribution renderer generates - `docs/legal/2026-07-03-third-party-attribution.md` from the same SBOM and - the drift check confirms that the data-room attribution file is current. -- The buyer data-room manifest checker confirms the sale-readiness package links - to required local evidence and current Figma/GitHub handoff URLs, and prevents - ready gates from citing partial or external artifacts as complete evidence. -- The buyer readiness scorecard generator reports 23 current data-room - artifacts, 8 readiness gates, 38 percent conservative gate readiness, and - ready-gate evidence integrity pass while keeping partial gates as discount - risks. -- The Figma Slides payload checker confirms the buyer diligence deck payload has - 11 slides, 4 objectives, explicit no-Code-Connect wording, readiness - scorecard content, discount-risk content, and claim-boundary wording. +- The unused `tika-parsers-standard-package` dependency remains absent, eliminating Tika transitive review-required components `jhighlight`, `junrar`, and `juniversalchardet` from the buyer-release graph. +- Spring Boot's default Logback starter is replaced with `spring-boot-starter-log4j2`, and `jakarta.annotation-api` remains excluded from the current starter paths. +- The standard-library attribution renderer generates `docs/legal/2026-07-03-third-party-attribution.md` from the same SBOM. +- The buyer-release license policy records 61 allowed components, 0 review-required components, and 0 unlisted violations. -Evidence: +Primary generated evidence: -- `sbom-cyclonedx.log` - `sbom-cyclonedx.json` +- `docs/legal/2026-07-03-third-party-attribution.md` +- `docs/security/2026-08-05-netty-4.1.136-remediation.md` +- `scripts/test_render_third_party_attribution.py` +- `src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java` + +Related historical and buyer-handoff evidence: + +- `sbom-cyclonedx.log` - `sbom-status.txt` - `license-policy.log` - `license-policy-summary.json` @@ -101,7 +134,6 @@ Evidence: - `buyer-readiness-scorecard-summary.json` - `figma-deck-payload-check.json` - `docs/design/2026-07-03-buyer-diligence-slides-generation-payload.json` -- `docs/legal/2026-07-03-third-party-attribution.md` - `docs/security/2026-07-02-license-allowlist-review.md` - `docs/security/2026-07-02-license-policy.json` - `docs/security/2026-07-02-auth-tenant-model.md` @@ -116,78 +148,36 @@ Evidence: - `docs/superpowers/plans/2026-07-02-conversion-job-lifecycle-events.md` - `docs/superpowers/plans/2026-07-03-conversion-recovery-sweep.md` - `buyer-deployment-slice-verification.md` -- FigJam diagrams: - [Clearfolio Gateway Signed Tenant Claims Flow](https://www.figma.com/board/114nJPcTcQzXvAEIS9T4gM) - and `Clearfolio KPI Snapshot Evidence Ledger Flow` plus - `Clearfolio KPI Snapshot Export Evidence API Flow` and - `Clearfolio Buyer Demo KPI Evidence Panel Flow` plus - `Clearfolio Operator Recovery Evidence Flow` and - `Clearfolio Conversion State Store Implementation Flow` plus - `Clearfolio Conversion Lifecycle Event Trail Flow` plus - `Clearfolio Buyer Readiness Scorecard Gate Map` plus - `Clearfolio Buyer Diligence Slides Storyboard` plus - `Clearfolio Ready Gate Evidence Integrity Check` plus - `Clearfolio Conversion Recovery Sweep Flow`. + +FigJam handoff includes the gateway signed-tenant flow, KPI snapshot ledger/export flows, buyer-demo KPI panel, operator recovery flow, conversion state-store and lifecycle-event flows, buyer readiness gate map, diligence slides storyboard, ready-gate evidence integrity check, and conversion recovery sweep flow. ## Local Smoke -Command path: +Original command path: -- Start app on a random local port with - `clearfolio.tenant-claims.hmac-secret` and - `clearfolio.artifact-link-ledger.path` plus - `clearfolio.analytics-snapshot-ledger.path` configured. -- Runtime Java: 21.0.11. -- Verify `GET /`, buyer-demo KPI evidence panel markup, - buyer-demo operator recovery evidence panel markup, `/assets/viewer/demo.js`, - demo JS KPI export endpoint reference, - missing-auth KPI denial, unsigned tenant-claim KPI denial, authenticated empty - KPI snapshot with signed tenant claims, authenticated empty KPI export lookup, - document upload with signed tenant headers, status polling to `SUCCEEDED`, - `/viewer/{docId}`, authenticated viewer bootstrap, signed artifact URL - creation, unsigned artifact denial, signed artifact range access, artifact - read audit lookup, artifact token revocation, revoked-token denial, - cross-tenant status denial, post-upload KPI snapshot, post-upload KPI export - lookup, and file-backed KPI snapshot ledger append evidence. +- Start the application on a random local port with `clearfolio.tenant-claims.hmac-secret`, `clearfolio.artifact-link-ledger.path`, and `clearfolio.analytics-snapshot-ledger.path` configured. +- Verify the root shell, buyer-demo KPI and recovery panels, demo assets, signed claims, upload and status polling, viewer/bootstrap, signed and ranged artifact access, read audit, revocation, cross-tenant concealment, KPI snapshots and exports, and file-backed ledger append evidence. -Result: +Original result: -- Root shell: 200. -- Root shell evidence panel: present. -- Root shell operator recovery panel: present. -- Demo JS: 200. -- Demo JS KPI export endpoint reference: present. -- Missing-auth KPI: 401. -- Unsigned tenant-claim KPI with secret configured: 401. -- Authenticated empty KPI: 200. -- Authenticated empty KPI exports: 200, 1 record, tenant id omitted. -- Final conversion status: `SUCCEEDED`. -- Status tenant: `buyer-demo`. -- Viewer HTML: 200. -- Viewer bootstrap: 200. -- Artifact link creation: 200. -- Unsigned artifact read: 401. -- Signed artifact range read: 206. -- Artifact read audit lookup: 200, 1 event, last status 206. -- Artifact token revocation: 200, `revoked=true`. -- Revoked artifact read: 403. +- Runtime Java: 21.0.11. +- Root shell: 200; evidence and recovery panels present. +- Missing or unsigned tenant claims: 401. +- Authenticated empty KPI and exports: 200. +- Final conversion status: `SUCCEEDED` for tenant `buyer-demo`. +- Viewer and bootstrap: 200. +- Signed artifact range read: 206; unsigned read: 401. +- Artifact read audit: 200; revocation succeeded; revoked read: 403. - Cross-tenant status lookup: 404. -- Post-upload KPI: `totalJobs=1`, `succeededJobs=1`, - `conversionSuccessRate=1.0`, numeric `p95TimeToPreviewMs`. -- Post-upload KPI exports: 200, 2 records, latest `totalJobs=1`, tenant id - omitted. -- Artifact ledger file: present, 2 `ISSUED` lines, 1 `REVOKED` line, - and 1 `READ` line. -- KPI snapshot ledger file: present, 2 `SNAPSHOT` lines. +- Post-upload KPI: one successful job and numeric preview latency. +- Artifact and KPI ledger append evidence present. Evidence: - `smoke-local.txt` - `smoke-ui-root.txt` +- `smoke-app.log` -## GitHub Checks +## GitHub Acceptance -This evidence refresh was produced locally before publishing the recovery-sweep -branch. The PR body should carry the local gate results from this file. Review -and queued GitHub checks are not treated as blockers for continuing the -sale-readiness work. +The historical snapshot is not a substitute for current pull-request evidence. A release or merge requires the exact current head to pass repository CI, Maven `verify`, zero missed production lines and branches, warning-free public Javadocs, Security Scan, SAST, every fuzz target, dependency/security review, current automated review, zero unresolved threads, and a counted independent approval. Queued, pending, cancelled, skipped-required, stale-head, or local-only results are not passing. diff --git a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json index eb356a03..2f9e4c21 100644 --- a/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json +++ b/docs/qa/evidence/2026-07-02-krw2b-sale-readiness/sbom-cyclonedx.json @@ -4,7 +4,7 @@ "serialNumber" : "urn:uuid:b6017fa5-aa1e-3a06-ab1c-8fd43d993316", "version" : 1, "metadata" : { - "timestamp" : "2026-07-10T19:18:50Z", + "timestamp" : "2026-08-05T12:07:15Z", "lifecycles" : [ { "phase" : "build" @@ -1035,45 +1035,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-codec-http", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "17647f3dcda67916dac602de8c8f2ca5" + "content" : "0ea9a6efa8033fdaec83eba2070dedad" }, { "alg" : "SHA-1", - "content" : "69d785784208bae296fa74d802772687c6947754" + "content" : "3f5101d264099848dc5fb708bad4a09c4601ac3f" }, { "alg" : "SHA-256", - "content" : "4018529d3d6aecf4044b98c75d9a90c91839ddf49c7aa484c5ac81c90a15da02" + "content" : "ffd1e1b19a533bc6e47ef2cbc1290374ff4a7cb53a280defa3df392538214948" }, { "alg" : "SHA-512", - "content" : "47d41c724aa9763ba2b36618d4905f02546e83ea0b498f0022a9a9483c158ce88794c657a679a6cf4495680f18d2a7d2f9c70038d5d0cb27d97f36cf4b36f423" + "content" : "ff2fa4c55a6954aa4dac52e66cad2a26e6fe551776bbf3a68da863610a0a8534496d2d66a80a941e8803994716ce3cc3635c21b666f6dd1ad91cae2a396ceb2d" }, { "alg" : "SHA-384", - "content" : "a986969aaa528a168981b4b70031a3ec9adc482a78224a96ee7110840acce8d4c8c5ce0b39d330821c993f1a6483a94b" + "content" : "5bbfae77116c14020723d681d5d28d0073b58225b6332887df13c915d3705d14dcc1b153152294421308e975a0058862" }, { "alg" : "SHA3-384", - "content" : "ec7423bd39906638bfd36a127f18f9fc9c8d51431aa7daa4981bc7361a1851e6afbd939f9d5c56e24ab94ada08c6ff52" + "content" : "2d41da28daea35313fc5c48c252e5aaaea2e53528ff989059e920861a02bce713b52b4ac9dec8e331f562cf714bd94d0" }, { "alg" : "SHA3-256", - "content" : "c108c82107511a4d96a6dd7613f2f7a7887e6c79c2f2c0256abfbd7fa93bba54" + "content" : "355212f7d6dcd40ac9d1cae43311c00ca9831b01ae06e264225985b2cf94e169" }, { "alg" : "SHA3-512", - "content" : "05d36acb34894cbfb3160bf2ec19950a0a1fdd15772c9e11a7006b8357b8a9c3b0111673b2fd1ac44f3d3a9cff7f37f56671baeec290428d0df008c086ebfb03" + "content" : "597974256e788cba3206ca7457b433dae2cf1eb34c9635e062be48df72fab1ff75ee40a915f96553b6d522529a1a1b6e08944ac52d4aae77036e6443fd78e37d" } ], "licenses" : [ @@ -1083,7 +1083,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1101,45 +1101,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-common", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "bfa2258e927ec224143136eda472297c" + "content" : "1fcc4e203d0b61b432bf10f4cfa94619" }, { "alg" : "SHA-1", - "content" : "3f1fd5004102cd1146a53d29127804569b63c90f" + "content" : "c11b3dad4ad80a34b3860dbdb7ce63166913eb12" }, { "alg" : "SHA-256", - "content" : "26775ca95820711403cf065fa2ec0134a0a04ff5417c688c0237aee68b55838d" + "content" : "e2f73be7ec359b46583ad875521137000eff520bafd320e730846ec9974f3be6" }, { "alg" : "SHA-512", - "content" : "a4317485b0b1434b552c98bcb69a1e1fffdffcdee22c84e61c107dfa9752faff7f5799617c81ba80861f929fe7dcf4436d7bf2af95c4eddd550ebfb75df12cc6" + "content" : "8401400d2c786a62b513d394fd372facab2581a7964982b1c8ec5af53a235a881cdb3d1447415b22f791b3282113728376b50ac7384c1b13ed90ce779548fdcf" }, { "alg" : "SHA-384", - "content" : "f6dbd09db24ddfbe0b0c7c4480eddbbfc29c9db89ecd38b56e4c9f0ca49907ea45a88479ec6d95ae83c98d490159ab58" + "content" : "94b02b8d7368c268828f85bf221907654dd6bfa334f6252fcaf26125a7b333d367b3a81d98c036d6d4a94716ded48ee2" }, { "alg" : "SHA3-384", - "content" : "151dc11ed193e806580144e00260c6e98f82e7716fe81d7181b923fb1f2656a07acf6e324c90c35cf066e90c023462ba" + "content" : "2ce8d61f5f58d1dc8cc8fe11c4d4f078ac92cc3d40f8a61d316a83501e4491faba68929f499a46741c48dbb9c580578b" }, { "alg" : "SHA3-256", - "content" : "5c0286ab9cd98756e203f38d8172577ed184c8236478532bbd78f85d4a583ea2" + "content" : "a9d06835f575037564b8ef926abfd5022bc09a5d57071082881691eec2fb889b" }, { "alg" : "SHA3-512", - "content" : "d2f40345230b7f1d5b04b78fd82de5cb94134ca12510d0e53a22245f9c24dcb420c30e8a2cffb26b474782d09cf986c57f9f99df856a481c2fc7f8d066839c10" + "content" : "4815b59200870be324cd4a506b7f278840ce9f4b152249851f690cd310fba26de890d8c1cf984978a4e21748d505d32cb784f3ebdbf0d5bd6c2baa455197dfb8" } ], "licenses" : [ @@ -1149,7 +1149,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1167,45 +1167,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-buffer", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "fcd9f423d78592d1ca785b68ee9b16ee" + "content" : "974bb8b00957141a3c8aaa56dc845ecc" }, { "alg" : "SHA-1", - "content" : "6e60e222c27d3534e63e519fb1a9d66c485f62bd" + "content" : "2675f7ca19b174466766127c59b52acdba01f6a2" }, { "alg" : "SHA-256", - "content" : "2a194f99fc93d07c4d442d04ac71bd2dc56d3188cd0e4270cdc2a953d1956bf9" + "content" : "c88f13fc41156fde5df918c7b240038dfbe97ac57576163f208630391789b5d5" }, { "alg" : "SHA-512", - "content" : "a839731d75317f515256c8340d73ea4344c9f488ce2cc31c99c2788d2a33a055482ebc438ebca63a27c0d49728fe778e7d0b06a031a0244728d0b0ccddfe6407" + "content" : "b335ae2f322969a4182fac5c8d056637fd4fadc3ef5a05a13f55fd2a25a22e13b4cade5d6af2aa8f77aed5e83269b3977966f7c57abda39b98420e72a749956a" }, { "alg" : "SHA-384", - "content" : "ed107c69a75793161a6e3ade436cb564224b7bd58ea551b42c5b7d10dd6384fa25538a0064dba4521beb3ca69857c455" + "content" : "324583f3af2c2c3933add108fbdc5922fefe5e77595c608a6f7088b7aff168a0c8139a90b1f94dc8cb27052e1fa922b5" }, { "alg" : "SHA3-384", - "content" : "6f1c0b2b2c428e9fdbda3aa37558fa91330be87a7fda93eb2ba66d3b224feef63120216a06e3de2388bdf8648c549f28" + "content" : "4b83e43ddef6aeeb0b55206b2abad41386812a60f33ff2303fc3e298dc0516b5798c12546f98fbea98881a6d140674eb" }, { "alg" : "SHA3-256", - "content" : "bb6f4ce57de81fb991b78f4e6861088e46a3e8de176a84757d48d14a2ac1ce74" + "content" : "155689ada0fc252379de770957847fa38bcc4b27c6c44fbe62aa1d54dd2b3636" }, { "alg" : "SHA3-512", - "content" : "13f9bafd0b5534316b77170719aab58a46daf9d7303b5541887b9171522501fff7b1fbbe6ab887a2a94b2d8455aa42fa059fce9dc21db98309d02e6539e32835" + "content" : "557b47c06465ce198de44efd3999af3f540b6682ac2888a42114721e7b657713d5f36936fd4c287b064e0d3ca536910ab8279aeca4842efb74e5783c25adc80f" } ], "licenses" : [ @@ -1215,7 +1215,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1233,45 +1233,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-transport", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "06b22eaac4fad8e22753969b96b2c36a" + "content" : "bb5b1ee9a2d1792931fcb008cf3557e3" }, { "alg" : "SHA-1", - "content" : "cbc238f1f9707f3652252fdc48568cce0e9a01d9" + "content" : "0c2e6961562760a9128766cb84a50d2ac51402ff" }, { "alg" : "SHA-256", - "content" : "6bde734d1ec073142eed31b1e68cd5d68fbf241e060b37f07a164e5ecb15631c" + "content" : "b92881ea925721ed42fee5122a42b8bce4b84da737dbc3ca2d84dfaca52c28b6" }, { "alg" : "SHA-512", - "content" : "ff06cf5971ed28f46f73ecc6f5cbcb2b7dc7f54b9c046678737616fde57ec8e7ca215c63cecd5d2f09c174c1483ec7538a63781009955539be782183fba6558f" + "content" : "a76ab760519837e9d0a28fa6699ad4e279be1bb5e63a5c4173e9e5566e0e1bcb143a18a66b54fb1261aa07de5beb383cd93d9f043ae1ee869a7bef32896ba650" }, { "alg" : "SHA-384", - "content" : "043d7346fd771eba584d272ab0160aa9c10c011c3f3e7006d02191af6fdd33f73eac4d6fc5e727cba4544dea670cf4a6" + "content" : "84d8a4776e8ec72e625381aca19ac105b4053ed7da431991ca8091bb2df7a25b2b9505f650219d1a4650bdec2224ab92" }, { "alg" : "SHA3-384", - "content" : "599fe6ed7bfc5f22614b8bb82d57467e18c45328d6714d5055d6b2d8cd5286fb014f90672f6b51c52759d1b64222717a" + "content" : "411ef1e0b07cdee90cfd5ad00563aed6df3f5c13b41e8156458fb668744711aae29c4817156ad6e2a2c7867f976a90ab" }, { "alg" : "SHA3-256", - "content" : "bb0022cdfa7c304bda6aeb620815ca795973cec8ee1f4d5a6e10225bac004f67" + "content" : "3024e3a5448062c69490c4f5496514cd902b3a3269ff024b4d344bdaf09849ad" }, { "alg" : "SHA3-512", - "content" : "92a7fee52ff206f36d150aeacab70169b3e89bb5c8c7878c2932cb8cf98738c7122584336e183cfeb132721f8f22a55b44bca1a724336cadece6ccc4d4621946" + "content" : "2aeddb9fa673e3698c1b2189bb33ee20ae8d369956b7f3fa9e4554ab8bb58507be0fd11a824c20305aa55ae2f0052f8506dd35371d1cb0c80cc136f9042eefcb" } ], "licenses" : [ @@ -1281,7 +1281,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1299,45 +1299,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-codec", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "d4c7467d989f3fce353fde66eb1b4bdf" + "content" : "b61698c2f484ca179da5699da54688f9" }, { "alg" : "SHA-1", - "content" : "cf36e54dc81aa160a93780478727bdb3c3fa4600" + "content" : "f91a3bb222a8da5e08a8a2feae60e5fcda74e4dc" }, { "alg" : "SHA-256", - "content" : "7252171264dbb5bb8ed38e77f89643b31e3cabc96144ec27b6882435d718a61e" + "content" : "2de4fc13005c7740b46427a47ee04265a19779c147d53e583f441889b1148159" }, { "alg" : "SHA-512", - "content" : "663db82a0b3a83a4417b647b0b08620c915c9f476033d241bd57790fb3e024972a2ac421a66bba49451439c101e04915828d109a76316b8533c87848b7e95453" + "content" : "b9fba7ceea4acc923745d097b30b9978b3ab058aad3e880ee3061bab22cf5460a170ff6f8b94932b491d22740b027b74fb65e4245acbdd754be1837479782dbb" }, { "alg" : "SHA-384", - "content" : "4d66221bfb9846a4abaf037c91acc3b50831210933f4d352559b075a118704eba58024e5bbaea33f1b96e38ff459030e" + "content" : "16893a5504150305e44df81704d862565d8089a149edaff9dff9ad8057f7b26a72aa1910dc943680c816bf88b1821e3b" }, { "alg" : "SHA3-384", - "content" : "db590cd2eaa49fe7f49473af1dc4d55bea45c68c56caa6794455182d5fd6a0792b9aa4a614912fd57859803d437138fa" + "content" : "3c8addeb374411ef72d87ed55d023521cd2997843ac1e76cc947d86bdae3357de431135d3d9b6a3b0076b45c3ded93b1" }, { "alg" : "SHA3-256", - "content" : "2e87051eec597bbdea9b1c6b621cc1a08c3737ad5d80abbb9dcfa7f5b6412932" + "content" : "60e83534593483ea4066da60d968c21891cc46ca51df3190e8d2bb64517d04da" }, { "alg" : "SHA3-512", - "content" : "6a7f5b8b01c8cd272ac0f4936867a896489854856f3b65ee7ba6dd3ecfda5bfad9e1e9150cd76d1421a6e6ef04f90847d1180c1a81b29496cf1424c0f2d39d75" + "content" : "7a9ba3da73796fdd34e4d6882809e8ada882e398a2625d83a67f1704028c49fdf05d70f4b757b1947a891f0005fa1133d99516e2751f706481c7c5025c6e74ff" } ], "licenses" : [ @@ -1347,7 +1347,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1365,45 +1365,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-handler", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "3ac32114d15dc2b284571f5d35c6b790" + "content" : "d7b41fac3cf644831b0a5bb70ec0ebcc" }, { "alg" : "SHA-1", - "content" : "567f8742a3d7a0a8be0401dada8fbdbaa7bf02b6" + "content" : "9114e02d69825eed3a901d762026261af15a56ea" }, { "alg" : "SHA-256", - "content" : "245e74e04b6f4e8ef98853152412e3bf1499ce6fcf15329b798c8ce36c3537e2" + "content" : "54bb1a59f46a3aefd117942e6acee09e555b756776bad731fc06159d89c2da28" }, { "alg" : "SHA-512", - "content" : "33542ba61fa2ae931d06d6e4c1df904331e0c02dee912a06793959982481df15cc284ebb105cd7de9f31b5beb140a9cd31a609f4fd71182c317a82ead35a4299" + "content" : "13a4d19f934e2327764b19ff12816baa6bd3cdb5991e06104520241276184e052f895a5e5ab1c0ac6492d002e6e412efa65c47c7f14c7fa8eecb5655b1371b27" }, { "alg" : "SHA-384", - "content" : "9d80cf4a3d8d402b453e7ce34a4d885a2924b6a56c9ffd9fb26100e123b5ca5397deb70c46085e8714c76db792d658ed" + "content" : "225271b726dbc059393cdc898688fb2733802451a2b0fd9e8e9743ec6578cf9d11e482e5c71e8f29d21e9248d8a54703" }, { "alg" : "SHA3-384", - "content" : "0bcbf90f65c5333a60b15e46ac69f0b669f5a8932c3042dc3bbd4dcd0f6186a21155911148ae2f19a58394d9dddb6cb7" + "content" : "5654da7bacdbeba834b698ebb680fbefdd36dcd9f1879df2a76020213ab9c80fcf17937b16dbc98786cad40baf3d0d97" }, { "alg" : "SHA3-256", - "content" : "2844a349d6c7446874174272167217bb70287ae50244fe9f6c9159e23d804dad" + "content" : "529cc2ac8c6bb306b64a02f9004e5b3533962a5d9ac3db5217f11de4752704b9" }, { "alg" : "SHA3-512", - "content" : "c668303216efc23b45f9efd6c02d8b7814941bcdceb63facb165334f1119a7cb76bfc042a6ca982a852f38a753da71515a02839ab325e230d1a362ce38e88760" + "content" : "42d68ceb93c6d3855f5c522716f48a8d090567a5d3cad589f2bb10820f196e30ea37341b43081711f8d6326838be206b691aef691bfe2ed88bf2109f89330dbd" } ], "licenses" : [ @@ -1413,7 +1413,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1431,45 +1431,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-codec-http2@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-codec-http2@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-codec-http2", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "6449e3a486f2ed3a3c3d2e55eee93cd9" + "content" : "a738556000f1858e63207524bbaa03a2" }, { "alg" : "SHA-1", - "content" : "56375e341f0ca44079b1bfb62030285b9de5d713" + "content" : "4ce8dfcec2376f945f7c29e5d423105161230117" }, { "alg" : "SHA-256", - "content" : "aa4e81ab5fa3b7b243eb3e814aa582ab26c073d31b0abffdbb58ee150fa49c16" + "content" : "14f67ae095c056b062aa0ee2c7b01f6b78004cf3620a6e9061bcb637f079387a" }, { "alg" : "SHA-512", - "content" : "aac3e7c20a1af95b8adf69b08cc09c0045f2ca4ec25516d57f8a4d5f05973369005b8b117e0c18e3741a77ffb0f50d9624bb64c81f9ddf33fe572e4607d79f29" + "content" : "920b1c00f297e56fc3b2dbb2d1a983d014f5727179d147c17bba56e4d9d85c1a2b1bc918bb4a63b59852e572a83c430646ad4a8d52644a083e66433ab4e8da6e" }, { "alg" : "SHA-384", - "content" : "a38dd44f5cb48587f0fb42c41a64d3d4d677ecfad681c4c4bee404d79294d9b5f099458648ab7047f3846eb9e15f85cf" + "content" : "805957ca9f9884b6c1bec3c92452233bd77a575f2b2851ed3a86c0dbd3ccc344e8092a3f66082341f1421ec7d9359474" }, { "alg" : "SHA3-384", - "content" : "3d3bf5ee22ddf455393ee7ab15d888640e86618ddce6f4649fbfd06323105113376c1f7c665c76e6d2d5d9f3e34a57cd" + "content" : "20d9c091e9401b70ff2564fb93fdca0488b00128751eeaac3e3d022d67ab9d0243bca9023860db9309b68876cc7dcbdd" }, { "alg" : "SHA3-256", - "content" : "612686c5b416a81f2bc9c8a4b553d59e6d218c83c4204b58df56f9109d2f82bb" + "content" : "229c79f4b60b7c5ed6b6200af10a15bd3918175a36162094fbf7a16eb4f7079e" }, { "alg" : "SHA3-512", - "content" : "85d4272bf01de6b1f64725dc168fb2ac00d83b2f2dd0c63f341967554f15b5dd0b902028448e7a24c9d5948f1d25b7163810c3a612b5056e212203635e5fc797" + "content" : "93e122e0c47079b60142cb8a4132c2ebfe354e1362803cf0e299070357609c54e653246e85956be346cfaff556cc241e0668bf428a91930ba3a15ee422895808" } ], "licenses" : [ @@ -1479,7 +1479,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-codec-http2@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-codec-http2@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1497,45 +1497,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-resolver-dns", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "2099aaf4771a939f4720897331acacaf" + "content" : "83bc3a4af7a32017330cbb698d9393e7" }, { "alg" : "SHA-1", - "content" : "0d092a2851ecef2a07722da7da80a843f7e936ae" + "content" : "de43e9f34094a67a3495659fc49ee1a303f4d78c" }, { "alg" : "SHA-256", - "content" : "ca25581e4cebd55797ef3b4d0953b75df32c1af77fe771b96bfaa9e701cdb7c3" + "content" : "9c05a9b18b5d54fcc1569c48b93958019cf3dde5ae7011f8a46451ee05e7daff" }, { "alg" : "SHA-512", - "content" : "6fc7130da77cfef4510fe9ae2bacd985980cb357d993242605e956c8c4f51bbbdfa9caf1950adaf524793ae0caa520277c0c036be9b5ae2a03447f836fe30256" + "content" : "4bc7209071556bdaebabeadcd17da258bf7859c7c4524351c1d292bb84211b14496158352c03421d043fcdd21be9cfac3bea2ce1a754dea0baa6a5557e00571a" }, { "alg" : "SHA-384", - "content" : "d858b05204f75696016ad813e8fdfd18a87d8f403bb32154fabcf1c5a08df2f97f8821142c07e08871575fb61093963a" + "content" : "5d3cedd0d3610b892ea03d7a2c8c7aae4fd6a6c7437c33234cc6d2a1575c70af75b335037dca562ac04bf039dd1807b2" }, { "alg" : "SHA3-384", - "content" : "7d098aad0661a71b9575c9cd6752ce08f4abb3a9fe786c949aa06cf3895f6afaad4debc800565fa710f6ad26bdd97330" + "content" : "3eca0a8d27cdcff557dcea06ab91213840d4cbf06e2f10c8f275dd35e83dbd0768c46d9ef0bfec6ead05bc2cd370c5f6" }, { "alg" : "SHA3-256", - "content" : "197fbd0361d6136445d478ebf488a050f3ce3ac6f81a95656d13a2c67490b5c4" + "content" : "7bfb9c4cb0d9ed788e6be029802fc5331a2f38e671bdb812fd2ca97111cf6705" }, { "alg" : "SHA3-512", - "content" : "d561f016fc0be5fddf0b45d3b84fdfd482f5fa3b8a9373778e2183a4ab3e7ca3a9e0ac66626e75763d374911358ee3e74d249037c6d2cbd1b9de416b466b5779" + "content" : "9e54e1c2759b87d07a5fbfdb5a698967d17a882e15a492833136a7a7f24a67a04c4029817f063b9fa5f4493c425202ac38d930b7b40a2aea25eeb742a243533a" } ], "licenses" : [ @@ -1545,7 +1545,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1563,45 +1563,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-resolver", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "129c1aa96462490d58a358aceaba0553" + "content" : "020fa71ad6ac3fd061d8a4427e118758" }, { "alg" : "SHA-1", - "content" : "d43b00856050cfd4445a063d5816a4782ae422f9" + "content" : "54e0ce6acf52451fa951a4632f40b516b00a87b6" }, { "alg" : "SHA-256", - "content" : "77dd03865965b6c12b9e521bddec82f035caeb33156e09c158289c5094318481" + "content" : "e64972fec474f5b9bd086b738154d190a923e82c4923ac550aff4f5ea9e98600" }, { "alg" : "SHA-512", - "content" : "f4f6d22bd7805e631780d660dfec268960780f9c73da4aa5c2835c89719cb738f457d6a833473c621ccf578cbeacfbf83764360bc50ae3d95b72fe7b5cf3a6c5" + "content" : "fb73dc56ac6ac0b4233c2f6eaa2918b58519ff1073ac70520a17f714d21cdef12b38c72f87895c680ab040d8b46cb8ce1db0ad1839b4f2712cfc422b36ad27c4" }, { "alg" : "SHA-384", - "content" : "efa50893602f0bbd54e7cf6e0066192882fb77b0f7271ccb8ba2f5efad3365fe0c5257dab21873300019113376ae9bcf" + "content" : "04bb48b4c9755f14d480fca64ac6b658acb6ccd112a34eb0e209eb9a50573232d98f2c9b1aa15aadff1661893f235320" }, { "alg" : "SHA3-384", - "content" : "edd91ab9326b41343c7030bef8d897e0ba861ee9e40898ed4b7a38512c31c76b34a8e085f6f96886686b4732e7d3d7a2" + "content" : "1a4006f6c3592c05d8160ed6b8670d2a51c7bc8c8948898a43e494fff092b3b7948bbaf58dfac2b91c824bf34a4e04ef" }, { "alg" : "SHA3-256", - "content" : "316ea500ee5b62175051f725693669a4909c046e4b009164a23584a0169d6b35" + "content" : "9a4bfc5f2afffe2725efecbeeb7602de72223fe00ec68bddb54c57d66055f9fc" }, { "alg" : "SHA3-512", - "content" : "3b44e554de6c1aee42d2087eb04e8b9096a5a1ed85ebdd5f8d8e2254dc4b1b3d0a3cf3daad4dfc3d9486de88c70b0b03d75f3e055e6880343d821f147f36e0bd" + "content" : "b9c0eaef1c4b98e7896d265eb6191c44c15488096972e1fb62e31c1c60c1ccb00c9d21d08d07ac64a12d4576076951924229989284d075f08f5bb133fc605f88" } ], "licenses" : [ @@ -1611,7 +1611,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1629,45 +1629,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-codec-dns@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-codec-dns@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-codec-dns", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "a424ef4d2ebfea699f9d1218c41f8662" + "content" : "6344e1103219485b1c46503c3f741859" }, { "alg" : "SHA-1", - "content" : "0814e353f6c5f9383f2b93d941961f89565f4161" + "content" : "143b952318f5624dff1707dfd1c4cf8565998385" }, { "alg" : "SHA-256", - "content" : "5e996d7ac7597f368ab114fbb91d16788918c7e5bf166345c51e56db54d50fd1" + "content" : "89bddc4ec42756c41a0195a50a1323a324836162b6712a843c3de5c29096c93b" }, { "alg" : "SHA-512", - "content" : "6776da183ffbdef92da487c1ff071cb77f1f020c2f9730183a1959226d3bb4f8a592fa8118ea78f3f8938d35129c1bcfd60176d0802803b8897ead66a6e92846" + "content" : "9aab4727b460c95e7b9a56e76cf0d0f18ac2f55f01c65cad7cc1559a6d7ede9236e73740350f890e6813394ab75491b60cc567ddb4a24e4c5874770a97f2d094" }, { "alg" : "SHA-384", - "content" : "8cf961cacb0ca2ad8edc654105d746078c76a8922780ca209babf65037cdbe1f9cdbbaa9cf6a4d602e88877032867c38" + "content" : "acfd49075478a33a0ed2393dfa64ee6400eabb904724f338697be544aa7cf99f1808a4a7cf41236987759ff37f7295c7" }, { "alg" : "SHA3-384", - "content" : "cb6d88cdc3ccf23c20d89322ffb35c6172746bb8b9a785599245fc72c645aee44cad19c69a5305e0559158a7fc62b562" + "content" : "eed5eb30fb135fb7d5e5cf37eb9f242b397990696c8a77709030a0277a9adbb5ee3b735a36cc3143dbda8ac3a9575c0f" }, { "alg" : "SHA3-256", - "content" : "d396287ecffdf697be77ea8bdb43da2f9ce91e13e5abc07ee931a889c5406e99" + "content" : "75161e25f223eb91cb7a574be46b058eb4f8060323144b94caa56f60bb134d07" }, { "alg" : "SHA3-512", - "content" : "d9cb1b743e95eed44fd9fe40db6638e6f0f870b536c240ac23364acc3be06b86e49421bc38d138c6bc30f272a90ccc9ba7cf9d0017acf03d3664cc69a40aa6e0" + "content" : "9fe9df9a48ab7e89d202a45b24eb96afb74dc0cffb87ad5ba5cf4ed416c3d6a3445eabf26cb869403bf84803687bd3eaddbd96bc58ae97cd650b11981890ce89" } ], "licenses" : [ @@ -1677,7 +1677,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-codec-dns@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-codec-dns@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1695,45 +1695,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-resolver-dns-native-macos", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "9aff4b62bef6c3e4cccb8fc5e72db35c" + "content" : "ef6154c32fff9fcf90ded6acabf6acd7" }, { "alg" : "SHA-1", - "content" : "160036ecf20002bf9b7b563ea2101f26c3ee64d8" + "content" : "fd7a2c2da74df9a3aaa6ae31902365ef594f4d48" }, { "alg" : "SHA-256", - "content" : "0c86fa27317c4172fff03a0c20286e2c62ef9d60ad78f389a83ede48a5bb54cd" + "content" : "0d4ee2dbe280d70099618c7efcd029b46b85359ba633a0eef05e5bab3667bdd5" }, { "alg" : "SHA-512", - "content" : "74ce3f47ea26a890d51e8fcbc2e25fba034b12a067b4df61734d5c7ac7d44971e8f55c3360e031f7e867e30b43ba4b8ca8d22e57ae78ed6a0cd769869feec128" + "content" : "05c0bbc8864f48b439e2af6403e9f51bded8239b759662f1cd696656fe6d89150cee3a0478645fd70fe9971d761f5870ff5e7eca863069628dd915d8dd432cc3" }, { "alg" : "SHA-384", - "content" : "849b343d9e7ef7ad09756ac9e347c0c08315ee60eab116f236eacec23af1ecf4d7a8329f2a0f8699faaf5e8888e135ac" + "content" : "ae9941f406a273bcadad741dc66a210b3aea3b9adf6bfeb07116dd2250356a8424a4a6773af6b996e862ba7cd3552a2a" }, { "alg" : "SHA3-384", - "content" : "9f64f63e61b7c8b7d57d196bb6067e8842643321e32afc7ad62362c8915fdc0f92f3efa639c80173715024cf5916c41d" + "content" : "4072ade6dec8794f3c9e999f3dac56671f8c419db690bd2ededc2307342c864a107af3732e1d81af5dfd29872d3e9f99" }, { "alg" : "SHA3-256", - "content" : "907a413fb830590379df951cfe91051df322ac640e5d8a4f4c4306d6217b0737" + "content" : "3d51e5edadf72fb575a32cd7b5d8f7627c22e83ce445f10712140271c2742d14" }, { "alg" : "SHA3-512", - "content" : "932585e4251601a5d6a6a482ae45a924fae7452e19609bc446d74c584977b6264e60bcc58761cf74b89912dad834520ec1dae16600978528a34a99488cec0b4f" + "content" : "9f37dfa1cc4f1d24b72dc8751d18065e94997c3c04fe84164b14fb40ae5224661fda5662164250e5342ae9e47b3206004efa5c491681e77818852524bdacf53c" } ], "licenses" : [ @@ -1743,7 +1743,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar", + "purl" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar", "externalReferences" : [ { "type" : "website", @@ -1761,45 +1761,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-resolver-dns-classes-macos", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "1cc229f2acf3ac7ea3331021f38a684f" + "content" : "77e4bc0cf256cbfcaf5a2bf73e378381" }, { "alg" : "SHA-1", - "content" : "508fc43de462e60c814e76d7e23dea59b0cc4fe8" + "content" : "474af89eaee42a9b9ceef222a7fcecc80cddbbed" }, { "alg" : "SHA-256", - "content" : "4aab49a507dbbe446ad2c6a7587fe69c511defa6c273ce1a559e3458a3378a5b" + "content" : "c8c3ad3b364d302cf2a3ed8702bfeae455536963382d0d9260b9fc47ea6f234a" }, { "alg" : "SHA-512", - "content" : "47d2792a6c257b22d4f61a3dbbc3150d8cada4f89343869c67af5d04282af9f53d3ae1f55b20b173d425725a8e41bb98747a37f8652428a39f0ed02cd801a326" + "content" : "ec1cd7fe56a1df1853934bc511e7a802f2e18a29edba881e54b443351473ea71780095c61d194ae87df89801f0a43e506fe05f8c4b7f2678f023d76535749865" }, { "alg" : "SHA-384", - "content" : "6cce60f35b59287ca061318411681bba557e046580065d326baaa9097593935b28904d10303cf6cad7b75e71648301cd" + "content" : "63e02bbf5166a9564af91c560235ac6bfe5098c8195edab93adc685e956b450a6807bb196b72c6a20e7bad87d1e87be6" }, { "alg" : "SHA3-384", - "content" : "ea645153e34d957274f4c98d1796976abe8bbf11655f77f6534143a24c003f1c26a6b70ac1449a5c64058c9be157e682" + "content" : "72daaa90d671802dba4462fc9cc23300969109e7818269af81ddddfe21daeeffa9b96ec38adc530a21a35f6ef1b0b61e" }, { "alg" : "SHA3-256", - "content" : "de57d13e362c759ec02c183ff526b130deff23b4c376084ed8dd36f760311159" + "content" : "d04a1022e2342063817caf392c4ecb585558e023a8a462eb8ba4f24ead3fdf66" }, { "alg" : "SHA3-512", - "content" : "b9dd08e2ea0d488f3943027d8a92837697f4687ab3e8fbe54af9ce64c04423740e8a392042e28e81902bc4c1efc5afe7731eaf4553b546ff1900868a70ed1c20" + "content" : "42fd7915ec51bfaf936465fe13eb003cb9bc405a914167f944662739295be38d5c03faf648de8fa36b4181325b31700616d924641a11c75ba7cade64326d2fd1" } ], "licenses" : [ @@ -1809,7 +1809,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1827,45 +1827,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-transport-native-epoll", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "97756f389188954811a6dacbfe0005ab" + "content" : "d01515edc2dc9280fcb605d0d1638c1a" }, { "alg" : "SHA-1", - "content" : "9321b17feb084585c9286208ad2c438cc9abed30" + "content" : "4d2ce2c0069e2b5bc5b7e546a5a7bf1e0742f75c" }, { "alg" : "SHA-256", - "content" : "18a40063da3364cffff81c6c2097fb6ebcb45c62264dabcce45aade4fdac3125" + "content" : "c3956f90241582bbbad5612c3159cffeb7c3533324759f3c3bd778ca9b176cae" }, { "alg" : "SHA-512", - "content" : "89e0517d5aded7a91dab4560ff65b7dd8ec30d8a0acadff8fd708dda843df7648cbed22225724376b699c221758a02c674b443a7d18dd707bae7c1985380cc0a" + "content" : "1b3077f7c921ddc37122ff95d69ceac288858ab59b8636548f8f7ecb1df872c33105c0c48fb574c1520bcd4d660d79710b2ed6efe7186f0b2fc65b55d8f0d940" }, { "alg" : "SHA-384", - "content" : "a87d40e90002dac61eff262131570623bac1d6a40ceb576601c8e68f5aa791539ac1c5983b6cfc783804fcbe7b2fa51f" + "content" : "f3b8f22160152e149da61dbb588c200f808a7f93136ae49af10bff0eb96adc7d1ab77fe2db0e212f03d6c1d02ff57937" }, { "alg" : "SHA3-384", - "content" : "015a7d8c67bd0354c13ceabc183ed717b4977d54e2b2c19602d75f439fef81e5e6618ea3222781d6bd7eecb865d5118b" + "content" : "2b3eb64886c36a4c4f5234233c6752fdf576538568e50f8257ff2c35493c6b34cc15f497111bd7a405b9932a0a5a41e4" }, { "alg" : "SHA3-256", - "content" : "48ba69067259e8ca33aecf4fa5849089ad078a309338e2f2a89a3fcb3aabc2a4" + "content" : "f6fd525ed45de1e68925283112b6902356943464a65df6dbec0110608129ad8d" }, { "alg" : "SHA3-512", - "content" : "102fdaf685a08dcf3e57101b3470b967a1aa84373b1a874c3e253a66b611daade82faf6954958f018d6e4ed82c612a250bec806d0c7a60966703054fa1ac328b" + "content" : "3ebcae1ce865793d18304e56b96dc14efaa724a087d0051b606ecc67c8425f24b4cf89a5d3f3509d876ff37a8f9bb0a2f3d43113a7ca658f0404525c390f3789" } ], "licenses" : [ @@ -1875,7 +1875,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar", + "purl" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar", "externalReferences" : [ { "type" : "website", @@ -1893,45 +1893,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-transport-native-unix-common", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Static library which contains common unix utilities.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "1e2845ca46311605e0a5e1623bc22e47" + "content" : "4de177266bbf4335c7168ddeef5de796" }, { "alg" : "SHA-1", - "content" : "9ceeb22325232e9998e3f0ae396e5e4cb462672f" + "content" : "550e2091240ce07f9c1f114aecd89294df92fcb7" }, { "alg" : "SHA-256", - "content" : "a7895075f112611d1640a596c2678a28aab92d5681c1c14755b109b8998f995e" + "content" : "7e014c9b13defd9d254d4e5a5edd8ab6dde17533e1152f99699a6b28b2967a8a" }, { "alg" : "SHA-512", - "content" : "e6be72757a08d1389ec5f78c2cb4ab099ed1dc4f1d1003ca892641a07b2fbe248bf2fb5f0b23a67999b98f2feb0e6fa9d897dae4fb6413c912a8b8945986bc11" + "content" : "b96384fc0757b7b4da71a791addf9a6bd7b22794726de49148e7bb0c463b8481058eaeb02c99365fd990a9328c7d3801f4b607c7effda55b6993d1c87c9aef07" }, { "alg" : "SHA-384", - "content" : "fcb44d8789ce69fa7e3245ee13d01754a3a85c6fe9a6a9ecb0ef6c904ea8833525001cfb817db8afa6e412f670f93fcd" + "content" : "61e5ce1f86efef4422952ef6b34e157b3c0e022398af27fabf1f3e29cfc3ed9bf06412798d056befbbbbac7f4fa9814b" }, { "alg" : "SHA3-384", - "content" : "c8ae6de42edbfe2cff295a4b808beae783eacdfa9b431b090a59340548e03f138bb50dfca3e83ea6365b96fb4a86ee7d" + "content" : "ba535dfccf8eb23188059efa87d239b3ea1515d02ae2d66b6519e301dbb5a5d58775003490e7c899c02239523aaf0441" }, { "alg" : "SHA3-256", - "content" : "111d714be16afedf991598213fd8cab5f0b8ccaa6596ac6cb635b8bda62c61a0" + "content" : "b24eb906574fc11aaeb74026fc2e5554a24197a45c6010cf984123daa268bc3b" }, { "alg" : "SHA3-512", - "content" : "b6bc5cbb82b3cbcb250bbc1d1548084ca6d92bf3ced1bced111f4aa3bbfe6f77f87208d781165f49045262e4fc502d5a2349257046b08a6b8432ec2e6afc3819" + "content" : "82b09870019e5229a428519b5fdaca932abaa742512b01cbc7cae2c1891969a8e9f43face683b2f68f483587bad48168883a30baf91d52ef62c216d6f8f33e64" } ], "licenses" : [ @@ -1941,7 +1941,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -1959,45 +1959,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-transport-classes-epoll", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "5494b16595a173bc4353eb1fbc4b8033" + "content" : "544fc46a9f54b83567ffdff8700f0405" }, { "alg" : "SHA-1", - "content" : "4f2bd9bcd7c91768c1f9aca8e62870ece18527bd" + "content" : "bf7e09fc712f4c3fef6563fe121d4b424fede6f3" }, { "alg" : "SHA-256", - "content" : "9d9537ab9e15164c9f0dc0748884c148814a18d78ac6dfa65cf4b3d06068ce01" + "content" : "f6a0b631b98667f131daf4ca07a9cae1072d58e259dcbc0f8c6a053d843449c6" }, { "alg" : "SHA-512", - "content" : "1fb2bd89e154d771580ad2a14f954f3b0325bdf949c1cfe4102a62ff9eb53fd8b1c45373206262a64b644016302cf13f8ae8dfc8a757350402212f82b1dc4e8a" + "content" : "1198533994e14eb5f4afbbe0db83f6cafcfd2d1a58138a911386cfb8f35d554e733277bb1d079e0b63bf34c5f15eb2fa913e29ddb647f71a2b3d78fc9b4a9715" }, { "alg" : "SHA-384", - "content" : "d676506b94103de1b1adc82083eb84e4132c4419ee838a0cdbadef251a835d61bb427e3b4a258fd5d02493615ef67107" + "content" : "765cc327cadbe46d5230aa24d806d48a210f860e4d8575f700fe8b49788a0241efbf4f08bbe2aa9bd3a2e86485e47a7e" }, { "alg" : "SHA3-384", - "content" : "f71e38b5f318a186c0f405fa27a88f35a6029a64226730094db93f8714331d023775f84317d5bf4c6c9e40727f520e87" + "content" : "ae6158851bebb9fae34fbda6a2fd2466b167129c8588e260e17149a68bd29d4bd4b7391a2846c7e3ecb711fd88065d57" }, { "alg" : "SHA3-256", - "content" : "bc4e704f8a354fe07524d7eebe2852b8bcc7b61ef5dc285176f8c1d0867d9d08" + "content" : "fc5a54f07589f8e3a58e9bac8617b22f70a691f6741ee42ce4969e1a601669e6" }, { "alg" : "SHA3-512", - "content" : "1e2f5e1c527812bcda9830eac830242614842b2e0d1292f53bc0e7bddd9b6ab5816dfd0350c29cb2699b736f4e5319cce743bf3890ea117073cb350cda622e81" + "content" : "1f49a2f1c57a99b0edee3c40851b51cdf2d6187c6ac168ec0156f8a013e113b0d8145b15aae8ffc9009a5c4bea3a2ee000b10d8f8174357b5caecb2a2c698130" } ], "licenses" : [ @@ -2007,7 +2007,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -2091,45 +2091,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-handler-proxy@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-handler-proxy@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-handler-proxy", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "e981f38ac2e481c77e0685e5bcb0e284" + "content" : "b16d047bc4f63673b642984e973f08db" }, { "alg" : "SHA-1", - "content" : "df2a683563e8ca8442a49ddc9c2da9110dcc3cef" + "content" : "3172bd2f12e169a103140c8f82915376687aa22a" }, { "alg" : "SHA-256", - "content" : "75661010630a44468f0e85d7ed8be7779c0cb1369fe85d30799cedc52e9ed3b7" + "content" : "47400551f0444dea33629ee0c52d4e30856b2ddf00b12adf1881902957e74cf2" }, { "alg" : "SHA-512", - "content" : "367aff1b592e168775d8de14f5256a714a2bbbf85deb19a2b1c5d21f85d5bd067356dc028466080b6086301bf0a9df16be98286f0b4ddae6e1a679e3f68a58a5" + "content" : "8c8d99f25f8ae9a045a40e37b44b0b346126c4ddce8d149796c98d48c99a4b757e2f59df0f25c16e009f6bad63c0ce62b5117d3d74674df12135e0f94f01d8da" }, { "alg" : "SHA-384", - "content" : "fcd074ad72eea319a8c479f91a2ccf352199d32ea608dad8ec8f243e19e4cdbaeca3c3cdf9fdf568e73c77804b050f34" + "content" : "ac6b39a8f9794a778b0ee109237d9759d0f95a3763b9eeba0a541d179f42ea8647b65e4ef1ddb6791f47d1d761b6206d" }, { "alg" : "SHA3-384", - "content" : "bfd4c4c8f7a4f57239a824b442cf1ad5d11e70d7abf26794342b296bc8c1e59f510f6f7c082fca349e2cc1aa9c0d5fb8" + "content" : "e70f09b674ea3ec9092df0dfafdf3dd4eab14df925418e0dc23a2e3c22404af9b37d0bfd6b89cefd85cbab6ec86bb762" }, { "alg" : "SHA3-256", - "content" : "2bbd040bd29192dca98f50c9f28a711613d2bc72e1b38e20aaf91d7c8e6e0a7c" + "content" : "a60487c209137050a18453773bfa6a6ca51f7b08f1b95a9911b57148ba704a7b" }, { "alg" : "SHA3-512", - "content" : "b815dc49bfc7bccae35905aae97546c9156673f7c524ec1a0f505c8e7fc0e462710e611bc212c8b5ae803c2a77d458701ae02c54d127d18a0aeaa64ead762232" + "content" : "95cecf430d76f1e52219bd8a99f7f3d50160e6e2467056710476bb44eaed486c030e84b925b67da8820bbd91e23d20d0049deac7cca370a42df1a12d06d07533" } ], "licenses" : [ @@ -2139,7 +2139,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-handler-proxy@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-handler-proxy@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -2157,45 +2157,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/io.netty/netty-codec-socks@4.1.135.Final?type=jar", + "bom-ref" : "pkg:maven/io.netty/netty-codec-socks@4.1.136.Final?type=jar", "publisher" : "The Netty Project", "group" : "io.netty", "name" : "netty-codec-socks", - "version" : "4.1.135.Final", + "version" : "4.1.136.Final", "description" : "Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers and clients.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "f022acaa2b77f55e286168e22a868932" + "content" : "fc4f904e8b36adddf7e6da7b433fe1aa" }, { "alg" : "SHA-1", - "content" : "65675a46d40f11b27dce2593d05be327d083f5f2" + "content" : "9513dc426df3365f5422e5d83b0d40d2726f0460" }, { "alg" : "SHA-256", - "content" : "ec7a39e8d7d7e223014115a021273f011c3cb1e8fb187cbfb90a74e76d68c25c" + "content" : "341f47dbaac667a6e7e6cad4114218025f9755ced641bb0740fb69e34d29b421" }, { "alg" : "SHA-512", - "content" : "ab55a58bb253d2ad63adaef77e642f009902efa8749746798ad598e219ed487445ccc5f5c4dfaeb8a0616122b5b12257ef709c3ca834786152d7b887a674ec51" + "content" : "b004b9a30f536d53c7b15ef48757a0b26d9550459fede7b68d22a4821cf876a3268c059f49f89a71010cb160750f0ed2d519ed1715d262cb9ef56de314050b9a" }, { "alg" : "SHA-384", - "content" : "68d1053454ae05519e6092ec23c1b11986dac155e128dc6f3f249653bcf8c90e71e31c8db6c88b5691efe5cc26530dd0" + "content" : "42742f0902ef695eb82b7fd4118a11c37b5f01e971edfab27449d3e7fcd50c3fd98bb52508fad455dbb11c943108e910" }, { "alg" : "SHA3-384", - "content" : "bd2903d5efe990b81cfc9844cbfbfc2cf36d9f73a7d137bd89eda2590d507a622d0e89ff7d5f08d52be5d2944e71d360" + "content" : "0674aae29a22360a92e4278f81a187482a194b014fd170e99d1d70f4ef0b6ff4bc6bee661139b0d6f7e123f538ddd501" }, { "alg" : "SHA3-256", - "content" : "5d6ebec22d5ae3695aea50fff14777bc1b1452477b593ad3d44127149bf9b883" + "content" : "07c8f65fe764c1135132f00c34c15494ce9b6bcce39c8baef849efcbcc6fd113" }, { "alg" : "SHA3-512", - "content" : "06686081615d84611543363cdb0398e9411fd7d7c47765b9cf4ee6a2599095c8d55aed91c21759b50e9507eb535b825623a4b5be39caa60444bec00b5cb22805" + "content" : "64ee3f092ee297b9a7e1e719952d777a4d38de56f6467a1923453b2f551545d9e26b24582d7332d72d39be2d23d2ccf7d073b4acc68fdcd6994e20d660b8ad3b" } ], "licenses" : [ @@ -2205,7 +2205,7 @@ } } ], - "purl" : "pkg:maven/io.netty/netty-codec-socks@4.1.135.Final?type=jar", + "purl" : "pkg:maven/io.netty/netty-codec-socks@4.1.136.Final?type=jar", "externalReferences" : [ { "type" : "website", @@ -3493,45 +3493,45 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar", + "bom-ref" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.8?type=jar", "publisher" : "The Apache Software Foundation", "group" : "org.apache.pdfbox", "name" : "pdfbox", - "version" : "3.0.3", + "version" : "3.0.8", "description" : "The Apache PDFBox library is an open source Java tool for working with PDF documents.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "15cd4480a886e22cd081537be5a11f14" + "content" : "92ed0441337f2ed1f43eeaa901bd7814" }, { "alg" : "SHA-1", - "content" : "a739bfc1b72d2f98d973cd1419f5ae2decd36068" + "content" : "c5d8c0b56aa156d64283f07729215a9938be2e69" }, { "alg" : "SHA-256", - "content" : "5be38d2ec81691b05d535eb720de4dc566c5d07e5a04731fa00668d153a8b4a6" + "content" : "97647cfbde61ebcfc06b4cf8c9b0ffcaaee073396eceb4a7f6836a9b9128903c" }, { "alg" : "SHA-512", - "content" : "84c51bce7ff6ebc9f7159a824a0de4ad575c6c546920cc776de494b1af7d28ba9f30c90a59ccd8ea25390c43567389e168535cc1da4b1d5aaad4cb8810b6c743" + "content" : "bd59e4918285dbed5d5a5e922368b139b2d3eed3e7b1102e726bbc69041328c82175826b92f341885f17f22c7af8438f4c5e676f25b90a8c062d42b1d7e5a57c" }, { "alg" : "SHA-384", - "content" : "6fd95bb4a5a20d9905ab1925e6721c4d57602f5a6aee4740f3fd152a50d042c63c945d6a48a67717a9529672f3945161" + "content" : "bf70e79d86149d82d3a475bf8be8bb19dedd88ebb975af4cb4e07b53c1ee70e6d53e18a83db2ce2b48918ea733014956" }, { "alg" : "SHA3-384", - "content" : "e6beb355db668b0cc477cb81163e4e70a9bb4cbce12021bc109c5f8f40270e06511d9e0989ac3744fdf163496631c5f5" + "content" : "420f994bff085278cc7f2061ec10373e5c0b39809cc6dff82986b0f4ed65bdca88cb3f65fb124066c6905c02f565430a" }, { "alg" : "SHA3-256", - "content" : "a47d4c4f31d25621ee4e77d534573d36a4502631a62ed2db9f1e6882695c927d" + "content" : "f54d3f1432e99a116d703f9d17dfca9adaf4cfe9e5dc2e57d1e6e22d7b9958b1" }, { "alg" : "SHA3-512", - "content" : "dd416a53eff57fbb8a5d41a6cb8d074a31352604ebd3749c2d0097a0d253d20dbb92682a291614c86ba7ed86901a4d40ce0ddf9850c8ac1cd32ee01037341ab3" + "content" : "e344572c8bf9a207113cb4a4f9ffc15e211d23413b791a4a8c009b4976eec9b4da39b3ef80d37b32f44a1e239c3b8c9148161f918d5452584472cf9d9a721364" } ], "licenses" : [ @@ -3542,7 +3542,7 @@ } } ], - "purl" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar", + "purl" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.8?type=jar", "externalReferences" : [ { "type" : "website", @@ -3562,51 +3562,51 @@ }, { "type" : "vcs", - "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.3/pdfbox" + "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.8/pdfbox" } ] }, { "type" : "library", - "bom-ref" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar", + "bom-ref" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar", "publisher" : "The Apache Software Foundation", "group" : "org.apache.pdfbox", "name" : "pdfbox-io", - "version" : "3.0.3", + "version" : "3.0.8", "description" : "The Apache PDFBox library is an open source Java tool for working with PDF documents. This artefact contains IO related classes.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "a348b5a7bdb7784fcf3d7ad7cc31d2b4" + "content" : "f45a2386cf4178b5955dda2807139745" }, { "alg" : "SHA-1", - "content" : "c40dcf555b72b1d8c0cd19391d63e5b58382b9cb" + "content" : "0953f459ebc08048b75afc3e823aea2e0466a01b" }, { "alg" : "SHA-256", - "content" : "123ea3187b497c54e661d50c3c867479bf77668ff450e50710c658f2bb4687ba" + "content" : "36a0e04001010b4c764857817412b96339930b19755e728959805cc0352061b2" }, { "alg" : "SHA-512", - "content" : "35a778196ccfb75959812cb17de5225413a99deb6b00e35533a32df9d5ba93fe6a9648765de8c03f242af5ab017580c1b5df4849370019867cd030fd2ea6b9fd" + "content" : "659c72ecfa9743df09321ef594cb0e948cabff17d7aed01583f78ec344e2126e6c75506abc43bc36369c0692bf09ea9e81b956ea9a3e28af29dd5c86bae9ff7b" }, { "alg" : "SHA-384", - "content" : "ca41c4bba6371ba6edf7d00f9bcacb83466a857b391b3bcf08b1e59e370241dac7607ebb164b97dc5c0e697f2f876f23" + "content" : "6df807534a26d19789036801d0d36e7efc10e53e854160732cf5827219c58f1a9fd8c1f84e9d8a8d08afec05545b81bc" }, { "alg" : "SHA3-384", - "content" : "41eca88d9e1daf1ea73761d6c3e5d47a699debcc546fc2216dc72231e449a1312220d17f46b0f7b8db745c82411eea84" + "content" : "83692991b95310b6d0a20dd5f591153740286615856ff72939df44628f0221d9f9f02275a8707bf59af7d12ed941bb05" }, { "alg" : "SHA3-256", - "content" : "118a036da75c311d6812d984f8f5788fcd2f3c4e6260dd744920cae920ece2ee" + "content" : "8a5620ef92655e317e1c8a9301d2a57ccd672354e6dd37fe24d5d9224867699e" }, { "alg" : "SHA3-512", - "content" : "710b5b1c4ec3a6f19201f0c9695b6c5601cfa9cf06776e431c642860e316e936971db1a58f35a8b6c8325041258e8c6f9ac8f081f9d820a181e1742692bccc4c" + "content" : "3b56fe5e9b77d3206ba4fb342c4a7b9329f357baf625bc5686cc89cc3df6057bcad526c90707ce2168733b3ca6113733abb9a96f1ccffbf04bfb1865b8ce4cdf" } ], "licenses" : [ @@ -3617,7 +3617,7 @@ } } ], - "purl" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar", + "purl" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar", "externalReferences" : [ { "type" : "website", @@ -3637,51 +3637,51 @@ }, { "type" : "vcs", - "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.3/io" + "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.8/io" } ] }, { "type" : "library", - "bom-ref" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.3?type=jar", + "bom-ref" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.8?type=jar", "publisher" : "The Apache Software Foundation", "group" : "org.apache.pdfbox", "name" : "fontbox", - "version" : "3.0.3", + "version" : "3.0.8", "description" : "The Apache FontBox library is an open source Java tool to obtain low level information from font files. FontBox is a subproject of Apache PDFBox.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "05adabd366e6f8a22ac04c293237dd89" + "content" : "379e6faf6ea614ad3318eda879e4f423" }, { "alg" : "SHA-1", - "content" : "9eebd1ee868a79fcf7390283b2baf4179dadb8ed" + "content" : "e9f4225dc564b212ecdc31d1574f2d0c1dc3ad30" }, { "alg" : "SHA-256", - "content" : "65690c3f39b04a14d12c17f4998c15186ce877d3e2ec222c708577e3cc028030" + "content" : "a1915c24e3edbe0ecec93896dfbf6d41427810b663ade97bd4e8bae86ec3fdab" }, { "alg" : "SHA-512", - "content" : "4e88662f50d0ecafe2bfccdbe3164a1ec01ab8ed451e8727f7c344794e53ee735fa1a4b584b8483795c905d13949eede22de417135e72652cd341d7317ef57a5" + "content" : "fdc1a5bcb016280c561e20684db77cba126660872f6ad03440596b8817ed97452e80237e0c3e41f8914711dac96abb8da8d1f37014eef7a7433fff85aff953b7" }, { "alg" : "SHA-384", - "content" : "41dfedf5735484e433cc8aae7e81fa388b5091ba6057940242960dfc78c873a48ddfe6c9245cfa01bd3e28ead30f7c32" + "content" : "a003c4f5a10df5885ae07dd485a4e2932bb9dfc1d858f44d582b6254e72171954e341f15b07f96f9fc745e47b2dfc6a5" }, { "alg" : "SHA3-384", - "content" : "79c39834996bc7fb66138f7e82dc1a64add1442a914f52710a5be006398bdcae50a43b2d98abd35d469bdb8a45a79d14" + "content" : "019569e5e5043e27220becc49b14d781c9e96ad3a20156b1e24249e8c36b4a2d4a6373b64b8640dea9b2c05dca9bda8f" }, { "alg" : "SHA3-256", - "content" : "98db31e368cb097e3e5928f1db6220f16ddd144b276471b2a8aa560cf8fdc89a" + "content" : "1d7995e3c97eab0405539ce436aac2b4f832a3ca7cb8628346e05955fb85c42c" }, { "alg" : "SHA3-512", - "content" : "8cc1fcf1d602d81b4680e5d34ae8bea76ffbf4e678be30fbe1fffa0d773b15d40e20ad9bb9f61e5af10dde46fb7e8df15e85ce83a40d37ddee55ee0d13c4cbc2" + "content" : "b00c2d71f06b9a754c5dbd667efad8edf2ac10fca14f2d222dcd09d8d1f3379a6675f7eb2a003ca7ea234ca8f67dcc7dd3d6872ce0b1401d4a13f4dd3bbd7dc0" } ], "licenses" : [ @@ -3692,7 +3692,7 @@ } } ], - "purl" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.3?type=jar", + "purl" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.8?type=jar", "externalReferences" : [ { "type" : "website", @@ -3712,51 +3712,51 @@ }, { "type" : "vcs", - "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.3/fontbox" + "url" : "https://svn.apache.org/viewvc/pdfbox/tags/3.0.8/fontbox" } ] }, { "type" : "library", - "bom-ref" : "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar", + "bom-ref" : "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar", "publisher" : "The Apache Software Foundation", "group" : "commons-logging", "name" : "commons-logging", - "version" : "1.3.3", + "version" : "1.4.0", "description" : "Apache Commons Logging is a thin adapter allowing configurable bridging to other, well-known logging systems.", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "62de1aea096b3ac52e46b908dac4ac97" + "content" : "954e27d33e55e587a4694d5952a0c5c6" }, { "alg" : "SHA-1", - "content" : "580ad1a4f34876c4f964c083361de31b3d60be68" + "content" : "e8f6313365dfa0580e49c58837afc8caa9b4ce05" }, { "alg" : "SHA-256", - "content" : "5828f96c09d886f9b1a0993c7804b27cf4fcec8534517164f5137ac8b67ea9b9" + "content" : "d175dbd751dd782a63bde28c7a039520e971f25e84b79c19b8435edc3603e0dc" }, { "alg" : "SHA-512", - "content" : "86adf089e9d5723bce8d4e0dcd0a7590c3aa27eed3982c8fde49f865dccb262043df8b47b2e721258fa8a880d0e32defa3406e9dd48ccc54c5d34793c919f36b" + "content" : "5c333d925f81fdd09a80176ff15cd38381eb12e9ec2dac75f113d66006002175b33cdd9a0668100c5c6a49ae81f9f576909b89219a345a0075ed4683a997570a" }, { "alg" : "SHA-384", - "content" : "b25673d250c7043c55ba65ba8160e4b8814ab99691f3a3c51e6069bbec330503a74e42a37bc00969beff19fcce4c6a35" + "content" : "31ed518d623d408894a188878693fd953a3aefebebdddc4a304769bf2862ae70623340cdea387b911a62ef7cb9043a37" }, { "alg" : "SHA3-384", - "content" : "f4c459c29b7f4cd9665f4110acb41370449db898512f95a424f2a06d2119dc22bf052a21d7896a3aa967603a010e0b59" + "content" : "1c91fab5c64bfa679da2186cdef39a0360c9d18122bf37b4dd932eee4502527e8458b5c849dbcc11e46ef5fb87d1a777" }, { "alg" : "SHA3-256", - "content" : "27483413bbea96155f333884b9c1ba0d776e4ce338674fa4ba7cc33af1073196" + "content" : "2189847ad7dd3d50bd17aab9dab8b31c8e7e25e72ca0d25ad83a4215bbfb5e2d" }, { "alg" : "SHA3-512", - "content" : "6af0c80bb419e4604f525e4fb5bd4e2cd373bc07daa106bef3d87af11c93759b2216e1ff6b1061017082145fc877c65b9cb079ef4a6acd4d16806de2fb47019b" + "content" : "f57be786553cc10966ce4af25ce401125ae76f6ee569812eafd67a6eaff06618ae835d637e5e198bd121463afa4993db61975f4ed0380fe1dcbfe3108b3ada51" } ], "licenses" : [ @@ -3767,7 +3767,7 @@ } } ], - "purl" : "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar", + "purl" : "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar", "externalReferences" : [ { "type" : "website", @@ -3775,7 +3775,7 @@ }, { "type" : "build-system", - "url" : "https://github.com/apache/commons-parent/actions" + "url" : "https://github.com/apache/commons-logging/actions" }, { "type" : "distribution-intake", @@ -3797,44 +3797,44 @@ }, { "type" : "library", - "bom-ref" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.0.227?type=jar", + "bom-ref" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.1.200?type=jar", "group" : "org.webjars.npm", "name" : "pdfjs-dist", - "version" : "6.0.227", + "version" : "6.1.200", "description" : "WebJar for pdfjs-dist", "scope" : "required", "hashes" : [ { "alg" : "MD5", - "content" : "72411f297134490511fcaafd566bf4e4" + "content" : "f1f7565a3639df404408f1770ea112c7" }, { "alg" : "SHA-1", - "content" : "308f852590def6b814c707312ccbb814f4d42fb5" + "content" : "92fb124655143e47cb0b3dcb902add53aa9cede3" }, { "alg" : "SHA-256", - "content" : "3943ada724d106abbc8f1f231087f2c09b7bed0b34e0a94a0f575f7dea6d3b99" + "content" : "22639fd7614aed05df9a06861e07540e11a2790bb820e008c17269425b68890e" }, { "alg" : "SHA-512", - "content" : "eb7fdf934e9d9b1dda84fb68519ab065dfc4e50958efe1f565dbaf8c03d1aaf06bf9629a57d02392da978cace39f6e125b597cbc9523fc667982faf43fe87c77" + "content" : "cf0b7394a2fd8db0cff1624f7141a810a1e7c34e51226c03c0d4582711e37edb82a48119c388788dcb26f5c5171528566431460ae30840e1e0b4e6c42494e202" }, { "alg" : "SHA-384", - "content" : "a178dc7a1bf0b98f569456a6c8b03ad3b781eb5b0a9786910e383bdcb75b5de86f6eb93b4313f204b1547f0e29d99fd6" + "content" : "c31dd289b74a7154cb86c5bf8a7ca204dfe0659f28cbcb981c2639a867af62427f77a346a24f86667bbef42d6ed05e89" }, { "alg" : "SHA3-384", - "content" : "59c16ea9820319e1b7af774d861788c8ba0f653a18cccab5e3e908abf3759017402844ecfa8ff29d6df50ebe480614ec" + "content" : "82260e56e7177a87633ec72b486d4f151d5af3aa3a646e8a59b5cfd3235f4f302145436ad54daeeb75e5e2594e42cc87" }, { "alg" : "SHA3-256", - "content" : "b54d499a0186e9ecdcec53a2ffda8c895419e1c43915578aa3991d52bc06b42d" + "content" : "e44c08ce546326f4ab445d94348cdb281ac94eafd866b2d3b6645b4f625aa650" }, { "alg" : "SHA3-512", - "content" : "50c2486ac3b73637b8d55a624fc6703bec5a7fa7646f310ff3109490a5b446288701c58263219a6f3476821e2e089bcbce7f767fe6746e5a67dd4d013177eb28" + "content" : "71eabf677a2fc65cdff96eb79c3e72773a5506f1c3315274d7955f0621c96cfe53d400ff312fdb59105d2753e744e76b0dd166b99e3342ea2c517f6a2c700450" } ], "licenses" : [ @@ -3845,7 +3845,7 @@ } } ], - "purl" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.0.227?type=jar", + "purl" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.1.200?type=jar", "externalReferences" : [ { "type" : "website", @@ -4211,8 +4211,8 @@ "pkg:maven/org.springframework.boot/spring-boot-starter-webflux@3.5.16?type=jar", "pkg:maven/org.springframework.boot/spring-boot-starter-validation@3.5.16?type=jar", "pkg:maven/org.springframework.boot/spring-boot-starter-log4j2@3.5.16?type=jar", - "pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar", - "pkg:maven/org.webjars.npm/pdfjs-dist@6.0.227?type=jar", + "pkg:maven/org.apache.pdfbox/pdfbox@3.0.8?type=jar", + "pkg:maven/org.webjars.npm/pdfjs-dist@6.1.200?type=jar", "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.22.1?type=jar" ] }, @@ -4366,170 +4366,170 @@ { "ref" : "pkg:maven/io.projectreactor.netty/reactor-netty-http@1.2.18?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec-http2@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar", - "pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar", + "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec-http2@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar", + "pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar", "pkg:maven/io.projectreactor.netty/reactor-netty-core@1.2.18?type=jar", "pkg:maven/io.projectreactor/reactor-core@3.7.19?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", "dependsOn" : [ ] }, { - "ref" : "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-codec-http2@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-codec-http2@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-resolver@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec-dns@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-resolver@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec-dns@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-codec-dns@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-codec-dns@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar", + "ref" : "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-resolver-dns-classes-macos@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar", + "ref" : "pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-transport-classes-epoll@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport-native-unix-common@4.1.136.Final?type=jar" ] }, { "ref" : "pkg:maven/io.projectreactor.netty/reactor-netty-core@1.2.18?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-handler@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-handler-proxy@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-resolver-dns@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.135.Final?classifier=osx-x86_64&type=jar", - "pkg:maven/io.netty/netty-transport-native-epoll@4.1.135.Final?classifier=linux-x86_64&type=jar", + "pkg:maven/io.netty/netty-handler@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-handler-proxy@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-resolver-dns@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-resolver-dns-native-macos@4.1.136.Final?classifier=osx-x86_64&type=jar", + "pkg:maven/io.netty/netty-transport-native-epoll@4.1.136.Final?classifier=linux-x86_64&type=jar", "pkg:maven/io.projectreactor/reactor-core@3.7.19?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-handler-proxy@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-handler-proxy@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec-socks@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec-http@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec-socks@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec-http@4.1.136.Final?type=jar" ] }, { - "ref" : "pkg:maven/io.netty/netty-codec-socks@4.1.135.Final?type=jar", + "ref" : "pkg:maven/io.netty/netty-codec-socks@4.1.136.Final?type=jar", "dependsOn" : [ - "pkg:maven/io.netty/netty-common@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-buffer@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-transport@4.1.135.Final?type=jar", - "pkg:maven/io.netty/netty-codec@4.1.135.Final?type=jar" + "pkg:maven/io.netty/netty-common@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-buffer@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-transport@4.1.136.Final?type=jar", + "pkg:maven/io.netty/netty-codec@4.1.136.Final?type=jar" ] }, { @@ -4620,32 +4620,32 @@ ] }, { - "ref" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.3?type=jar", + "ref" : "pkg:maven/org.apache.pdfbox/pdfbox@3.0.8?type=jar", "dependsOn" : [ - "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar", - "pkg:maven/org.apache.pdfbox/fontbox@3.0.3?type=jar", - "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar" + "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar", + "pkg:maven/org.apache.pdfbox/fontbox@3.0.8?type=jar", + "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar" ] }, { - "ref" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar", + "ref" : "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar", "dependsOn" : [ - "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar" + "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar" ] }, { - "ref" : "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar", + "ref" : "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar", "dependsOn" : [ ] }, { - "ref" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.3?type=jar", + "ref" : "pkg:maven/org.apache.pdfbox/fontbox@3.0.8?type=jar", "dependsOn" : [ - "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.3?type=jar", - "pkg:maven/commons-logging/commons-logging@1.3.3?type=jar" + "pkg:maven/org.apache.pdfbox/pdfbox-io@3.0.8?type=jar", + "pkg:maven/commons-logging/commons-logging@1.4.0?type=jar" ] }, { - "ref" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.0.227?type=jar", + "ref" : "pkg:maven/org.webjars.npm/pdfjs-dist@6.1.200?type=jar", "dependsOn" : [ ] } ] diff --git a/docs/security/2026-07-02-auth-tenant-model.md b/docs/security/2026-07-02-auth-tenant-model.md index d81a2a12..d6babd80 100644 --- a/docs/security/2026-07-02-auth-tenant-model.md +++ b/docs/security/2026-07-02-auth-tenant-model.md @@ -1,6 +1,7 @@ # Auth, RBAC, and Tenant Model Date: 2026-07-02 +Last updated: 2026-08-09 This document defines the production authorization contract needed before Clearfolio Viewer can claim tenant-safe preview access. It now includes the @@ -44,7 +45,7 @@ Current buyer-demo runtime headers: - `X-Clearfolio-Tenant-Id: buyer-demo` - `X-Clearfolio-Subject-Id: buyer-demo-operator` -- `X-Clearfolio-Permissions: job:create,job:read,job:retry,viewer:read,artifact-link:create,analytics:read` +- `X-Clearfolio-Permissions: job:create,job:read,job:retry,viewer:read,artifact:read,artifact-link:create,analytics:read` These headers are a runtime enforcement scaffold. In unsigned demo mode they are not a cryptographic identity proof. When @@ -108,13 +109,24 @@ to the identity provider or gateway, not the viewer service. Server-side authorization must check both permission and tenant ownership. A matching permission without matching `tenantId` is insufficient. +Artifact-byte authorization deliberately has two independent layers when the +endpoint contract requires them: + +1. tenant authorization (`artifact:read` plus same-tenant ownership), and +2. signed artifact-delivery authority (signature, expiry, `artifact:read` scope, + document/tenant/checksum binding, issued-token ledger, revocation, canonical + single-Range handling, and controlled read-audit evidence). + +Possessing the tenant permission does not bypass the signed token boundary, and +possessing a signed token does not bypass an endpoint's tenant authorization. + ## Resource Ownership Rules | Resource | Tenant binding | Access rule | | --- | --- | --- | -| Conversion job | `job.tenantId` | Caller `tenantId` must match before status, viewer bootstrap, retry, or analytics drill-down. | +| Conversion job | `job.tenantId` | Caller `tenantId` must match before status, direct download, viewer bootstrap, retry, or analytics drill-down. | | Source document metadata | `document.tenantId` | Exposed only through job/viewer APIs after permission check. | -| Preview artifact | `artifact.tenantId` and `artifactChecksum` | Read only through short-lived signed artifact token. | +| Preview artifact | `artifact.tenantId` and `artifactChecksum` | Read through the short-lived signed artifact-delivery contract. The direct job-download route additionally requires dedicated `artifact:read` and matching job tenant before artifact-store access, then validates signature, expiry, scope, document/tenant/checksum binding, issuance and revocation before returning bytes. | | Artifact link | `artifactLink.tenantId` and `tokenId` | Revocable by operator or tenant admin in the same tenant. | | Metrics event | `event.tenantId` | Aggregate views must filter tenant unless explicitly buyer-demo scoped. | | Audit event | `audit.tenantId` | Read by operator, tenant admin, or buyer reviewer for scoped evidence. | @@ -125,60 +137,88 @@ unauthorized action, depending on route semantics. ## API Enforcement Matrix -| API | Required permission | Tenant check | +| API | Required permission / signed authority | Tenant and artifact checks | | --- | --- | --- | | `POST /api/v1/convert/jobs` | `job:create` | Assign job to caller `tenantId`. | | `GET /api/v1/convert/jobs/{jobId}` | `job:read` | `job.tenantId == token.tenantId`. | +| `GET /api/v1/convert/jobs/{jobId}/download` | tenant `artifact:read` **and** valid signed artifact token | `401` when tenant claims or the signed token are missing/structurally invalid/signature-invalid/expired; `403` when tenant permission is missing or signed scope/document/ledger/revocation/checksum authority fails; `404` for missing or cross-tenant job and missing artifact; `409` until the owned job is `SUCCEEDED`; `416` for invalid, multi-range, or unsatisfiable Range; `200` for a verified full read; `206` for a verified single-range read. Verified full, partial, and rejected-range reads are audited. | | `POST /api/v1/convert/jobs/{jobId}/retry` | `job:retry` | Same tenant plus operator role. | -| `GET /api/v1/viewer/{docId}` | `viewer:read` | `job.tenantId == token.tenantId`; artifact tokens are enforced in the signed-link slice. | +| `GET /api/v1/viewer/{docId}` | `viewer:read` | `job.tenantId == token.tenantId`; bootstrap issues signed artifact link for ready jobs. | | `GET /viewer/{docId}` | none for HTML shell | Shell does not inspect job existence; protected JSON APIs decide state. | | `POST /api/v1/viewer/{docId}/artifact-links` | `artifact-link:create` | Same tenant and succeeded job. | -| `GET /artifacts/{docId}.pdf` | `artifact:read` | Signed artifact token tenant and checksum must match. | +| `GET /artifacts/{docId}.pdf` | valid signed artifact token | Signed token scope/document/tenant/current checksum/issuance/revocation must match; zero or one Range; record read audit. | | `GET /api/v1/analytics/kpi-snapshot` | `analytics:read` | Tenant-scoped aggregate by default. | -Current implementation status: - -- Implemented: `job:create`, `job:read`, `job:retry`, `viewer:read`, and - `analytics:read` permission checks on JSON APIs. +## Current Branch Implementation Status + +The bullets in this section describe the current branch under review. They do +not become protected-main release evidence until the unchanged exact head passes +all repository gates and integrates. + +- Implemented: `job:create`, `job:read`, `job:retry`, `viewer:read`, + `artifact:read`, and `analytics:read` permission checks on JSON APIs. +- Implemented: direct conversion-job downloads validate dedicated + `artifact:read` before resource lookup, enforce same-tenant ownership before + artifact-store access, and conceal cross-tenant UUID access as `404`. +- Implemented on the current branch: direct downloads now reuse + `ArtifactLinkService` signed-delivery verification rather than returning bytes + on tenant permission alone. Missing/invalid tokens fail closed, revoked tokens + are rejected, token scope/document/tenant/current-checksum/issuance state is + validated, zero-or-one Range semantics are shared with canonical artifact + delivery, and verified full/partial/rejected-range reads emit controlled audit + evidence. - Implemented: `ConversionJob.tenantId` and `ConversionJob.subjectId`. - Implemented: tenant-aware content-hash dedupe so two tenants do not collapse onto one canonical job for the same upload bytes. -- Implemented: cross-tenant status, retry, and viewer-bootstrap lookup returns - `404` without revealing the other tenant's job. +- Implemented: cross-tenant status, direct download, retry, and viewer-bootstrap + lookup returns `404` without revealing the other tenant's job. - Implemented: KPI snapshots filter to the request tenant. - Implemented: optional HMAC validation for gateway-signed tenant headers when `clearfolio.tenant-claims.hmac-secret` is configured. - Implemented: `production` Spring profile startup fails when signed tenant claim secret is missing. -- Not implemented: OIDC/JWT signature, issuer, audience, expiry, revocation, and - role mapping. +- Not implemented: production OIDC/JWT signature, issuer, audience, expiry, + revocation, and role mapping. - Implemented: signed artifact link creation and artifact token verification - for current in-memory PDF artifacts. + for current PDF artifacts. - Implemented: runtime artifact token ledger, tenant-scoped token revocation, and artifact read audit-event API. -- Not implemented: durable artifact metadata, externally persisted revocation - state, persisted artifact audit events, and production key management. +- Not implemented: durable distributed artifact metadata/revocation/audit state + and production external key-management integration. + +## Artifact Delivery Failure Semantics -## Error Semantics +The direct-download rows below are executable contracts and are covered by +`ConversionDownloadAuthorizationTest`; the canonical `/artifacts/{docId}.pdf` +route uses the same signed-token and single-range authority. -| Condition | Status | Error code | +| Condition | Status | Contract | | --- | ---: | --- | -| Missing token | 401 | `AUTH_TOKEN_REQUIRED` | -| Invalid token or signature | 401 | `AUTH_TOKEN_INVALID` | -| Expired token | 401 | `AUTH_TOKEN_EXPIRED` | -| Missing permission | 403 | `AUTH_FORBIDDEN` | -| Wrong tenant | 403 or 404 | `TENANT_RESOURCE_FORBIDDEN` | -| Revoked token | 401 | `AUTH_TOKEN_REVOKED` | -| Unknown issuer or audience | 401 | `AUTH_TOKEN_INVALID` | +| Missing tenant claims | 401 | Fail before job or artifact lookup. | +| Missing tenant `artifact:read` permission | 403 | Fail before job or artifact lookup. | +| Missing job or cross-tenant job | 404 | Conceal cross-tenant existence and do not read artifact bytes. | +| Owned job not yet `SUCCEEDED` | 409 | Do not expose bytes from submitted, processing, or failed work. | +| Missing stored artifact for an owned succeeded job | 404 | Do not convert missing bytes into a successful response. | +| Missing signed artifact token | 401 | Fail before document bytes are returned. | +| Malformed token, invalid signature, or expired token | 401 | `ArtifactLinkService.verifyReadToken()` treats these as authentication failures without exposing token internals. | +| Signed token scope is not `artifact:read` | 403 | Signed authority does not include artifact-byte access. | +| Signed token document id does not match the route job id | 403 | Prevent token reuse across documents. | +| Signed token is absent from the issued-token ledger | 403 | A structurally valid token is not sufficient without issuance evidence. | +| Revoked issued artifact token | 403 | Preserve revocation without falling back to tenant permission. | +| Signed token ledger tenant/document/checksum binding mismatch | 403 | Issuance evidence must match the verified claim. | +| Signed token job tenant mismatch | 403 | Signed authority must remain bound to the owned job tenant. | +| Current artifact checksum differs from signed checksum | 403 | Prevent reuse after artifact replacement. | +| Valid request without `Range` | 200 | Return the verified full artifact with `Accept-Ranges`, `no-store`, `nosniff`, attachment disposition, checksum, and read audit. | +| Valid single `bytes` Range | 206 | Return one bounded slice with `Content-Range`, `Accept-Ranges`, attachment disposition, checksum, and read audit. | +| Invalid syntax, unsupported unit, multi-range, or unsatisfiable Range | 416 | Do not silently serve a whole artifact; record the rejected verified read. | +| Unknown OIDC issuer/audience (future IdP path) | 401 | Production identity integration remains planned. | Error payloads must keep the existing shared API shape and must not include raw tokens or cross-tenant identifiers. Current scaffold note: the shared `ApiExceptionHandler` emits HTTP status names -as `errorCode` values, so missing tenant headers currently return -`errorCode=UNAUTHORIZED` with message `auth token required`, and missing -permissions return `errorCode=FORBIDDEN`. Auth-specific error codes can replace -those once the OIDC/JWT validator is introduced. +as `errorCode` values for tenant-claim failures. Artifact-token delivery returns +controlled low-information HTTP failures and does not echo token content. ## Audit Events @@ -192,7 +232,7 @@ those once the OIDC/JWT validator is introduced. | `artifact.link.revoked` | `tenantId`, `operatorId`, `tokenId`, `reason`, `traceId` | | `artifact.read` | `tenantId`, `subjectId`, `docId`, `tokenId`, `rangeRequested`, `statusCode`, `traceId` | -Store token fingerprints, not raw tokens. +Store token fingerprints or controlled token identifiers, not raw tokens. ## Buyer Acceptance Criteria @@ -200,6 +240,11 @@ Store token fingerprints, not raw tokens. tenant boundary. - Every write or sensitive read has a server-side permission check. - Artifact reads use signed artifact tokens, not bare `docId` capability URLs. +- Direct conversion-job downloads require authenticated `artifact:read`, + same-tenant ownership, a valid non-revoked signed artifact token bound to the + current artifact checksum, the canonical zero-or-one Range profile, and + controlled read-audit evidence; `job:read` or `artifact:read` alone never + authorizes document bytes. - Operator retry requires an operator permission and is auditable. - KPI snapshots can be shown for one tenant without leaking another tenant's volume, latency, or failure rate. @@ -212,17 +257,23 @@ Store token fingerprints, not raw tokens. buyer-demo runtime. 2. Done: add `tenantId`, `subjectId`, and permission checks to conversion job metadata and JSON API paths. -3. Done: enforce `job:create`, `job:read`, `job:retry`, `viewer:read`, and - `analytics:read` on existing JSON routes. +3. Done: enforce `job:create`, `job:read`, `job:retry`, `viewer:read`, + `artifact:read`, and `analytics:read` on existing JSON routes. 4. Done: add tenant-scoped KPI projection from current in-memory jobs. 5. Done: add optional gateway-signed tenant headers with HMAC and timestamp skew controls. 6. Done: fail closed for `production` profile when the tenant-claim signing secret is absent. 7. Next: replace demo headers with validated gateway/OIDC JWT claims. -8. Done: add signed artifact link creation and token verification. -9. Next: add durable revocation, persisted audit events, and CI/contract tests - for production token rejection paths. +8. Done: add signed artifact link creation, issued-token ledger, revocation, + current-artifact checksum binding, Range handling, and read auditing. +9. Done on the current branch: route direct conversion-job downloads through the + same signed artifact-delivery authority while preserving dedicated tenant + `artifact:read` and cross-tenant `404` concealment. +10. Next: move token issuance/revocation/read-audit and job lifecycle evidence + from process/local-ledger boundaries to the reviewed durable distributed + persistence design; add production key-management integration and end-to-end + IdP rejection contracts. No library split is justified until a second Clearfolio service or external SDK needs to reuse this authorization contract. diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md new file mode 100644 index 00000000..497679fa --- /dev/null +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -0,0 +1,117 @@ +# Audit identifier pseudonymization + +## Decision + +Clearfolio must not write raw approver identifiers or approval tokens to application logs. Policy-override audit events use a domain-separated keyed HMAC for the approver identifier and a non-reversible token fingerprint for the already high-entropy approval signature. Authentication-token handling is outside this policy-override logging contract and remains governed by the repository-wide logging and authorization controls. + +The approver field is named `approverFingerprint`, not `approverId`, so downstream log consumers cannot mistake pseudonymous data for the source identifier. Pseudonymized values remain personal data when they can be related back to a person using separately held information; they are not treated as anonymized data. + +## Cryptographic contract + +### Policy override key + +A configured `conversion.policy-override-secret` authorizes blocked-document policy exceptions and must contain at least 32 UTF-8 bytes. Blank or absent configuration keeps policy override disabled. A nonblank value below the minimum fails application startup and direct construction of the public validation service before any conversion endpoint or standalone module can accept traffic. Configuring a valid policy-override key without a dedicated audit pseudonym key fails through the same shared validation contract, because accepting an administrative exception without approver-correlatable audit evidence would make the security decision operationally unauditable. The gates measure encoded bytes rather than Java character count and never log supplied key material. + +Deployments must generate this key from a cryptographically secure random source and must not use a password, person or tenant identifier, repository token, or other human-memorable value. The minimum-length gate prevents a weak configured secret from reducing the effective security of the HMAC approval token even when the HMAC algorithm itself is correctly implemented (National Institute of Standards and Technology, 2008; Turan & Brandão, 2024). + +### Approver identifier + +The approver fingerprint is calculated as follows: + +```text +HMAC-SHA-256( + dedicated_audit_key, + UTF-8("clearfolio:audit-approver:v1\n" + exact_approver_identifier) +) +``` + +The first 128 bits are encoded as lowercase hexadecimal and prefixed by the non-sensitive key version: + +```text +:<32 lowercase hexadecimal characters> +``` + +The implementation preserves the exact Java string bytes supplied after the policy override has passed its existing identity validation. It does not lowercase, Unicode-normalize, or trim inside the pseudonymizer because those transformations would silently alter identity semantics. Null input produces `absent:`. An empty Java string is not absent: it is processed as a zero-length identifier through the same domain-separated HMAC and produces a normal versioned fingerprint. A missing dedicated key produces `unavailable:` only while policy-override signing is disabled and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. Once a policy-signing key is configured, a missing or blank audit key prevents both application startup and direct construction of an override-capable validation service. + +A configured audit pseudonym secret must contain at least 32 UTF-8 bytes and must be generated from a cryptographically secure random source. The byte-length gate prevents accidentally deploying a short human-memorable secret whose effective strength would bound the HMAC protection. Blank or absent configuration retains the explicit non-correlatable `unavailable` behavior only for deployments where policy override remains disabled; a nonblank weak key, or an absent key paired with an enabled policy-signing key, fails the shared configuration validation before traffic is accepted. FIPS 198-1 remains the current final NIST HMAC standard while NIST SP 800-224 remains an initial public draft; NIST expects the final SP to be published concurrently with withdrawal of FIPS 198-1 (National Institute of Standards and Technology, 2008, 2025; Turan & Brandão, 2024). + +Only an absent key-version property defaults to `v1`. Explicit blank, oversized, or unsafe key-version values fail application startup so one version label can never identify multiple key generations accidentally. The accepted format is one to 32 Java UTF-16 code units matching the implementation-equivalent expression `^[\p{L}\p{Nd}._-]{1,32}$`: each character must satisfy Java `Character.isLetterOrDigit` or be `.`, `_`, or `-`. The value is retained as a Java Unicode string and written by the configured log encoding; deployments use UTF-8 log output. Control characters, separators, whitespace, slashes, and other punctuation are rejected. + +### Approval token + +The approval token is a policy-override HMAC signature and is therefore already a high-entropy authentication value. The audit-only token fingerprint is calculated independently as follows: + +```text +SHA-256(UTF-8(exact_approval_token)) +``` + +The first eight digest bytes are encoded as 16 lowercase hexadecimal characters and written as `tokenFingerprint`. The fingerprint is unkeyed and has no domain prefix because it is used only as a short diagnostic correlation value for an already high-entropy signature; it must never be accepted as an authentication credential or used to validate a policy override. Null, empty, and blank approval tokens are rejected by request validation before fingerprinting, so the audit fingerprint function has no absent or empty sentinel contract. + +## Runtime secret loading + +Runtime key material is supplied through Spring Boot's config-tree property source rather than direct secret-bearing environment variables. The default mount is `/run/secrets/clearfolio/`; `CLEARFOLIO_SECRET_CONFIG_DIR` may select another bootstrap directory but must not contain a secret value. + +The secret store or orchestrator mounts files with these exact names: + +```text +conversion.policy-override-secret +conversion.audit-pseudonym-secret +conversion.audit-pseudonym-key-version +``` + +Spring reads each file's contents as the corresponding property. The deployment must restrict file ownership and mode, prevent inclusion in container images and support bundles, and avoid logging the imported values. If the optional config tree is absent, the application retains safe disabled defaults because policy override remains disabled. If a deployment supplies `conversion.policy-override-secret`, it must supply a distinct strong `conversion.audit-pseudonym-secret` in the same rollout; otherwise Spring startup fails before traffic is accepted. Standalone and MSA consumers that instantiate `DefaultDocumentValidationService` directly receive the identical fail-closed validation and therefore cannot bypass the key-strength, mandatory-audit-key, or key-separation rules by omitting the Spring container. + +## Key ownership and rotation + +- `conversion.policy-override-secret` is an authorization key owned by the security function. It must contain at least 32 UTF-8 bytes, be generated from a cryptographically secure random source, and be rotated through the deployment secret manager. +- `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. It is mandatory whenever `conversion.policy-override-secret` is configured. +- The configured audit value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier. +- The shared configuration guard used by Spring startup and direct validation-service construction rejects an enabled policy-signing key without a configured audit key and rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's guard. +- `conversion.audit-pseudonym-key-version` is a non-secret identifier such as `2026-08` but is mounted with the same versioned configuration bundle to keep key and label rotation atomic. +- Rotation changes both the secret and version. During an investigation that spans a rotation boundary, operators must treat fingerprints from different versions as intentionally unlinkable unless an approved, separately controlled re-identification process exists. +- Retired keys must not remain in application configuration. Any escrow or incident-response copy must be access-controlled, time-bounded, and audited. + +## Retention and access + +Audit log retention must be limited to the shortest period required by the documented security, contractual, and regulatory purpose. Read access is restricted by least privilege. Export, search, re-identification, and deletion workflows must be auditable. Logs and pseudonym keys must never be stored in the same access domain. + +## Incident response + +If the audit pseudonym key is suspected to be exposed: + +1. Rotate the key and version immediately. +2. Preserve affected log ranges under incident hold without broadening access. +3. Determine whether dictionary attacks against likely identifiers were feasible. +4. Treat exposed pseudonymized records as potentially exposed personal data. +5. Follow the applicable breach-assessment and notification process. +6. Verify that no raw identifiers, approval tokens, or key material were written to logs. + +## Verification requirements + +Automated tests must prove: + +- determinism within one key version and domain; +- separation across keys, versions, and domains; +- Spring-startup and direct-construction rejection of configured policy-override and audit keys shorter than 32 UTF-8 bytes; +- Spring-startup and direct-construction rejection when policy-override signing is enabled without a configured audit pseudonym key; +- acceptance of multibyte policy keys based on encoded byte length rather than character count; +- rejection of invalid explicit key versions; +- Spring-startup and direct-construction rejection when policy and audit purposes reuse the same nonblank key; +- distinct absent, empty, and unavailable approver behavior while policy signing is disabled; +- rejection of null, empty, or blank approval tokens before token fingerprinting; +- safe handling of Unicode and control characters; +- no raw approver identifier or approval token in captured policy-override audit output; +- stable failure behavior if the HMAC provider is unavailable; +- 100% JaCoCo line and branch coverage for the `com.clearfolio.viewer.*` production package. + +## References + +European Parliament and Council of the European Union. (2016). *Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation)*. *Official Journal of the European Union, L 119*, 1–88. + +National Institute of Standards and Technology. (2008). *The keyed-hash message authentication code (HMAC)* (FIPS PUB 198-1). U.S. Department of Commerce. https://doi.org/10.6028/NIST.FIPS.198-1 + +National Institute of Standards and Technology. (2025, June 23). *Proposed withdrawal of FIPS 198-1, HMAC*. Computer Security Resource Center. https://csrc.nist.gov/News/2025/proposed-withdrawal-of-fips-198-1-hmac + +OWASP Foundation. (n.d.). *Logging cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 4, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html + +Turan, M. S., & Brandão, L. T. A. N. (2024). *Keyed-hash message authentication code (HMAC): Specification of HMAC and recommendations for message authentication* (NIST SP 800-224 Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-224.ipd diff --git a/docs/security/2026-08-05-netty-4.1.136-remediation.md b/docs/security/2026-08-05-netty-4.1.136-remediation.md new file mode 100644 index 00000000..e9a8dd99 --- /dev/null +++ b/docs/security/2026-08-05-netty-4.1.136-remediation.md @@ -0,0 +1,131 @@ +# ADR: Align the Reactive HTTP Stack on Netty 4.1.136.Final + +- **Status:** Accepted +- **Decision date:** 2026-08-05 +- **Decision owners:** Clearfolio maintainers and security reviewers +- **Applies to:** `clearfolio-viewer` reactive HTTP runtime and every transitive `io.netty` module managed through Spring Boot + +## Context + +Clearfolio uses Spring Boot WebFlux, which resolves Reactor Netty and the Netty transport, codec, resolver, and handler modules transitively. Spring Boot 3.5.16 manages the Netty 4.1 line at `4.1.135.Final`. Exact-head Strix run `30997430437`, job `92277868841`, reported a HIGH dependency finding against that line and identified `4.1.136.Final` as the fixed 4.1 release. + +The Netty project released `4.1.136.Final` at 20:18 UTC on July 8, 2026. Its primary release record includes HTTP/1.1 and HTTP/2 boundary validation, MQTT decoder and UTF-8 validation, compression safety, parser-boundary, flow-control, and traffic-shaping fixes relative to `4.1.135.Final`. + +A partial dependency override would be unsafe because Netty is a coordinated family of modules. Mixing codec, transport, resolver, and handler patch levels can create an unreviewed runtime graph even when Maven resolves successfully. + +## Decision + +Set the Spring Boot-supported Maven property below in the root `pom.xml`: + +```xml +4.1.136.Final +``` + +This property changes the managed version for the complete Netty module family while retaining Spring Boot's dependency-management structure. Do not pin individual Netty artifacts independently unless an accepted follow-up ADR proves that a mixed module graph is required and compatible. + +Two executable contracts guard the decision: + +- `DependencyPolicyTest.pomPinsPatchedNettyLineForReactiveHttpServing` reads the real root POM through an XML parser configured to reject document types, external entities, parameter entities, XInclude, and entity expansion. +- `scripts/test_render_third_party_attribution.py` independently reads the single nonblank `netty.version` property, then proves that every committed Netty component version, purl, bom-ref, dependency edge, and attribution row resolves to that version. + +## Deterministic buyer-evidence flow + +The committed SBOM is generated evidence, not a hand-edited dependency inventory. The accepted generation path is: + +```mermaid +flowchart LR + H[Exact source head
3b6e434] --> M[Maven resolves
Spring Boot + netty.version] + M --> C[CycloneDX Maven Plugin 2.9.1
makeAggregateBom] + C --> B[target/bom.json
CycloneDX 1.6] + B --> V[Graph verifier
61 components / 17 Netty] + V --> A[Deterministic attribution renderer] + B --> I[Immutable Actions artifact
ID 8929593015] + A --> I + I --> D[Committed buyer data-room evidence] + D --> T[Permanent drift and dependency tests] +``` + +The exact generation command is: + +```bash +mvn -B --no-transfer-progress -DskipTests \ + org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom \ + -Dcyclonedx.skipAttach=true \ + -DoutputFormat=json \ + -DoutputName=bom +``` + +The plugin writes the canonical JSON document to `target/bom.json`. The `outputFormat` and `outputName` parameters are Maven user properties without a `cyclonedx.` prefix; only `cyclonedx.skipAttach` uses that prefix in this invocation. + +## Evidence record + +Read-only workflow run `31004040777` generated the accepted evidence from source head `3b6e43426790ab8590c9ef50656bfb5cbbb206ce` at `2026-08-05T12:07:15Z`. + +- Artifact ID: `8929593015` +- Artifact archive SHA-256: `07a0325e08157f00dda28c58ed4e41af51863cccb2ceea2c4e378ead77dc337f` +- CycloneDX version: `1.6` +- Total components: `61` +- Netty components: `17` +- Netty version set: exactly `4.1.136.Final` +- SBOM SHA-256: `e138a9263edb40c613d5f159acba8fa89ee848a7cef4b6619e095c48451b095c` +- Attribution SHA-256: `e19a3767a545bd059e50003882d8ff2f8a3ff4d3b8fd28d3f305eead61261da9` + +The graph verifier proved that every Netty dependency reference has a corresponding current Netty component ref and that `4.1.135.Final` is absent from both generated files. The attribution renderer was rerun from the generated JSON and matched the committed Markdown byte contract. + +The committed SBOM and attribution are shareable buyer evidence. Workflow logs and the one-day artifact are transient generation provenance and must not be presented as durable data-room evidence after expiry. Reproduction therefore depends on the documented command, exact source revision, immutable plugin version, committed hashes, permanent drift tests, and fresh exact-head CI. + +## Security and compatibility boundaries + +- The override changes only the Netty patch line. It does not change Spring Boot, Spring Framework, Reactor, or the public Clearfolio API. +- Maven must resolve all applicable `io.netty` modules to `4.1.136.Final`; stale modules at `4.1.135.Final` or an older version are a release blocker. +- Existing zero-missed-line and zero-missed-branch JaCoCo gates, compiler warnings-as-errors, fuzzing, SAST, dependency review, OSV, Trivy, Scorecard, Strix, and independent review remain mandatory. +- A successful unit-test run does not replace dependency-tree and security-scanner evidence. +- The override must not be copied into downstream modules as separate ad hoc pins. Standalone builds inherit the root property; modular consumers should use a versioned BOM or equivalent explicit contract. +- The evidence record describes the dependency graph of its exact generation head. Any dependency change requires regeneration and a new evidence hash record. + +## Verification + +For the exact pull-request head: + +1. Run `mvn -B --no-transfer-progress verify`. +2. Run `mvn -B --no-transfer-progress dependency:tree -Dincludes=io.netty` and confirm one coherent `4.1.136.Final` line for every applicable Netty module. +3. Run `python3 scripts/test_render_third_party_attribution.py` and require the generated graph and attribution drift contract to pass. +4. Require successful CI, Security Scan, SAST Semgrep, every fuzz target, CodeRabbit, Strix, OpenCode, and Noema evidence for the same head. +5. Reject queued, cancelled, skipped-required, stale-head, local-only, or manually inferred results. +6. Preserve an independent approving review that GitHub counts under protected-branch rules. + +## Removal and upgrade rule + +Keep this override until one of the following occurs: + +- the Spring Boot parent used by Clearfolio manages Netty `4.1.136.Final` or a later reviewed compatible release; or +- Clearfolio moves to a different supported reactive HTTP stack through an accepted architecture decision. + +Removing or increasing the override requires the same exact-head dependency-tree, compatibility, security, coverage, SBOM regeneration, and review evidence. A newer version number alone is not proof of compatibility. + +## Consequences + +### Positive + +- The complete Netty family moves to one reviewed fixed 4.1 patch line. +- The remediation uses Spring Boot's documented version-property mechanism rather than fragile per-artifact pins. +- Real-project and generated-evidence contracts prevent silent dependency or data-room drift. +- Exact generation provenance, hashes, and local-versus-shareable evidence boundaries remain auditable for acquisition diligence. + +### Trade-offs + +- Clearfolio temporarily diverges from the Netty patch version selected by Spring Boot 3.5.16. +- The project must retain explicit exact-head compatibility and scanner evidence until the parent line catches up. +- A future parent upgrade must reconcile this property deliberately and regenerate the buyer evidence. + +## References + +CycloneDX Project. (n.d.). *CycloneDX Maven plugin* [Source code]. GitHub. Retrieved August 5, 2026, from https://github.com/CycloneDX/cyclonedx-maven-plugin + +Netty Project. (2026, July 8). *Netty 4.1.136.Final* [Software release]. GitHub. https://github.com/netty/netty/releases/tag/netty-4.1.136.Final + +OWASP Foundation. (2024, April 9). *CycloneDX 1.6* [Software bill of materials specification]. https://github.com/CycloneDX/specification/releases/tag/1.6 + +Spring. (n.d.). *Managed dependency coordinates: Spring Boot 3.5.16*. Retrieved August 5, 2026, from https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/coordinates.html + +Spring. (n.d.). *Version properties: Spring Boot 3.5.16*. Retrieved August 5, 2026, from https://docs.spring.io/spring-boot/3.5/appendix/dependency-versions/properties.html diff --git a/pom.xml b/pom.xml index 51e22d13..ebdd52f7 100644 --- a/pom.xml +++ b/pom.xml @@ -29,8 +29,15 @@ ${java.version} UTF-8 + 0.8.15 + 3.12.0 3.0.8 6.1.200 + + 4.1.136.Final @@ -192,10 +199,70 @@ @{argLine} -Xshare:off -XX:+EnableDynamicAgentLoading --enable-native-access=ALL-UNNAMED + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-coverage-agent + + prepare-agent + + + + report-and-check-coverage + verify + + report + check + + + + + BUNDLE + + + LINE + MISSEDCOUNT + 0 + + + BRANCH + MISSEDCOUNT + 0 + + + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven.javadoc.version} + + all + true + true + public + + + + validate-public-api-documentation + verify + + javadoc + + + + org.springframework.boot spring-boot-maven-plugin - + \ No newline at end of file diff --git a/scripts/test_ci_workflow_stack_coverage.py b/scripts/test_ci_workflow_stack_coverage.py new file mode 100644 index 00000000..8a609d0d --- /dev/null +++ b/scripts/test_ci_workflow_stack_coverage.py @@ -0,0 +1,95 @@ +"""Verify that stacked pull requests retain the complete CI acceptance contract.""" + +from pathlib import Path +import unittest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" + + +def _workflow_jobs(workflow: str) -> dict[str, str]: + """Return top-level workflow job bodies without adding a YAML dependency.""" + jobs: dict[str, list[str]] = {} + current_job: str | None = None + in_jobs = False + + for line in workflow.splitlines(): + if line == "jobs:": + in_jobs = True + continue + if not in_jobs: + continue + if line and not line.startswith(" "): + break + if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): + current_job = line.strip()[:-1] + jobs[current_job] = [] + continue + if current_job is not None: + jobs[current_job].append(line) + + return {job: "\n".join(body) for job, body in jobs.items()} + + +class CiWorkflowStackCoverageTest(unittest.TestCase): + """Exercises the stacked-pull-request workflow contract with standard discovery.""" + + def test_ci_runs_for_every_pull_request_base(self) -> None: + """Stacked pull requests must receive the same exact-head CI as main-bound PRs.""" + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + + self.assertIn(" pull_request: {}", workflow) + self.assertNotIn(" pull_request:\n branches: [main]", workflow) + + def test_ci_preserves_exact_head_and_synthetic_merge_evidence(self) -> None: + """Bind exact-head and synthetic-merge checks to their intended CI jobs.""" + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + jobs = _workflow_jobs(workflow) + maven_job = jobs["test"] + merge_job = jobs["merge-compatibility"] + script_job = jobs["script-checks"] + + exact_head_expression = "github.event.pull_request.head.sha || github.sha" + checkout_action = ( + "uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" + ) + revision_assertion = 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"' + + self.assertIn(checkout_action, maven_job) + self.assertIn(f"ref: ${{{{ {exact_head_expression} }}}}", maven_job) + self.assertIn(f"EXPECTED_SHA: ${{{{ {exact_head_expression} }}}}", maven_job) + self.assertIn(revision_assertion, maven_job) + + self.assertIn(checkout_action, merge_job) + self.assertIn("ref: ${{ github.sha }}", merge_job) + self.assertIn("EXPECTED_SHA: ${{ github.sha }}", merge_job) + self.assertIn(revision_assertion, merge_job) + maven_verify = "mvn -B --no-transfer-progress verify" + report_verify = "python3 scripts/verify_maven_test_reports.py" + self.assertIn(maven_verify, merge_job) + self.assertIn(report_verify, merge_job) + self.assertLess(merge_job.index(maven_verify), merge_job.index(report_verify)) + + self.assertIn("name: Buyer-readiness script tests", script_job) + + def test_job_parser_does_not_merge_sibling_job_commands(self) -> None: + """A command in one sibling job must never satisfy another job's contract.""" + jobs = _workflow_jobs( + "jobs:\n" + " first:\n" + " steps:\n" + " - run: echo first\n" + " second:\n" + " steps:\n" + " - run: echo second\n" + ) + + self.assertIn("echo first", jobs["first"]) + self.assertNotIn("echo second", jobs["first"]) + self.assertIn("echo second", jobs["second"]) + self.assertNotIn("echo first", jobs["second"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_render_third_party_attribution.py b/scripts/test_render_third_party_attribution.py old mode 100644 new mode 100755 index db6f99d5..767c5202 --- a/scripts/test_render_third_party_attribution.py +++ b/scripts/test_render_third_party_attribution.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 -"""Unit tests for the Clearfolio third-party attribution renderer.""" +"""Unit and repository-contract tests for third-party attribution evidence.""" from __future__ import annotations +import json +import re import sys import unittest from pathlib import Path @@ -11,7 +13,26 @@ from render_third_party_attribution import render_markdown +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +POM_PATH = REPOSITORY_ROOT / "pom.xml" +SBOM_PATH = ( + REPOSITORY_ROOT + / "docs" + / "qa" + / "evidence" + / "2026-07-02-krw2b-sale-readiness" + / "sbom-cyclonedx.json" +) +ATTRIBUTION_PATH = ( + REPOSITORY_ROOT / "docs" / "legal" / "2026-07-03-third-party-attribution.md" +) +NETTY_VERSION_PATTERN = re.compile( + r"\s*([^<\s]+)\s*" +) + + def component(group: str, name: str, version: str, license_id: str, purl: str) -> dict: + """Build one compact CycloneDX component fixture for renderer tests.""" return { "group": group, "name": name, @@ -21,9 +42,19 @@ def component(group: str, name: str, version: str, license_id: str, purl: str) - } +def managed_netty_version() -> str: + """Read the reviewed Netty family version from the trusted project POM.""" + matches = NETTY_VERSION_PATTERN.findall(POM_PATH.read_text(encoding="utf-8")) + if len(matches) != 1: + raise AssertionError("pom.xml must declare exactly one non-blank netty.version property") + return matches[0] + + class ThirdPartyAttributionTest(unittest.TestCase): + """Protect rendering behavior and buyer-evidence dependency consistency.""" def test_renders_sorted_component_table_and_summary(self) -> None: + """Render deterministic summary metadata and sorted component rows.""" markdown = render_markdown({ "bomFormat": "CycloneDX", "specVersion": "1.6", @@ -48,11 +79,23 @@ def test_renders_sorted_component_table_and_summary(self) -> None: self.assertIn("# Third-Party Attribution", markdown) self.assertIn("SBOM format: CycloneDX 1.6", markdown) self.assertIn("Component count: 2", markdown) - self.assertLess(markdown.index("com.example:alpha"), markdown.index("org.springframework:spring-core")) - self.assertIn("| com.example:alpha | 1.0.0 | MIT | `pkg:maven/com.example/alpha@1.0.0?type=jar` |", markdown) - self.assertIn("| org.springframework:spring-core | 6.2.7 | Apache-2.0 | `pkg:maven/org.springframework/spring-core@6.2.7?type=jar` |", markdown) + self.assertLess( + markdown.index("com.example:alpha"), + markdown.index("org.springframework:spring-core"), + ) + self.assertIn( + "| com.example:alpha | 1.0.0 | MIT | " + "`pkg:maven/com.example/alpha@1.0.0?type=jar` |", + markdown, + ) + self.assertIn( + "| org.springframework:spring-core | 6.2.7 | Apache-2.0 | " + "`pkg:maven/org.springframework/spring-core@6.2.7?type=jar` |", + markdown, + ) def test_marks_missing_license_metadata_for_release_review(self) -> None: + """Surface components whose SBOM license metadata needs human review.""" markdown = render_markdown({ "components": [ { @@ -65,6 +108,91 @@ def test_marks_missing_license_metadata_for_release_review(self) -> None: self.assertIn("NOASSERTION", markdown) + def test_buyer_evidence_tracks_reviewed_netty_security_line(self) -> None: + """Require the generated SBOM graph and attribution to match Maven.""" + expected_version = managed_netty_version() + sbom_text = SBOM_PATH.read_text(encoding="utf-8") + sbom = json.loads(sbom_text) + netty_components = [ + item + for item in sbom.get("components", []) + if item.get("group") == "io.netty" + ] + + self.assertGreater( + len(netty_components), + 0, + "the buyer SBOM must retain the resolved Netty runtime family", + ) + self.assertEqual( + {expected_version}, + {str(item.get("version", "")) for item in netty_components}, + "every resolved Netty module must match pom.xml netty.version", + ) + component_refs = set() + for item in netty_components: + coordinate = f"io.netty:{item.get('name', '')}" + purl = str(item.get("purl", "")) + bom_ref = str(item.get("bom-ref", "")) + self.assertIn( + f"@{expected_version}", + purl, + f"{coordinate} purl must identify the reviewed Netty version", + ) + self.assertIn( + f"@{expected_version}", + bom_ref, + f"{coordinate} bom-ref must identify the reviewed Netty version", + ) + component_refs.add(bom_ref) + + dependency_refs = set() + for dependency in sbom.get("dependencies", []): + dependency_ref = str(dependency.get("ref", "")) + if "pkg:maven/io.netty/" in dependency_ref: + dependency_refs.add(dependency_ref) + dependency_refs.update( + str(item) + for item in dependency.get("dependsOn", []) + if "pkg:maven/io.netty/" in str(item) + ) + self.assertEqual( + component_refs, + dependency_refs, + "every Netty dependency edge must resolve to one current component ref", + ) + self.assertNotIn( + "4.1.135.Final", + sbom_text, + "the historical Spring-managed Netty line must be absent from the SBOM", + ) + + actual_attribution = ATTRIBUTION_PATH.read_text(encoding="utf-8") + self.assertEqual( + render_markdown(sbom), + actual_attribution, + "buyer attribution must be regenerated from the committed SBOM", + ) + netty_rows = [ + line + for line in actual_attribution.splitlines() + if line.startswith("| io.netty:") + ] + self.assertEqual( + len(netty_components), + len(netty_rows), + "attribution must contain one row for every resolved Netty component", + ) + self.assertTrue( + all(f"| {expected_version} |" in row for row in netty_rows), + "every attribution row must use the reviewed Netty version", + ) + self.assertNotIn( + "4.1.135.Final", + actual_attribution, + "the historical Netty line must be absent from buyer attribution", + ) + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_verify_maven_test_reports.py b/scripts/test_verify_maven_test_reports.py new file mode 100644 index 00000000..a42b054d --- /dev/null +++ b/scripts/test_verify_maven_test_reports.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Tests for fail-closed Maven test-report acceptance.""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import verify_maven_test_reports as report_gate +from verify_maven_test_reports import ReportGateError, verify_maven_reports + + +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" + + +def _workflow_jobs(workflow: str) -> dict[str, str]: + """Return top-level workflow job bodies without adding a YAML dependency.""" + jobs: dict[str, list[str]] = {} + current_job: str | None = None + in_jobs = False + + for line in workflow.splitlines(): + if line == "jobs:": + in_jobs = True + continue + if not in_jobs: + continue + if line and not line.startswith(" "): + break + if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): + current_job = line.strip()[:-1] + jobs[current_job] = [] + continue + if current_job is not None: + jobs[current_job].append(line) + + return {job: "\n".join(body) for job, body in jobs.items()} + + +def _write_report( + directory: Path, + *, + filename: str = "TEST-example.xml", + tests: str = "1", + skipped: str = "0", + errors: str = "0", + failures: str = "0", +) -> None: + """Write one compact Maven test-suite XML fixture.""" + directory.mkdir(parents=True, exist_ok=True) + (directory / filename).write_text( + f'', + encoding="utf-8", + ) + + +class MavenTestReportGateTest(unittest.TestCase): + """Protect report discovery, non-empty execution, and zero-skip rules.""" + + def test_accepts_executed_surefire_reports_without_failsafe_output(self) -> None: + """Accept a normal unit-test run when every discovered test executed.""" + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + _write_report(target / "surefire-reports", tests="3") + + summary = verify_maven_reports(target) + + self.assertEqual({"surefire": (3, 0)}, summary) + + def test_accepts_executed_surefire_and_failsafe_reports(self) -> None: + """Apply the same zero-skip rule when integration reports exist.""" + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + _write_report(target / "surefire-reports", tests="2") + _write_report(target / "failsafe-reports", tests="4") + + summary = verify_maven_reports(target) + + self.assertEqual({"surefire": (2, 0), "failsafe": (4, 0)}, summary) + + def test_accepts_utf8_byte_order_mark(self) -> None: + """Allow the harmless UTF-8 BOM emitted by some XML writers.""" + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + reports = target / "surefire-reports" + reports.mkdir(parents=True) + (reports / "TEST-bom.xml").write_bytes( + b'\xef\xbb\xbf' + ) + + summary = verify_maven_reports(target) + + self.assertEqual({"surefire": (1, 0)}, summary) + + def test_rejects_missing_surefire_reports(self) -> None: + """Fail when Maven produced no authoritative unit-test evidence.""" + with tempfile.TemporaryDirectory() as temporary_directory: + with self.assertRaisesRegex(ReportGateError, "Surefire produced no TEST-.* reports"): + verify_maven_reports(Path(temporary_directory)) + + def test_rejects_zero_executed_tests(self) -> None: + """Fail when reports exist but contain no executed test cases.""" + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + _write_report(target / "surefire-reports", tests="0") + + with self.assertRaisesRegex(ReportGateError, "Surefire executed zero tests"): + verify_maven_reports(target) + + def test_rejects_skipped_surefire_tests(self) -> None: + """Fail when a disabled or conditionally skipped unit test is reported.""" + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + _write_report(target / "surefire-reports", tests="2", skipped="1") + + with self.assertRaisesRegex(ReportGateError, "Surefire reported 1 skipped test"): + verify_maven_reports(target) + + def test_rejects_skipped_failsafe_tests(self) -> None: + """Fail when optional integration-test evidence contains a skip.""" + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + _write_report(target / "surefire-reports") + _write_report(target / "failsafe-reports", skipped="1") + + with self.assertRaisesRegex(ReportGateError, "Failsafe reported 1 skipped test"): + verify_maven_reports(target) + + def test_rejects_reported_test_failures_and_errors(self) -> None: + """Do not trust report evidence that contradicts a successful Maven exit.""" + for attribute, singular in (("failures", "failure"), ("errors", "error")): + with self.subTest(attribute=attribute): + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + values = {attribute: "1"} + _write_report(target / "surefire-reports", **values) + + with self.assertRaisesRegex( + ReportGateError, + f"Surefire reported 1 test {singular}", + ): + verify_maven_reports(target) + + def test_rejects_invalid_or_negative_report_counts(self) -> None: + """Treat malformed count metadata as unusable acceptance evidence.""" + for invalid_count in ("not-a-number", "-1"): + with self.subTest(invalid_count=invalid_count): + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + _write_report(target / "surefire-reports", tests=invalid_count) + + with self.assertRaisesRegex(ReportGateError, "non-negative integer"): + verify_maven_reports(target) + + def test_rejects_missing_required_suite_counts(self) -> None: + """Fail closed when a Maven suite omits mandatory outcome counts.""" + for missing_attribute in ("tests", "skipped", "failures", "errors"): + with self.subTest(missing_attribute=missing_attribute): + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + reports = target / "surefire-reports" + reports.mkdir(parents=True) + attributes = { + "tests": "1", + "skipped": "0", + "failures": "0", + "errors": "0", + } + del attributes[missing_attribute] + serialized_attributes = " ".join( + f'{name}="{value}"' for name, value in attributes.items() + ) + (reports / "TEST-missing.xml").write_text( + f"", + encoding="utf-8", + ) + + with self.assertRaisesRegex( + ReportGateError, + f"missing required {missing_attribute} attribute", + ): + verify_maven_reports(target) + + def test_rejects_malformed_xml_and_missing_test_suite(self) -> None: + """Fail closed for unreadable XML and documents without a test suite.""" + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + reports = target / "surefire-reports" + reports.mkdir(parents=True) + (reports / "TEST-malformed.xml").write_text("", encoding="utf-8") + with self.assertRaisesRegex(ReportGateError, "contains no testsuite"): + verify_maven_reports(target) + + def test_rejects_doctype_and_entity_declarations_before_xml_parsing(self) -> None: + """Prevent external entities and expansion bombs in UTF-8 report evidence.""" + payloads = ( + b'', + b'', + ) + for payload in payloads: + with self.subTest(payload=payload[:20]): + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + reports = target / "surefire-reports" + reports.mkdir(parents=True) + (reports / "TEST-unsafe.xml").write_bytes(payload) + + with self.assertRaisesRegex(ReportGateError, "DTD or entity declaration"): + verify_maven_reports(target) + + def test_rejects_utf16_encoded_declaration_bypasses(self) -> None: + """Reject encodings that could hide dangerous declarations from byte scans.""" + dangerous_xml = ( + ']>' + '&payload;' + ) + payloads = ( + dangerous_xml.encode("utf-16"), + dangerous_xml.encode("utf-16-le"), + ) + for payload in payloads: + with self.subTest(prefix=payload[:8]): + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + reports = target / "surefire-reports" + reports.mkdir(parents=True) + (reports / "TEST-encoded.xml").write_bytes(payload) + + with self.assertRaisesRegex( + ReportGateError, + "UTF-8|NUL byte", + ): + verify_maven_reports(target) + + def test_rejects_oversized_report_before_xml_parsing(self) -> None: + """Bound parser memory exposure even when test code writes a large report.""" + with tempfile.TemporaryDirectory() as temporary_directory: + target = Path(temporary_directory) + reports = target / "surefire-reports" + reports.mkdir(parents=True) + (reports / "TEST-large.xml").write_bytes(b"") + + with mock.patch.object(report_gate, "MAX_REPORT_BYTES", 32): + with self.assertRaisesRegex(ReportGateError, "exceeds the 32-byte limit"): + verify_maven_reports(target) + + def test_ci_invokes_report_gate_after_maven_verify(self) -> None: + """Keep the executable report gate after Maven verify in the exact-head job.""" + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + maven_job = _workflow_jobs(workflow)["test"] + verify_command = "mvn -B --no-transfer-progress verify" + report_gate_command = "python3 scripts/verify_maven_test_reports.py" + + self.assertIn(verify_command, maven_job) + self.assertIn(report_gate_command, maven_job) + self.assertLess( + maven_job.index(verify_command), + maven_job.index(report_gate_command), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_maven_test_reports.py b/scripts/verify_maven_test_reports.py new file mode 100644 index 00000000..29bb8cc5 --- /dev/null +++ b/scripts/verify_maven_test_reports.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Fail closed when Maven test reports are missing, empty, malformed, or skipped.""" + +from __future__ import annotations + +import argparse +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Final + +REPORT_PATTERN: Final[str] = "TEST-*.xml" +MAX_REPORT_BYTES: Final[int] = 16 * 1024 * 1024 +_FORBIDDEN_XML_DECLARATIONS: Final[tuple[str, ...]] = (" str: + """Return an XML element name without an optional namespace prefix.""" + return tag.rsplit("}", 1)[-1] + + +def _non_negative_count(report: Path, suite: ET.Element, attribute: str) -> int: + """Read one required non-negative integer test-suite attribute.""" + raw_value = suite.get(attribute) + if raw_value is None: + raise ReportGateError( + f"{report} testsuite is missing required {attribute} attribute" + ) + try: + value = int(raw_value) + except ValueError as error: + raise ReportGateError( + f"{report} testsuite {attribute} must be a non-negative integer, got {raw_value!r}" + ) from error + if value < 0: + raise ReportGateError( + f"{report} testsuite {attribute} must be a non-negative integer, got {raw_value!r}" + ) + return value + + +def _read_bounded_report(report: Path) -> bytes: + """Read one UTF-8 report within the byte and XML-declaration limits.""" + try: + with report.open("rb") as report_stream: + report_bytes = report_stream.read(MAX_REPORT_BYTES + 1) + except OSError as error: + raise ReportGateError(f"{report} cannot be read: {error}") from error + if len(report_bytes) > MAX_REPORT_BYTES: + raise ReportGateError(f"{report} exceeds the {MAX_REPORT_BYTES}-byte limit") + + try: + report_text = report_bytes.decode("utf-8-sig") + except UnicodeDecodeError as error: + raise ReportGateError(f"{report} must use UTF-8 XML encoding: {error}") from error + if "\x00" in report_text: + raise ReportGateError(f"{report} contains a forbidden NUL byte") + + uppercase_text = report_text.upper() + if any(marker in uppercase_text for marker in _FORBIDDEN_XML_DECLARATIONS): + raise ReportGateError(f"{report} contains a forbidden DTD or entity declaration") + return report_bytes + + +def _suite_elements(report: Path) -> list[ET.Element]: + """Parse one bounded UTF-8 entity-free report and return concrete test suites.""" + report_bytes = _read_bounded_report(report) + try: + # Input is bounded, strict UTF-8, NUL-free, and pre-scanned for DTD/entity declarations. + root = ET.fromstring(report_bytes) # nosemgrep: python.lang.security.use-defused-xml-parse.use-defused-xml-parse + except ET.ParseError as error: + raise ReportGateError(f"{report} is not valid XML: {error}") from error + + if _local_name(root.tag) == "testsuite": + return [root] + suites = [element for element in root.iter() if _local_name(element.tag) == "testsuite"] + if not suites: + raise ReportGateError(f"{report} contains no testsuite element") + return suites + + +def _reported_count_error(display_name: str, count: int, attribute: str) -> ReportGateError: + """Build one readable failure for nonzero Maven outcome evidence.""" + noun = attribute[:-1] if count == 1 else attribute + return ReportGateError(f"{display_name} reported {count} test {noun}") + + +def _verify_report_family( + report_directory: Path, + *, + display_name: str, + required: bool, +) -> tuple[int, int] | None: + """Verify one Surefire or Failsafe report family and return its totals.""" + reports = sorted(report_directory.glob(REPORT_PATTERN)) + if not reports: + if required: + raise ReportGateError( + f"{display_name} produced no TEST-*.xml reports in {report_directory}" + ) + return None + + total_tests = 0 + total_skipped = 0 + total_failures = 0 + total_errors = 0 + for report in reports: + for suite in _suite_elements(report): + total_tests += _non_negative_count(report, suite, "tests") + total_skipped += _non_negative_count(report, suite, "skipped") + total_failures += _non_negative_count(report, suite, "failures") + total_errors += _non_negative_count(report, suite, "errors") + + if total_tests == 0: + raise ReportGateError(f"{display_name} executed zero tests") + if total_skipped > 0: + noun = "test" if total_skipped == 1 else "tests" + raise ReportGateError(f"{display_name} reported {total_skipped} skipped {noun}") + if total_failures > 0: + raise _reported_count_error(display_name, total_failures, "failures") + if total_errors > 0: + raise _reported_count_error(display_name, total_errors, "errors") + return total_tests, total_skipped + + +def verify_maven_reports(target_directory: Path) -> dict[str, tuple[int, int]]: + """Require executed, zero-skip Surefire evidence and validate Failsafe when present. + + Args: + target_directory: Maven ``target`` directory containing report subdirectories. + + Returns: + A mapping from the lowercase report-family name to ``(tests, skipped)`` totals. + + Raises: + ReportGateError: If required evidence is absent or any report is unsafe. + """ + summary: dict[str, tuple[int, int]] = {} + surefire = _verify_report_family( + target_directory / "surefire-reports", + display_name="Surefire", + required=True, + ) + if surefire is None: # Defensive assertion for static type narrowing. + raise ReportGateError("Surefire report verification returned no summary") + summary["surefire"] = surefire + + failsafe = _verify_report_family( + target_directory / "failsafe-reports", + display_name="Failsafe", + required=False, + ) + if failsafe is not None: + summary["failsafe"] = failsafe + return summary + + +def main() -> int: + """Run the Maven report gate as a command-line program.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--target-directory", + type=Path, + default=Path("target"), + help="Maven target directory to inspect (default: target)", + ) + arguments = parser.parse_args() + try: + summary = verify_maven_reports(arguments.target_directory) + except ReportGateError as error: + parser.exit(1, f"Maven test-report gate failed: {error}\n") + + for family, (tests, skipped) in summary.items(): + print(f"{family}: tests={tests} skipped={skipped}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/main/java/com/clearfolio/viewer/ClearfolioViewerApplication.java b/src/main/java/com/clearfolio/viewer/ClearfolioViewerApplication.java index eb1d1dee..f29d3038 100644 --- a/src/main/java/com/clearfolio/viewer/ClearfolioViewerApplication.java +++ b/src/main/java/com/clearfolio/viewer/ClearfolioViewerApplication.java @@ -11,6 +11,13 @@ @ConfigurationPropertiesScan public class ClearfolioViewerApplication { + /** + * Creates the Spring configuration instance used during application startup. + */ + public ClearfolioViewerApplication() { + // Spring instantiates the application configuration class while building the context. + } + /** * Boots the application with the provided command line arguments. * diff --git a/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java b/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java index 85ef8df1..f5cbde5d 100644 --- a/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java +++ b/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java @@ -96,11 +96,13 @@ public List snapshotsFor(String tenantId) { } private void load() { - if (ledgerPath == null || !Files.exists(ledgerPath)) { + if (ledgerPath == null) { return; } try (Stream lines = Files.lines(ledgerPath, StandardCharsets.UTF_8)) { lines.forEach(this::replayLine); + } catch (java.nio.file.NoSuchFileException ex) { + // Ignore missing ledger file } catch (IOException | UncheckedIOException ex) { throw new IllegalStateException("kpi snapshot ledger cannot be loaded", ex); } diff --git a/src/main/java/com/clearfolio/viewer/api/AdminJobListResponse.java b/src/main/java/com/clearfolio/viewer/api/AdminJobListResponse.java index f691256e..722a124f 100644 --- a/src/main/java/com/clearfolio/viewer/api/AdminJobListResponse.java +++ b/src/main/java/com/clearfolio/viewer/api/AdminJobListResponse.java @@ -1,10 +1,14 @@ package com.clearfolio.viewer.api; import java.util.List; + import com.clearfolio.viewer.model.ConversionJob; /** - * Payload representing a list of conversion jobs for admin view. + * Administrative API payload containing conversion jobs that already passed + * the caller's authorization and tenant-scope checks. + * + * @param jobs ordered job summaries safe to return to the authorized caller */ public record AdminJobListResponse( List jobs diff --git a/src/main/java/com/clearfolio/viewer/api/ApiErrorResponse.java b/src/main/java/com/clearfolio/viewer/api/ApiErrorResponse.java index 60123e47..097207aa 100644 --- a/src/main/java/com/clearfolio/viewer/api/ApiErrorResponse.java +++ b/src/main/java/com/clearfolio/viewer/api/ApiErrorResponse.java @@ -6,7 +6,13 @@ import com.fasterxml.jackson.annotation.JsonProperty; /** - * API payload used for error responses. + * Stable API error envelope that gives clients a machine-readable code, a safe + * explanation, and a trace identifier without exposing internal stack details. + * + * @param errorCode canonical machine-readable error code + * @param message operator- and client-safe explanation of the failure + * @param traceId correlation identifier for privacy-controlled diagnostics + * @param details immutable structured context that is safe to disclose */ public record ApiErrorResponse( String errorCode, diff --git a/src/main/java/com/clearfolio/viewer/api/ConversionJobStatusResponse.java b/src/main/java/com/clearfolio/viewer/api/ConversionJobStatusResponse.java index ab1a1a1a..01e78dc4 100644 --- a/src/main/java/com/clearfolio/viewer/api/ConversionJobStatusResponse.java +++ b/src/main/java/com/clearfolio/viewer/api/ConversionJobStatusResponse.java @@ -6,7 +6,27 @@ import com.clearfolio.viewer.model.ConversionJob; /** - * API payload describing the current state of a conversion job. + * API payload describing the current state and retry history of one conversion + * job. + * + * @param jobId stable conversion-job identifier + * @param tenantId tenant that owns the job and every derived artifact + * @param fileName original client-visible source filename + * @param status current conversion lifecycle state + * @param message operator-safe status or failure explanation + * @param convertedResourcePath internal converted-resource reference when one is + * available + * @param createdAt time at which the job was accepted + * @param startedAt time at which processing most recently began, or + * {@code null} before processing + * @param completedAt time at which the job reached a terminal state, or + * {@code null} while incomplete + * @param attemptCount number of processing attempts already started + * @param maxAttempts maximum number of attempts allowed before dead lettering + * @param retryAt earliest time at which another attempt may begin, or + * {@code null} when no retry is scheduled + * @param deadLettered whether automatic processing has exhausted its retry + * allowance */ public record ConversionJobStatusResponse( UUID jobId, diff --git a/src/main/java/com/clearfolio/viewer/api/SubmitConversionResponse.java b/src/main/java/com/clearfolio/viewer/api/SubmitConversionResponse.java index 1afb4fa4..6b664a20 100644 --- a/src/main/java/com/clearfolio/viewer/api/SubmitConversionResponse.java +++ b/src/main/java/com/clearfolio/viewer/api/SubmitConversionResponse.java @@ -3,7 +3,12 @@ import java.util.UUID; /** - * API payload returned when a conversion request is accepted. + * API payload returned after Clearfolio durably accepts a conversion request + * for asynchronous processing. + * + * @param jobId stable identifier used for status, viewer, and artifact requests + * @param status initial lifecycle state exposed to the client + * @param statusUrl same-origin URL from which the client can poll job state */ public record SubmitConversionResponse(UUID jobId, String status, String statusUrl) { diff --git a/src/main/java/com/clearfolio/viewer/api/ViewerBootstrapResponse.java b/src/main/java/com/clearfolio/viewer/api/ViewerBootstrapResponse.java index 66e2dc2a..c2bcaa16 100644 --- a/src/main/java/com/clearfolio/viewer/api/ViewerBootstrapResponse.java +++ b/src/main/java/com/clearfolio/viewer/api/ViewerBootstrapResponse.java @@ -6,7 +6,24 @@ import com.clearfolio.viewer.model.ConversionJob; /** - * API payload that initializes the viewer for a converted document. + * API payload that initializes the document viewer with lifecycle metadata, + * renderer selection, and an optional tenant-authorized artifact link. + * + * @param docId stable document or conversion-job identifier + * @param status current conversion lifecycle state + * @param fileName original client-visible source filename + * @param viewerMode viewer implementation selected for the rendered artifact + * @param previewResourcePath resource URL used by the viewer shell + * @param createdAt time at which the conversion job was accepted + * @param startedAt time at which processing began, or {@code null} + * @param completedAt time at which processing completed, or {@code null} + * @param sourceExtension normalized lowercase source-file extension + * @param rendererAdapter adapter selected for the source document family + * @param artifactLinkUrl signed artifact URL, or {@code null} when unavailable + * @param artifactLinkExpiresAt expiry time of the signed artifact URL, or + * {@code null} + * @param artifactLinkScope authorization scope encoded into the artifact link, + * or {@code null} */ public record ViewerBootstrapResponse( String docId, diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java index d04668b9..897b95b2 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java +++ b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkLedger.java @@ -136,11 +136,13 @@ public List readEventsFor(String tenantId, UUID docId) { } private void load() { - if (ledgerPath == null || !Files.exists(ledgerPath)) { + if (ledgerPath == null) { return; } try (Stream lines = Files.lines(ledgerPath, StandardCharsets.UTF_8)) { lines.forEach(this::replayLine); + } catch (java.nio.file.NoSuchFileException ex) { + // Ignore missing ledger file } catch (IOException | UncheckedIOException ex) { throw new IllegalStateException("artifact link ledger cannot be loaded", ex); } diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkService.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkService.java index efb8ad5e..aa3ef5f6 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkService.java +++ b/src/main/java/com/clearfolio/viewer/artifact/ArtifactLinkService.java @@ -72,6 +72,7 @@ public class ArtifactLinkService { * Creates the link service with an optional configured HMAC secret. * * @param artifactStore artifact byte store + * @param artifactLinkLedger issued-link, revocation, and read-audit ledger * @param configuredSecret optional deployment secret */ @Autowired diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactTokenException.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactTokenException.java index 3f74dff1..6c33d8b4 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/ArtifactTokenException.java +++ b/src/main/java/com/clearfolio/viewer/artifact/ArtifactTokenException.java @@ -9,6 +9,7 @@ public class ArtifactTokenException extends RuntimeException { private static final long serialVersionUID = 1L; + /** HTTP response status associated with this verified token failure category. */ private final HttpStatus status; /** diff --git a/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java index fe69737f..7e756fa7 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java +++ b/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java @@ -86,14 +86,12 @@ public Optional getPdf(UUID docId) { } Path pdfPath = pdfPath(docId); - if (!Files.exists(pdfPath)) { - return Optional.empty(); - } - try { byte[] loaded = bytesReader.read(pdfPath); cache.put(docId, loaded); return Optional.of(loaded.clone()); + } catch (java.nio.file.NoSuchFileException ex) { + return Optional.empty(); } catch (IOException ex) { throw new IllegalStateException("failed to read artifact for docId " + docId, ex); } diff --git a/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java index 385a44d0..c066f2d4 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java +++ b/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java @@ -15,6 +15,13 @@ public class InMemoryArtifactStore implements ArtifactStore { private final ConcurrentHashMap pdfByDocId = new ConcurrentHashMap<>(); + /** + * Creates an empty process-local artifact store. + */ + public InMemoryArtifactStore() { + // The concurrent map is initialized eagerly so the store is immediately thread-safe. + } + /** * {@inheritDoc} */ diff --git a/src/main/java/com/clearfolio/viewer/artifact/PdfBoxArtifactGenerator.java b/src/main/java/com/clearfolio/viewer/artifact/PdfBoxArtifactGenerator.java index 794c3443..b04c5ea4 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/PdfBoxArtifactGenerator.java +++ b/src/main/java/com/clearfolio/viewer/artifact/PdfBoxArtifactGenerator.java @@ -6,8 +6,6 @@ import java.util.Objects; import java.util.function.Supplier; -import org.springframework.stereotype.Component; - import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; @@ -18,9 +16,15 @@ import com.clearfolio.viewer.model.ConversionJob; /** - * PDF artifact generator backed by Apache PDFBox. + * Development and test placeholder PDF generator backed by Apache PDFBox. + * + *

This class deliberately is not a Spring component. Its one-page metadata + * output is useful for deterministic low-level tests and development fixtures, + * but it is not document conversion and must never be selected by the + * production application context as evidence of Office-format fidelity. + * Production runtime wiring uses a qualified conversion adapter or fails + * closed until one is configured.

*/ -@Component public class PdfBoxArtifactGenerator implements PdfArtifactGenerator { @FunctionalInterface @@ -31,7 +35,7 @@ interface OutputTargetFactory { private final OutputTargetFactory outputTargetFactory; /** - * Creates a PDF generator that writes to an in-memory buffer. + * Creates a placeholder generator that writes to an in-memory buffer. */ public PdfBoxArtifactGenerator() { this(OutputTarget::inMemory); @@ -42,7 +46,10 @@ public PdfBoxArtifactGenerator() { } /** - * {@inheritDoc} + * Generates a deterministic metadata-only placeholder for development and tests. + * + * @param job conversion job whose metadata is rendered + * @return placeholder PDF bytes; never evidence of source-document fidelity */ @Override public byte[] generatePdf(ConversionJob job) { diff --git a/src/main/java/com/clearfolio/viewer/artifact/QualifiedConversionRequiredArtifactGenerator.java b/src/main/java/com/clearfolio/viewer/artifact/QualifiedConversionRequiredArtifactGenerator.java new file mode 100644 index 00000000..17c23d81 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/artifact/QualifiedConversionRequiredArtifactGenerator.java @@ -0,0 +1,44 @@ +package com.clearfolio.viewer.artifact; + +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Production-safe artifact generator selected while no qualified transformed- + * format conversion adapter is configured. + * + *

PDF uploads are seeded into the artifact store unchanged before the worker + * reaches this generator. Any invocation here therefore represents a source + * that would otherwise be turned into Clearfolio's development placeholder + * PDF. Returning a placeholder with a successful conversion state would + * overstate document fidelity, so the production bean fails closed until the + * sandboxed Office conversion boundary is qualified.

+ */ +@Component +@Primary +public final class QualifiedConversionRequiredArtifactGenerator implements PdfArtifactGenerator { + + /** + * Creates the fail-closed production generator used until a qualified + * transformed-format conversion adapter is configured. + */ + public QualifiedConversionRequiredArtifactGenerator() { + // Explicit constructor exists so the public API is fully documented. + } + + /** + * Rejects transformed-format generation while no qualified adapter exists. + * + * @param job conversion job that requires transformed-format rendering + * @return never returns normally + * @throws IllegalStateException always, because no production converter is qualified + */ + @Override + public byte[] generatePdf(ConversionJob job) { + throw new IllegalStateException( + "qualified document converter is not configured; placeholder output is not production success" + ); + } +} diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantContext.java b/src/main/java/com/clearfolio/viewer/auth/TenantContext.java index 0d2859ae..40b79f92 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantContext.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantContext.java @@ -9,7 +9,18 @@ import org.springframework.http.HttpHeaders; /** - * Header-derived tenant and permission claims for the current request. + * Header-derived tenant and permission claims for one authenticated request. + * + *

The tenant identifier defines the data-isolation boundary, the subject + * identifies the authenticated user or service, and the immutable permission + * set limits which operations that subject may perform. Callers should obtain + * contexts through the signed-claim authorization boundary rather than trusting + * raw client headers directly.

+ * + * @param tenantId normalized tenant identifier that scopes all repository and + * artifact access + * @param subjectId normalized authenticated user or service identifier + * @param permissions immutable normalized permission claims for the subject */ public record TenantContext(String tenantId, String subjectId, Set permissions) { diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index ced5e6a3..4f9c6a68 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -30,6 +30,11 @@ public final class TenantPermissions { */ public static final String VIEWER_READ = "viewer:read"; + /** + * Permission required to read generated document artifact bytes. + */ + public static final String ARTIFACT_READ = "artifact:read"; + /** * Permission required to create signed artifact links. */ diff --git a/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java b/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java index d5f3fde1..2b9d1f4c 100644 --- a/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java +++ b/src/main/java/com/clearfolio/viewer/config/ArtifactStoreConfig.java @@ -15,6 +15,13 @@ @Configuration public class ArtifactStoreConfig { + /** + * Creates the Spring configuration component for artifact storage. + */ + public ArtifactStoreConfig() { + // Spring creates this stateless configuration object before invoking its bean method. + } + /** * Creates the artifact store selected by configuration; the disk-backed * store is the default so artifacts survive application restarts. diff --git a/src/main/java/com/clearfolio/viewer/config/ArtifactStoreProperties.java b/src/main/java/com/clearfolio/viewer/config/ArtifactStoreProperties.java index c7c829ae..3f39c5b0 100644 --- a/src/main/java/com/clearfolio/viewer/config/ArtifactStoreProperties.java +++ b/src/main/java/com/clearfolio/viewer/config/ArtifactStoreProperties.java @@ -25,6 +25,13 @@ public class ArtifactStoreProperties { private String mode = MODE_FILESYSTEM; private String rootDir = DEFAULT_ROOT_DIR; + /** + * Creates artifact-store properties with restart-surviving filesystem defaults. + */ + public ArtifactStoreProperties() { + // Field initializers provide the secure deployment defaults. + } + /** * Returns the configured artifact store mode. * diff --git a/src/main/java/com/clearfolio/viewer/config/ConversionExecutorConfig.java b/src/main/java/com/clearfolio/viewer/config/ConversionExecutorConfig.java index 5f1394f8..a427a9f9 100644 --- a/src/main/java/com/clearfolio/viewer/config/ConversionExecutorConfig.java +++ b/src/main/java/com/clearfolio/viewer/config/ConversionExecutorConfig.java @@ -12,6 +12,13 @@ @Configuration public class ConversionExecutorConfig { + /** + * Creates the stateless Spring configuration component for conversion execution. + */ + public ConversionExecutorConfig() { + // Spring creates this component before requesting the bounded executor bean. + } + /** * Creates the conversion executor backed by a bounded thread pool. * diff --git a/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java b/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java index a86fc982..c50d6fe9 100644 --- a/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java +++ b/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java @@ -21,8 +21,18 @@ public class ConversionProperties { private double retryBackoffMultiplier = 2.0; private long maxUploadSizeBytes = 5 * 1024 * 1024L; private String policyOverrideSecret = ""; + private String auditPseudonymSecret = ""; + private String auditPseudonymKeyVersion = "v1"; private long processingLeaseTimeoutMs = 60_000L; + /** + * Creates conversion properties with bounded worker, retry, upload, lease, + * policy-override, and privacy-safe audit defaults. + */ + public ConversionProperties() { + // Field initializers define secure defaults before Spring binds deployment values. + } + /** * Returns file extensions that are blocked from upload. * @@ -200,6 +210,44 @@ public void setPolicyOverrideSecret(String policyOverrideSecret) { this.policyOverrideSecret = policyOverrideSecret == null ? "" : policyOverrideSecret; } + /** + * Returns the dedicated secret used to pseudonymize audit identifiers. + * + * @return audit pseudonym secret + */ + public String getAuditPseudonymSecret() { + return auditPseudonymSecret; + } + + /** + * Sets the dedicated secret used to pseudonymize audit identifiers. + * + * @param auditPseudonymSecret audit pseudonym secret + */ + public void setAuditPseudonymSecret(String auditPseudonymSecret) { + this.auditPseudonymSecret = auditPseudonymSecret == null ? "" : auditPseudonymSecret; + } + + /** + * Returns the non-sensitive key version included in audit fingerprints. + * + * @return audit pseudonym key version + */ + public String getAuditPseudonymKeyVersion() { + return auditPseudonymKeyVersion; + } + + /** + * Sets the non-sensitive key version included in audit fingerprints. + * + * @param auditPseudonymKeyVersion audit pseudonym key version + */ + public void setAuditPseudonymKeyVersion(String auditPseudonymKeyVersion) { + this.auditPseudonymKeyVersion = auditPseudonymKeyVersion == null + ? "v1" + : auditPseudonymKeyVersion; + } + /** * Returns the processing lease timeout used by restart recovery. * diff --git a/src/main/java/com/clearfolio/viewer/config/ViewerSecurityHeadersWebFilter.java b/src/main/java/com/clearfolio/viewer/config/ViewerSecurityHeadersWebFilter.java index 798246b1..71cd7392 100644 --- a/src/main/java/com/clearfolio/viewer/config/ViewerSecurityHeadersWebFilter.java +++ b/src/main/java/com/clearfolio/viewer/config/ViewerSecurityHeadersWebFilter.java @@ -27,11 +27,23 @@ public class ViewerSecurityHeadersWebFilter implements WebFilter { private final String frameAncestors; + /** + * Creates a viewer security filter with a normalized CSP frame-ancestor policy. + * + * @param frameAncestors configured CSP {@code frame-ancestors} source list + */ public ViewerSecurityHeadersWebFilter( @Value("${viewer.security.frame-ancestors:self}") String frameAncestors) { this.frameAncestors = normalizeFrameAncestors(frameAncestors); } + /** + * Applies viewer-only browser security headers before the response is committed. + * + * @param exchange current reactive HTTP exchange + * @param chain remaining WebFlux filter chain + * @return completion signal for the filtered request + */ @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { String path = exchange.getRequest().getPath().value(); diff --git a/src/main/java/com/clearfolio/viewer/controller/ApiExceptionHandler.java b/src/main/java/com/clearfolio/viewer/controller/ApiExceptionHandler.java index 29aa9be1..524f925d 100644 --- a/src/main/java/com/clearfolio/viewer/controller/ApiExceptionHandler.java +++ b/src/main/java/com/clearfolio/viewer/controller/ApiExceptionHandler.java @@ -34,6 +34,13 @@ public class ApiExceptionHandler { @Value("${conversion.max-upload-size-bytes:5242880}") private long configuredMaxUploadSize = 5242880L; + /** + * Creates the exception handler registered by Spring WebFlux controller advice. + */ + public ApiExceptionHandler() { + // Spring injects the configured upload limit after constructing this stateless handler. + } + /** * Handles blocked or unsupported document format requests. * @@ -156,7 +163,7 @@ public ResponseEntity handleTypeMismatch( resolveTraceId(exchange), Map.of( "parameter", ex.getName(), - "value", String.valueOf(ex.getValue()) + "value", safeTypeMismatchValue(ex.getValue()) ) )); } @@ -200,10 +207,10 @@ public ResponseEntity handleUnexpected( URI requestUri = exchange.getRequest().getURI(); String path = requestUri == null ? "" : requestUri.getRawPath(); LOGGER.error( - "Unexpected error on path={} traceId={}", + "Unexpected error on path={} traceId={} failureType={}", sanitizeForLog(path), sanitizeForLog(traceId), - ex + ex.getClass().getSimpleName() ); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ApiErrorResponse( @@ -237,6 +244,10 @@ private String normalizeStatusCode(HttpStatusCode statusCode) { return resolved.name(); } + private String safeTypeMismatchValue(Object rejectedValue) { + return rejectedValue == null ? "null" : "[redacted]"; + } + private String sanitizeForLog(final String value) { if (value == null) { return ""; diff --git a/src/main/java/com/clearfolio/viewer/controller/ArtifactController.java b/src/main/java/com/clearfolio/viewer/controller/ArtifactController.java index af55706b..881b98f3 100644 --- a/src/main/java/com/clearfolio/viewer/controller/ArtifactController.java +++ b/src/main/java/com/clearfolio/viewer/controller/ArtifactController.java @@ -1,8 +1,8 @@ package com.clearfolio.viewer.controller; +import java.util.List; import java.util.Optional; import java.util.UUID; -import java.util.List; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -11,21 +11,21 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import com.clearfolio.viewer.api.ArtifactLinkRequest; +import com.clearfolio.viewer.api.ArtifactLinkResponse; import com.clearfolio.viewer.api.ArtifactLinkRevocationRequest; import com.clearfolio.viewer.api.ArtifactLinkRevocationResponse; -import com.clearfolio.viewer.api.ArtifactLinkResponse; import com.clearfolio.viewer.api.ArtifactReadEventResponse; import com.clearfolio.viewer.artifact.ArtifactLinkService; import com.clearfolio.viewer.artifact.ArtifactStore; -import com.clearfolio.viewer.artifact.ArtifactTokenException; import com.clearfolio.viewer.artifact.ArtifactTokenClaims; +import com.clearfolio.viewer.artifact.ArtifactTokenException; import com.clearfolio.viewer.auth.TenantAccessService; import com.clearfolio.viewer.auth.TenantContext; import com.clearfolio.viewer.auth.TenantPermissions; @@ -41,8 +41,6 @@ @RestController public class ArtifactController { - private static final String RANGE_UNIT_BYTES = "bytes"; - private final DocumentConversionService conversionService; private final ArtifactStore artifactStore; private final ArtifactLinkService artifactLinkService; @@ -154,191 +152,36 @@ public Mono> getPdf( try { claims = artifactLinkService.verifyReadToken(docId, job.get(), pdfBytes, token); } catch (ArtifactTokenException ex) { - return Mono.just(tokenFailure(ex.getStatus())); + return Mono.just(ArtifactHttpResponse.tokenFailure(ex.getStatus())); } int totalLength = pdfBytes.length; - - Optional range = resolveSingleRange(rangeHeader, totalLength); - if (range.isPresent() && range.get().unsatisfiable()) { - ResponseEntity response = unsatisfiable(totalLength); - artifactLinkService.recordRead(claims, rangeHeader, response.getStatusCode().value(), traceId); - return Mono.just(response); - } - if (range.isPresent() && range.get().invalid()) { - ResponseEntity response = unsatisfiable(totalLength); + Optional range = ArtifactHttpRange.resolveSingleRange(rangeHeader, totalLength); + if (range.isPresent() && range.get().rejected()) { + ResponseEntity response = ArtifactHttpResponse.unsatisfiable(totalLength, null, null); artifactLinkService.recordRead(claims, rangeHeader, response.getStatusCode().value(), traceId); return Mono.just(response); } if (range.isEmpty()) { - ResponseEntity response = full(pdfBytes); + ResponseEntity response = ArtifactHttpResponse.full(pdfBytes, null, null); artifactLinkService.recordRead(claims, rangeHeader, response.getStatusCode().value(), traceId); return Mono.just(response); } - ResolvedRange resolved = range.get(); + ArtifactHttpRange.ResolvedRange resolved = range.get(); int start = resolved.startInclusive(); int end = resolved.endInclusive(); - int length = end - start + 1; byte[] slice = java.util.Arrays.copyOfRange(pdfBytes, start, end + 1); - ResponseEntity response = partial(slice, start, end, totalLength, length); + ResponseEntity response = ArtifactHttpResponse.partial( + slice, + start, + end, + totalLength, + null, + null + ); artifactLinkService.recordRead(claims, rangeHeader, response.getStatusCode().value(), traceId); return Mono.just(response); } - - private static ResponseEntity full(byte[] pdfBytes) { - return ResponseEntity.ok() - .contentType(MediaType.APPLICATION_PDF) - .header(HttpHeaders.CACHE_CONTROL, "no-store") - .header("X-Content-Type-Options", "nosniff") - .header(HttpHeaders.ACCEPT_RANGES, RANGE_UNIT_BYTES) - .contentLength(pdfBytes.length) - .body(pdfBytes); - } - - private static ResponseEntity partial( - byte[] body, - int start, - int end, - int total, - int length) { - return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) - .contentType(MediaType.APPLICATION_PDF) - .header(HttpHeaders.CACHE_CONTROL, "no-store") - .header("X-Content-Type-Options", "nosniff") - .header(HttpHeaders.ACCEPT_RANGES, RANGE_UNIT_BYTES) - .header(HttpHeaders.CONTENT_RANGE, contentRange(start, end, total)) - .contentLength(length) - .body(body); - } - - private static ResponseEntity unsatisfiable(int totalLength) { - return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE) - .header(HttpHeaders.CACHE_CONTROL, "no-store") - .header("X-Content-Type-Options", "nosniff") - .header(HttpHeaders.ACCEPT_RANGES, RANGE_UNIT_BYTES) - .header(HttpHeaders.CONTENT_RANGE, RANGE_UNIT_BYTES + " */" + totalLength) - .build(); - } - - private static ResponseEntity tokenFailure(HttpStatus status) { - return ResponseEntity.status(status) - .header(HttpHeaders.CACHE_CONTROL, "no-store") - .header("X-Content-Type-Options", "nosniff") - .build(); - } - - private static String contentRange(int start, int end, int total) { - return RANGE_UNIT_BYTES + " " + start + "-" + end + "/" + total; - } - - private Optional resolveSingleRange(String rangeHeader, int totalLength) { - if (rangeHeader == null || rangeHeader.isBlank()) { - return Optional.empty(); - } - - String trimmed = rangeHeader.strip(); - if (!trimmed.startsWith(RANGE_UNIT_BYTES + "=")) { - return Optional.of(ResolvedRange.invalidRange()); - } - - String spec = trimmed.substring((RANGE_UNIT_BYTES + "=").length()).strip(); - if (spec.isEmpty()) { - return Optional.of(ResolvedRange.invalidRange()); - } - - if (spec.contains(",")) { - return Optional.of(ResolvedRange.invalidRange()); - } - - int dash = spec.indexOf('-'); - if (dash < 0) { - return Optional.of(ResolvedRange.invalidRange()); - } - - String first = spec.substring(0, dash).strip(); - String second = spec.substring(dash + 1).strip(); - - if (first.isEmpty()) { - return resolveSuffix(second, totalLength); - } - - return resolveStartEnd(first, second, totalLength); - } - - private Optional resolveStartEnd(String first, String second, int totalLength) { - long startLong; - try { - startLong = Long.parseLong(first); - } catch (NumberFormatException ex) { - return Optional.of(ResolvedRange.invalidRange()); - } - if (startLong >= totalLength) { - return Optional.of(ResolvedRange.unsatisfiableRange()); - } - - int start = (int) startLong; - - if (second.isEmpty()) { - return Optional.of(ResolvedRange.ok(start, totalLength - 1)); - } - - long endLong; - try { - endLong = Long.parseLong(second); - } catch (NumberFormatException ex) { - return Optional.of(ResolvedRange.invalidRange()); - } - - if (endLong < startLong) { - return Optional.of(ResolvedRange.unsatisfiableRange()); - } - - long boundedEnd = Math.min(endLong, totalLength - 1L); - return Optional.of(ResolvedRange.ok(start, (int) boundedEnd)); - } - - private Optional resolveSuffix(String suffix, int totalLength) { - if (suffix.isEmpty()) { - return Optional.of(ResolvedRange.invalidRange()); - } - - long suffixLong; - try { - suffixLong = Long.parseLong(suffix); - } catch (NumberFormatException ex) { - return Optional.of(ResolvedRange.invalidRange()); - } - - if (suffixLong <= 0L) { - return Optional.of(ResolvedRange.invalidRange()); - } - - if (suffixLong >= totalLength) { - return Optional.of(ResolvedRange.ok(0, totalLength - 1)); - } - - long startLong = totalLength - suffixLong; - return Optional.of(ResolvedRange.ok((int) startLong, totalLength - 1)); - } - - private record ResolvedRange( - int startInclusive, - int endInclusive, - boolean invalid, - boolean unsatisfiable - ) { - static ResolvedRange ok(int startInclusive, int endInclusive) { - return new ResolvedRange(startInclusive, endInclusive, false, false); - } - - static ResolvedRange invalidRange() { - return new ResolvedRange(0, 0, true, false); - } - - static ResolvedRange unsatisfiableRange() { - return new ResolvedRange(0, 0, false, true); - } - } } diff --git a/src/main/java/com/clearfolio/viewer/controller/ArtifactHttpRange.java b/src/main/java/com/clearfolio/viewer/controller/ArtifactHttpRange.java new file mode 100644 index 00000000..c0d4ff59 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/controller/ArtifactHttpRange.java @@ -0,0 +1,177 @@ +package com.clearfolio.viewer.controller; + +import java.util.Optional; + +/** + * Parses Clearfolio's deliberately narrow HTTP byte-range profile. + * + *

Clearfolio supports zero or one {@code bytes} range. Multiple ranges are + * rejected rather than being interpreted inconsistently by different artifact + * endpoints.

+ */ +interface ArtifactHttpRange { + + String RANGE_UNIT_BYTES = "bytes"; + + /** + * Parses an optional single HTTP {@code bytes} range. + * + * @param rangeHeader optional Range header + * @param totalLength current artifact byte length + * @return empty for a full response, otherwise a parsed or rejected range + */ + static Optional resolveSingleRange(String rangeHeader, int totalLength) { + if (rangeHeader == null || rangeHeader.isBlank()) { + return Optional.empty(); + } + if (totalLength <= 0) { + return Optional.of(ResolvedRange.unsatisfiableRange()); + } + + String trimmed = rangeHeader.strip(); + int equalsIndex = trimmed.indexOf('='); + if (equalsIndex < 0 + || !trimmed.substring(0, equalsIndex).equalsIgnoreCase(RANGE_UNIT_BYTES)) { + return Optional.of(ResolvedRange.invalidRange()); + } + + String spec = trimmed.substring(equalsIndex + 1).strip(); + if (spec.isEmpty()) { + return Optional.of(ResolvedRange.invalidRange()); + } + if (spec.contains(",")) { + return Optional.of(ResolvedRange.invalidRange()); + } + + int dash = spec.indexOf('-'); + if (dash < 0) { + return Optional.of(ResolvedRange.invalidRange()); + } + + String first = spec.substring(0, dash); + String second = spec.substring(dash + 1); + if (first.isEmpty()) { + return resolveSuffix(second, totalLength); + } + return resolveStartEnd(first, second, totalLength); + } + + private static Optional resolveStartEnd(String first, String second, int totalLength) { + if (!isAsciiDigits(first) || (!second.isEmpty() && !isAsciiDigits(second))) { + return Optional.of(ResolvedRange.invalidRange()); + } + + long startLong; + try { + startLong = Long.parseLong(first); + } catch (NumberFormatException ex) { + return Optional.of(ResolvedRange.invalidRange()); + } + if (startLong >= totalLength) { + return Optional.of(ResolvedRange.unsatisfiableRange()); + } + + int start = (int) startLong; + if (second.isEmpty()) { + return Optional.of(ResolvedRange.ok(start, totalLength - 1)); + } + + long endLong; + try { + endLong = Long.parseLong(second); + } catch (NumberFormatException ex) { + return Optional.of(ResolvedRange.invalidRange()); + } + if (endLong < startLong) { + return Optional.of(ResolvedRange.unsatisfiableRange()); + } + + long boundedEnd = Math.min(endLong, totalLength - 1L); + return Optional.of(ResolvedRange.ok(start, (int) boundedEnd)); + } + + private static Optional resolveSuffix(String suffix, int totalLength) { + if (suffix.isEmpty() || !isAsciiDigits(suffix)) { + return Optional.of(ResolvedRange.invalidRange()); + } + + long suffixLong; + try { + suffixLong = Long.parseLong(suffix); + } catch (NumberFormatException ex) { + return Optional.of(ResolvedRange.invalidRange()); + } + if (suffixLong <= 0L) { + return Optional.of(ResolvedRange.invalidRange()); + } + if (suffixLong >= totalLength) { + return Optional.of(ResolvedRange.ok(0, totalLength - 1)); + } + + long startLong = totalLength - suffixLong; + return Optional.of(ResolvedRange.ok((int) startLong, totalLength - 1)); + } + + private static boolean isAsciiDigits(String value) { + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character < '0' || character > '9') { + return false; + } + } + return true; + } + + /** + * One resolved single-range outcome. + * + * @param startInclusive first byte when valid + * @param endInclusive final byte when valid + * @param invalid whether the syntax is outside Clearfolio's range profile + * @param unsatisfiable whether the syntax is valid enough to parse but cannot be satisfied + */ + record ResolvedRange( + int startInclusive, + int endInclusive, + boolean invalid, + boolean unsatisfiable + ) { + /** + * Creates a valid resolved range. + * + * @param startInclusive first byte + * @param endInclusive final byte + * @return valid range + */ + static ResolvedRange ok(int startInclusive, int endInclusive) { + return new ResolvedRange(startInclusive, endInclusive, false, false); + } + + /** + * Creates an invalid-syntax result. + * + * @return invalid range result + */ + static ResolvedRange invalidRange() { + return new ResolvedRange(0, 0, true, false); + } + + /** + * Creates an unsatisfiable result. + * + * @return unsatisfiable range result + */ + static ResolvedRange unsatisfiableRange() { + return new ResolvedRange(0, 0, false, true); + } + + /** + * Returns whether this result must produce a range failure response. + * + * @return {@code true} for invalid or unsatisfiable ranges + */ + boolean rejected() { + return invalid || unsatisfiable; + } + } +} diff --git a/src/main/java/com/clearfolio/viewer/controller/ArtifactHttpResponse.java b/src/main/java/com/clearfolio/viewer/controller/ArtifactHttpResponse.java new file mode 100644 index 00000000..af3db1d5 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/controller/ArtifactHttpResponse.java @@ -0,0 +1,123 @@ +package com.clearfolio.viewer.controller; + +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +/** + * Shared HTTP response contract for verified PDF artifact delivery. + * + *

Both canonical viewer artifacts and direct conversion-job downloads use + * this helper so cache, sniffing, range, and content-range semantics cannot + * drift between byte-delivery routes. Direct downloads may add attachment and + * checksum evidence while viewer delivery intentionally omits those headers.

+ */ +final class ArtifactHttpResponse { + + private static final String CONTENT_TYPE_OPTIONS = "X-Content-Type-Options"; + private static final String CHECKSUM_HEADER = "X-Checksum-Sha256"; + + private ArtifactHttpResponse() { + // Utility class. + } + + /** + * Builds a full PDF response. + * + * @param body PDF bytes + * @param contentDisposition optional attachment disposition + * @param checksum optional verified SHA-256 checksum + * @return HTTP 200 artifact response + */ + static ResponseEntity full( + byte[] body, + ContentDisposition contentDisposition, + String checksum) { + return decorateRangeHeaders( + ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF), + contentDisposition, + checksum + ).contentLength(body.length).body(body); + } + + /** + * Builds a single-range PDF response. + * + * @param body selected PDF bytes + * @param start inclusive byte start + * @param end inclusive byte end + * @param totalLength full artifact size + * @param contentDisposition optional attachment disposition + * @param checksum optional verified SHA-256 checksum + * @return HTTP 206 artifact response + */ + static ResponseEntity partial( + byte[] body, + int start, + int end, + int totalLength, + ContentDisposition contentDisposition, + String checksum) { + return decorateRangeHeaders( + ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) + .contentType(MediaType.APPLICATION_PDF) + .header( + HttpHeaders.CONTENT_RANGE, + ArtifactHttpRange.RANGE_UNIT_BYTES + " " + start + "-" + end + "/" + totalLength + ), + contentDisposition, + checksum + ).contentLength(body.length).body(body); + } + + /** + * Builds the controlled failure for malformed or unsatisfiable ranges. + * + * @param totalLength full artifact size + * @param contentDisposition optional attachment disposition + * @param checksum optional verified SHA-256 checksum + * @return HTTP 416 response + */ + static ResponseEntity unsatisfiable( + int totalLength, + ContentDisposition contentDisposition, + String checksum) { + return decorateRangeHeaders( + ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE) + .header(HttpHeaders.CONTENT_RANGE, ArtifactHttpRange.RANGE_UNIT_BYTES + " */" + totalLength), + contentDisposition, + checksum + ).build(); + } + + /** + * Builds a signed-token rejection without range or artifact metadata. + * + * @param status token failure status + * @return controlled token failure response + */ + static ResponseEntity tokenFailure(HttpStatus status) { + return ResponseEntity.status(status) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .header(CONTENT_TYPE_OPTIONS, "nosniff") + .build(); + } + + private static ResponseEntity.BodyBuilder decorateRangeHeaders( + ResponseEntity.BodyBuilder builder, + ContentDisposition contentDisposition, + String checksum) { + builder.header(HttpHeaders.CACHE_CONTROL, "no-store") + .header(CONTENT_TYPE_OPTIONS, "nosniff") + .header(HttpHeaders.ACCEPT_RANGES, ArtifactHttpRange.RANGE_UNIT_BYTES); + if (contentDisposition != null) { + builder.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString()); + } + if (checksum != null) { + builder.header(CHECKSUM_HEADER, checksum); + } + return builder; + } +} diff --git a/src/main/java/com/clearfolio/viewer/controller/ConversionController.java b/src/main/java/com/clearfolio/viewer/controller/ConversionController.java index 604edb56..efe6f756 100644 --- a/src/main/java/com/clearfolio/viewer/controller/ConversionController.java +++ b/src/main/java/com/clearfolio/viewer/controller/ConversionController.java @@ -1,5 +1,6 @@ package com.clearfolio.viewer.controller; +import java.util.Optional; import java.util.UUID; import org.springframework.beans.factory.annotation.Value; @@ -7,39 +8,38 @@ import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; -import org.springframework.util.unit.DataSize; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.util.unit.DataSize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; -import org.springframework.http.codec.multipart.FilePart; import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.bind.annotation.DeleteMapping; -import com.clearfolio.viewer.auth.TenantAccessService; -import com.clearfolio.viewer.auth.TenantContext; -import com.clearfolio.viewer.auth.TenantPermissions; import com.clearfolio.viewer.api.ArtifactLinkRequest; import com.clearfolio.viewer.api.ArtifactLinkResponse; import com.clearfolio.viewer.api.ConversionJobStatusResponse; import com.clearfolio.viewer.api.SubmitConversionResponse; import com.clearfolio.viewer.api.ViewerBootstrapResponse; import com.clearfolio.viewer.artifact.ArtifactLinkService; +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.artifact.ArtifactTokenClaims; +import com.clearfolio.viewer.artifact.ArtifactTokenException; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.model.ConversionJobStatus; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.PolicyOverrideRequest; import com.clearfolio.viewer.service.RetryDeadLetterResult; -import com.clearfolio.viewer.artifact.ArtifactStore; - -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Optional; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; @@ -204,15 +204,33 @@ public ViewerBootstrapResponse getViewer( } /** - * Downloads the converted PDF artifact. + * Downloads the converted PDF artifact for a tenant-owned conversion job. + * + *

The caller must have the dedicated artifact-read permission and a valid + * signed artifact token. Job-status read access or tenant ownership alone does + * not authorize access to document bytes. The endpoint supports zero or one + * HTTP byte range and records verified read evidence.

* * @param jobId conversion job identifier - * @return PDF bytes with attachment disposition and checksum header + * @param headers request headers carrying tenant claims + * @param rangeHeader optional {@code Range} header + * @param queryToken signed artifact token query parameter + * @param authorizationHeader optional bearer artifact token + * @param traceId optional request trace identifier + * @return signed PDF bytes with attachment disposition and checksum evidence */ @GetMapping("/api/v1/convert/jobs/{jobId}/download") - public Mono> downloadArtifact(@PathVariable UUID jobId) { + public Mono> downloadArtifact( + @PathVariable UUID jobId, + @RequestHeader HttpHeaders headers, + @RequestHeader(value = HttpHeaders.RANGE, required = false) String rangeHeader, + @RequestParam(value = ArtifactLinkService.ARTIFACT_TOKEN_PARAM, required = false) String queryToken, + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorizationHeader, + @RequestHeader(value = "X-Request-Id", required = false) String traceId) { + TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ARTIFACT_READ); ConversionJob job = conversionService.getJob(jobId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")); + tenantAccessService.requireSameTenant(tenantContext, job); if (job.getStatus() != ConversionJobStatus.SUCCEEDED) { throw new ResponseStatusException( @@ -227,17 +245,52 @@ public Mono> downloadArtifact(@PathVariable UUID jobId) { } byte[] pdfBytes = stored.get(); - String checksum = calculateSha256(pdfBytes); + String token = ArtifactLinkService.resolveToken(queryToken, authorizationHeader); + ArtifactTokenClaims claims; + try { + claims = artifactLinkService.verifyReadToken(jobId, job, pdfBytes, token); + } catch (ArtifactTokenException ex) { + return Mono.just(ArtifactHttpResponse.tokenFailure(ex.getStatus())); + } + + String checksum = claims.artifactChecksum(); String filename = pdfDownloadFilename(job.getOriginalFileName()); ContentDisposition contentDisposition = ContentDisposition.attachment() .filename(filename) .build(); + int totalLength = pdfBytes.length; + Optional range = ArtifactHttpRange.resolveSingleRange(rangeHeader, totalLength); + + if (range.isPresent() && range.get().rejected()) { + ResponseEntity response = ArtifactHttpResponse.unsatisfiable( + totalLength, + contentDisposition, + checksum + ); + artifactLinkService.recordRead(claims, rangeHeader, response.getStatusCode().value(), traceId); + return Mono.just(response); + } - return Mono.just(ResponseEntity.ok() - .contentType(MediaType.APPLICATION_PDF) - .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString()) - .header("X-Checksum-Sha256", checksum) - .body(pdfBytes)); + if (range.isEmpty()) { + ResponseEntity response = ArtifactHttpResponse.full(pdfBytes, contentDisposition, checksum); + artifactLinkService.recordRead(claims, rangeHeader, response.getStatusCode().value(), traceId); + return Mono.just(response); + } + + ArtifactHttpRange.ResolvedRange resolved = range.get(); + int start = resolved.startInclusive(); + int end = resolved.endInclusive(); + byte[] slice = java.util.Arrays.copyOfRange(pdfBytes, start, end + 1); + ResponseEntity response = ArtifactHttpResponse.partial( + slice, + start, + end, + totalLength, + contentDisposition, + checksum + ); + artifactLinkService.recordRead(claims, rangeHeader, response.getStatusCode().value(), traceId); + return Mono.just(response); } static String pdfDownloadFilename(String originalFileName) { @@ -251,7 +304,7 @@ static String pdfDownloadFilename(String originalFileName) { } String sanitized = sanitizeFilenameBase(baseName); - if (sanitized.isBlank() || sanitized.chars().allMatch(character -> character == '.' || character == '_')) { + if (sanitized.chars().allMatch(character -> character == '.' || character == '_')) { sanitized = "document"; } return sanitized + ".pdf"; @@ -290,18 +343,6 @@ private static String sanitizeFilenameBase(String baseName) { return sanitized.toString(); } - private String calculateSha256(final byte[] data) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(data); - // Optimization: java.util.HexFormat.of().formatHex() is faster - // and allocates less memory than String.format. - return java.util.HexFormat.of().formatHex(hash); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 algorithm not available", e); - } - } - private ViewerBootstrapResponse getViewerBootstrap(UUID docId, TenantContext tenantContext) { ConversionJob job = conversionService.getJob(docId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")); @@ -326,5 +367,4 @@ private ViewerBootstrapResponse getViewerBootstrap(UUID docId, TenantContext ten job.getStatus() + " not ready yet. retry in a few seconds" ); } - } diff --git a/src/main/java/com/clearfolio/viewer/controller/HealthController.java b/src/main/java/com/clearfolio/viewer/controller/HealthController.java index c2fb4701..9975524c 100644 --- a/src/main/java/com/clearfolio/viewer/controller/HealthController.java +++ b/src/main/java/com/clearfolio/viewer/controller/HealthController.java @@ -7,12 +7,23 @@ import org.springframework.web.bind.annotation.RestController; /** - * Lightweight endpoint used for liveness checks. + * Lightweight endpoint used for process-liveness checks. + * + *

This endpoint deliberately reports only whether the application process can + * answer requests. Traffic-readiness semantics are introduced separately so an + * orchestrator never confuses restart eligibility with dependency readiness.

*/ @RestController @RequestMapping("/healthz") public class HealthController { + /** + * Creates the stateless liveness controller. + */ + public HealthController() { + // No mutable state or external dependency belongs in the liveness path. + } + /** * Returns a static health payload when the service is alive. * diff --git a/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java b/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java index a0fa4d11..3e5ee071 100644 --- a/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java +++ b/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java @@ -21,6 +21,12 @@ public class ViewerUiController { static final String PDF_JS_WORKER_PATH = "/webjars/pdfjs-dist/6.1.200/build/pdf.worker.mjs"; private static final String INVALID_DOC_ID_SENTINEL = "invalid"; + /** + * Creates the stateless viewer UI controller. + */ + public ViewerUiController() { + } + /** * Returns the buyer-demo document intake shell. * diff --git a/src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java new file mode 100644 index 00000000..51beb103 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java @@ -0,0 +1,62 @@ +package com.clearfolio.viewer.conversion; + +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Deterministic offline Office conversion adapter backed by exact request fixtures. + * + *

This adapter is a contract oracle, not evidence of Office rendering fidelity and + * not a production Office renderer. It returns only pre-registered PDF bytes for the + * exact immutable request binding and therefore cannot silently accept a stale tenant, + * job, lifecycle generation, format, policy, correlation identity, or source digest.

+ */ +public final class DeterministicFixtureOfficeConversionAdapter implements OfficeConversionAdapter { + + private static final String ADAPTER_ID = "deterministic-fixture"; + private static final String ADAPTER_VERSION = "1"; + + private final Map fixtures; + + /** + * Creates an immutable fixture adapter from exact request bindings to reference PDFs. + * + *

Fixture byte arrays are defensively copied so later caller mutation cannot + * change the reference output accepted by the adapter.

+ * + * @param fixtures exact request bindings mapped to reference PDF bytes + */ + public DeterministicFixtureOfficeConversionAdapter( + Map fixtures) { + this.fixtures = fixtures.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().clone() + )); + } + + /** + * Returns the exact reference PDF registered for the request binding. + * + * @param request immutable tenant- and generation-bound conversion request + * @return deterministic reference PDF with matching request provenance + * @throws OfficeConversionException when no exact fixture is registered + */ + @Override + public OfficeConversionResult performConversion(OfficeConversionRequest request) { + byte[] pdfBytes = fixtures.get(request.binding()); + if (pdfBytes == null) { + throw new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, + "deterministic fixture not registered for request binding" + ); + } + return new OfficeConversionResult( + ADAPTER_ID, + ADAPTER_VERSION, + request.sourceSha256(), + request.binding(), + pdfBytes + ); + } +} diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java new file mode 100644 index 00000000..db945346 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionAdapter.java @@ -0,0 +1,308 @@ +package com.clearfolio.viewer.conversion; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; + +/** + * Provider-neutral boundary for sandboxed or remote Office-to-PDF conversion. + * + *

Implementations own converter-specific transport and process details. The + * Clearfolio API and job lifecycle depend only on this contract so a sandboxed + * sidecar, authenticated remote service, or deterministic fixture adapter can + * be substituted without changing document-delivery authority.

+ */ +@FunctionalInterface +public interface OfficeConversionAdapter { + + /** + * Maximum number of linked PDF actions inspected before the output fails closed. + */ + int MAX_ACTION_CHAIN_DEPTH = 32; + + /** + * Converts one immutable Office request and verifies its source and result. + * + *

Before provider invocation, Clearfolio requires the declared source + * format to be a current Office conversion candidate and requires its leading + * container signature to match the declared format family. ODF candidates + * additionally pass bounded, non-networked manifest parsing and root media-type + * validation. OOXML candidates additionally pass bounded, non-networked package + * metadata parsing that rejects externally resolved relationships and VBA project + * content-type declarations even when the corresponding part is renamed. This + * common preflight is intentionally narrower than complete archive, macro, OLE, + * malware, or fidelity qualification, which remain sandbox/content-policy + * responsibilities. After provider execution, the result must be present, + * source-bound, tied to the exact qualified adapter/runtime, request generation + * and policy, within request-bound byte/page publication ceilings, and parseable + * as a non-empty, unencrypted PDF without prohibited active content.

+ * + * @param request immutable tenant-, generation-, and adapter-bound conversion request + * @return verified PDF result with source, request, and adapter provenance + * @throws OfficeConversionException when source preflight fails, the provider + * returns no result, provenance mismatches, adapter identity is + * unexpected, output exceeds limits, output is malformed/encrypted, + * prohibited active content is present, or page limits are exceeded + */ + default OfficeConversionResult convert(OfficeConversionRequest request) { + OfficeSourceContainerPreflight.requireQualifiedContainer(request); + OfficeOdfManifestPreflight.requireQualifiedManifest(request); + OfficeOoxmlRelationshipPreflight.requireNoExternalRelationships(request); + OfficeConversionResult result = performConversion(request); + if (result == null) { + throw new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, + "conversion adapter returned no result" + ); + } + if (!request.sourceSha256().equals(result.sourceSha256())) { + throw new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, + "conversion result source digest mismatch" + ); + } + if (!request.expectedAdapterId().equals(result.adapterId()) + || !request.expectedAdapterVersion().equals(result.adapterVersion())) { + throw new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, + "conversion result adapter identity mismatch" + ); + } + if (!request.binding().equals(result.requestBinding())) { + throw new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, + "conversion result request binding mismatch" + ); + } + + byte[] pdfBytes = result.pdfBytes(); + if (pdfBytes.length > request.maxOutputBytes()) { + throw new OfficeConversionException( + OfficeConversionFailureCode.OUTPUT_LIMIT_EXCEEDED, + "conversion output exceeds maximum bytes" + ); + } + requireParseablePdf(pdfBytes, request.maxPdfPages()); + return result; + } + + /** + * Performs provider-specific conversion before Clearfolio validates result provenance. + * + * @param request immutable tenant- and generation-bound conversion request + * @return provider result, which the default conversion authority validates + */ + OfficeConversionResult performConversion(OfficeConversionRequest request); + + private static void requireParseablePdf(byte[] pdfBytes, int maxPdfPages) { + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + if (document.isEncrypted()) { + throw new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, + "conversion output PDF must not be encrypted" + ); + } + if (containsProhibitedActiveContent(document)) { + throw new OfficeConversionException( + OfficeConversionFailureCode.POLICY_DENIED, + "conversion output contains prohibited active content" + ); + } + int pageCount = document.getNumberOfPages(); + if (pageCount == 0) { + throw new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, + "conversion output PDF has no pages" + ); + } + if (pageCount > maxPdfPages) { + throw new OfficeConversionException( + OfficeConversionFailureCode.PAGE_LIMIT_EXCEEDED, + "conversion output exceeds maximum pages" + ); + } + } catch (IOException ex) { + throw new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, + "conversion output is not a valid PDF" + ); + } + } + + private static boolean containsProhibitedActiveContent(PDDocument document) { + COSDictionary catalog = document.getDocumentCatalog().getCOSObject(); + COSBase openAction = catalog.getDictionaryObject(COSName.getPDFName("OpenAction")); + if (openAction != null && isProhibitedOpenAction(openAction)) { + return true; + } + if (containsProhibitedAdditionalActions( + catalog.getDictionaryObject(COSName.getPDFName("AA")))) { + return true; + } + if (catalog.getDictionaryObject(COSName.getPDFName("AF")) != null) { + return true; + } + + COSBase namesBase = catalog.getDictionaryObject(COSName.getPDFName("Names")); + if (namesBase != null) { + if (!(namesBase instanceof COSDictionary names)) { + return true; + } + if (names.getDictionaryObject(COSName.getPDFName("JavaScript")) != null + || names.getDictionaryObject(COSName.getPDFName("EmbeddedFiles")) != null) { + return true; + } + } + + for (PDPage page : document.getPages()) { + if (pageContainsProhibitedActiveContent(page)) { + return true; + } + } + return false; + } + + private static boolean isProhibitedOpenAction(COSBase openAction) { + if (isInternalDestination(openAction)) { + return false; + } + return isProhibitedAction(openAction, false, newIdentitySet(), 0); + } + + private static boolean isInternalDestination(COSBase destination) { + return destination instanceof COSArray + || destination instanceof COSName + || destination instanceof COSString; + } + + private static boolean pageContainsProhibitedActiveContent(PDPage page) { + COSDictionary pageDictionary = page.getCOSObject(); + if (containsProhibitedAdditionalActions( + pageDictionary.getDictionaryObject(COSName.getPDFName("AA")))) { + return true; + } + + COSBase annotationsBase = pageDictionary.getDictionaryObject(COSName.getPDFName("Annots")); + if (annotationsBase == null) { + return false; + } + if (!(annotationsBase instanceof COSArray annotations)) { + return true; + } + for (int index = 0; index < annotations.size(); index++) { + COSBase annotationBase = annotations.getObject(index); + if (!(annotationBase instanceof COSDictionary annotation)) { + return true; + } + if (annotationContainsProhibitedActiveContent(annotation)) { + return true; + } + } + return false; + } + + private static boolean annotationContainsProhibitedActiveContent(COSDictionary annotation) { + if (containsProhibitedAdditionalActions( + annotation.getDictionaryObject(COSName.getPDFName("AA")))) { + return true; + } + COSBase actionBase = annotation.getDictionaryObject(COSName.getPDFName("A")); + if (actionBase == null) { + return false; + } + return isProhibitedAction(actionBase, true, newIdentitySet(), 0); + } + + private static boolean containsProhibitedAdditionalActions(COSBase additionalActionsBase) { + if (additionalActionsBase == null) { + return false; + } + if (!(additionalActionsBase instanceof COSDictionary additionalActions)) { + return true; + } + + // PDF /AA entries are event-triggered actions rather than explicit user + // navigation. Preserve an empty dictionary for interoperability, but fail + // closed when any trigger is configured regardless of the nested action + // type. A benign direct /A GoTo may remain fidelity-preserving; an /AA + // GoTo can execute automatically on page/document/annotation events. + return !additionalActions.keySet().isEmpty(); + } + + private static boolean isProhibitedAction( + COSBase actionBase, + boolean allowUri, + Set visited, + int depth + ) { + if (!(actionBase instanceof COSDictionary action) + || depth >= MAX_ACTION_CHAIN_DEPTH + || !visited.add(action)) { + return true; + } + + COSBase actionType = action.getDictionaryObject(COSName.getPDFName("S")); + boolean allowedType = COSName.getPDFName("GoTo").equals(actionType) + || (allowUri && COSName.getPDFName("URI").equals(actionType)); + if (!allowedType) { + return true; + } + if (COSName.getPDFName("GoTo").equals(actionType) + && action.getDictionaryObject(COSName.getPDFName("D")) == null) { + return true; + } + if (COSName.getPDFName("URI").equals(actionType) + && !isAllowedUriAction(action)) { + return true; + } + + COSBase next = action.getDictionaryObject(COSName.getPDFName("Next")); + if (next == null) { + return false; + } + if (next instanceof COSArray chainedActions) { + for (int index = 0; index < chainedActions.size(); index++) { + if (isProhibitedAction(chainedActions.getObject(index), allowUri, visited, depth + 1)) { + return true; + } + } + return false; + } + return isProhibitedAction(next, allowUri, visited, depth + 1); + } + + private static boolean isAllowedUriAction(COSDictionary action) { + COSBase uriBase = action.getDictionaryObject(COSName.getPDFName("URI")); + if (!(uriBase instanceof COSString uriString)) { + return false; + } + try { + URI uri = new URI(uriString.getString()); + String scheme = uri.getScheme(); + if ("mailto".equalsIgnoreCase(scheme)) { + return true; + } + boolean webScheme = "http".equalsIgnoreCase(scheme) + || "https".equalsIgnoreCase(scheme); + return webScheme && !uri.isOpaque() && uri.getHost() != null; + } catch (URISyntaxException ex) { + return false; + } + } + + private static Set newIdentitySet() { + return Collections.newSetFromMap(new IdentityHashMap<>()); + } +} diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionException.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionException.java new file mode 100644 index 00000000..1d978f44 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionException.java @@ -0,0 +1,60 @@ +package com.clearfolio.viewer.conversion; + +/** + * Typed failure returned by a qualified Office conversion adapter. + * + *

The stable failure code is the policy authority for retryability. Human- + * readable messages remain diagnostic context and must not be parsed to decide + * whether a conversion should be retried.

+ */ +public final class OfficeConversionException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Stable typed failure classification used for retry and product error mapping. + */ + private final OfficeConversionFailureCode failureCode; + + /** + * Creates a typed adapter failure. + * + * @param failureCode stable failure class + * @param message non-empty diagnostic message + * @throws IllegalArgumentException when the failure code or message is missing + */ + public OfficeConversionException( + OfficeConversionFailureCode failureCode, + String message) { + super(requireMessage(message)); + if (failureCode == null) { + throw new IllegalArgumentException("failureCode must not be null"); + } + this.failureCode = failureCode; + } + + /** + * Returns the stable conversion failure class. + * + * @return failure code used for retry and product error mapping + */ + public OfficeConversionFailureCode failureCode() { + return failureCode; + } + + /** + * Returns whether bounded retry is permitted for this failure class. + * + * @return {@code true} only for transient adapter/engine failures + */ + public boolean isRetryable() { + return failureCode.isRetryable(); + } + + private static String requireMessage(String message) { + if (message == null || message.isBlank()) { + throw new IllegalArgumentException("message must not be blank"); + } + return message.strip(); + } +} diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java new file mode 100644 index 00000000..9f0e1497 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionFailureCode.java @@ -0,0 +1,48 @@ +package com.clearfolio.viewer.conversion; + +/** + * Stable failure classes returned by qualified Office conversion adapters. + * + *

Retryability is part of the adapter contract so parser, policy, resource, + * and user-input failures cannot be mistaken for transient engine failures.

+ */ +public enum OfficeConversionFailureCode { + + /** Source format is outside the qualified support matrix. */ + UNSUPPORTED_FORMAT(false), + /** Source was rejected by macro, active-content, or document policy. */ + POLICY_DENIED(false), + /** Source requires a password and cannot be converted unattended. */ + PASSWORD_PROTECTED(false), + /** Source structure is malformed or cannot be parsed safely. */ + MALFORMED_INPUT(false), + /** Caller cancelled the exact conversion generation. */ + CANCELLED(false), + /** Converter returned output that failed PDF validation. */ + INVALID_OUTPUT(false), + /** Candidate PDF exceeds the request-bound publication size ceiling. */ + OUTPUT_LIMIT_EXCEEDED(false), + /** Candidate PDF exceeds the request-bound publication page ceiling. */ + PAGE_LIMIT_EXCEEDED(false), + /** Qualified converter service or capacity is temporarily unavailable. */ + ENGINE_UNAVAILABLE(true), + /** Conversion exceeded its bounded execution deadline. */ + TIMEOUT(true), + /** Isolated converter process or service crashed during execution. */ + ENGINE_CRASH(true); + + private final boolean retryable; + + OfficeConversionFailureCode(boolean retryable) { + this.retryable = retryable; + } + + /** + * Returns whether this failure class may enter bounded retry policy. + * + * @return {@code true} only for transient engine failure classes + */ + public boolean isRetryable() { + return retryable; + } +} diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java new file mode 100644 index 00000000..d996016a --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequest.java @@ -0,0 +1,335 @@ +package com.clearfolio.viewer.conversion; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.UUID; + +/** + * Immutable request passed across the provider-neutral Office conversion boundary. + * + *

The request binds untrusted document bytes to tenant, job-generation, + * source format, qualified adapter identity, policy, correlation identity, and + * bounded PDF publication limits before any converter implementation can + * process them. Source bytes are defensively copied at construction and on + * access so callers cannot mutate the digest-bound payload after validation.

+ * + * @param tenantId tenant that owns the conversion request + * @param jobId immutable conversion job identifier + * @param jobGeneration immutable lifecycle generation for stale-work fencing + * @param sourceFormat normalized source format such as {@code docx} + * @param expectedAdapterId qualified adapter implementation identifier + * @param expectedAdapterVersion exact qualified adapter/runtime version + * @param policyVersion conversion and active-content policy version + * @param correlationId request correlation identifier used for controlled tracing + * @param sourceBytes untrusted source bytes, defensively copied + * @param maxOutputBytes positive maximum PDF bytes accepted for publication + * @param maxPdfPages positive maximum PDF pages accepted for publication + */ +public record OfficeConversionRequest( + String tenantId, + UUID jobId, + long jobGeneration, + String sourceFormat, + String expectedAdapterId, + String expectedAdapterVersion, + String policyVersion, + String correlationId, + byte[] sourceBytes, + long maxOutputBytes, + int maxPdfPages +) { + + /** Default compatibility byte ceiling for contract callers without a policy-specific limit. */ + public static final long DEFAULT_MAX_OUTPUT_BYTES = 64L * 1024L * 1024L; + + /** Default compatibility page ceiling for contract callers without a policy-specific limit. */ + public static final int DEFAULT_MAX_PDF_PAGES = 1_000; + + private static final String CONTRACT_FIXTURE_ADAPTER_ID = "deterministic-fixture"; + private static final String CONTRACT_FIXTURE_ADAPTER_VERSION = "1"; + + /** + * Creates a qualified-adapter request using bounded compatibility publication limits. + * + *

Production integration should use the canonical constructor when policy + * supplies explicit byte or page ceilings. The exact adapter id and version + * remain mandatory authority fields.

+ * + * @param tenantId tenant that owns the conversion request + * @param jobId immutable conversion job identifier + * @param jobGeneration immutable lifecycle generation + * @param sourceFormat normalized source format + * @param expectedAdapterId qualified adapter identifier + * @param expectedAdapterVersion exact qualified adapter/runtime version + * @param policyVersion conversion-policy version + * @param correlationId controlled correlation identifier + * @param sourceBytes untrusted source bytes + */ + public OfficeConversionRequest( + String tenantId, + UUID jobId, + long jobGeneration, + String sourceFormat, + String expectedAdapterId, + String expectedAdapterVersion, + String policyVersion, + String correlationId, + byte[] sourceBytes) { + this( + tenantId, + jobId, + jobGeneration, + sourceFormat, + expectedAdapterId, + expectedAdapterVersion, + policyVersion, + correlationId, + sourceBytes, + DEFAULT_MAX_OUTPUT_BYTES, + DEFAULT_MAX_PDF_PAGES + ); + } + + /** + * Creates a qualified-adapter request with an explicit byte ceiling and the + * bounded compatibility page ceiling. + * + *

This overload preserves the public authority contract introduced by + * the byte-limit slice. New policy integrations that also control page count + * should use the canonical constructor.

+ * + * @param tenantId tenant that owns the conversion request + * @param jobId immutable conversion job identifier + * @param jobGeneration immutable lifecycle generation + * @param sourceFormat normalized source format + * @param expectedAdapterId qualified adapter identifier + * @param expectedAdapterVersion exact qualified adapter/runtime version + * @param policyVersion conversion-policy version + * @param correlationId controlled correlation identifier + * @param sourceBytes untrusted source bytes + * @param maxOutputBytes positive maximum PDF bytes accepted for publication + */ + public OfficeConversionRequest( + String tenantId, + UUID jobId, + long jobGeneration, + String sourceFormat, + String expectedAdapterId, + String expectedAdapterVersion, + String policyVersion, + String correlationId, + byte[] sourceBytes, + long maxOutputBytes) { + this( + tenantId, + jobId, + jobGeneration, + sourceFormat, + expectedAdapterId, + expectedAdapterVersion, + policyVersion, + correlationId, + sourceBytes, + maxOutputBytes, + DEFAULT_MAX_PDF_PAGES + ); + } + + /** + * Creates a package-local deterministic-fixture request with explicit publication limits. + * + *

This compatibility overload is deliberately non-public and bound to the + * deterministic fixture adapter. A production sidecar or remote-service + * integration must use the canonical public constructor and supply qualified + * adapter identity explicitly.

+ * + * @param tenantId tenant that owns the conversion request + * @param jobId immutable conversion job identifier + * @param jobGeneration immutable lifecycle generation + * @param sourceFormat normalized source format + * @param policyVersion conversion-policy version + * @param correlationId controlled correlation identifier + * @param sourceBytes untrusted source bytes + * @param maxOutputBytes positive maximum PDF bytes accepted for publication + * @param maxPdfPages positive maximum PDF pages accepted for publication + */ + OfficeConversionRequest( + String tenantId, + UUID jobId, + long jobGeneration, + String sourceFormat, + String policyVersion, + String correlationId, + byte[] sourceBytes, + long maxOutputBytes, + int maxPdfPages) { + this( + tenantId, + jobId, + jobGeneration, + sourceFormat, + CONTRACT_FIXTURE_ADAPTER_ID, + CONTRACT_FIXTURE_ADAPTER_VERSION, + policyVersion, + correlationId, + sourceBytes, + maxOutputBytes, + maxPdfPages + ); + } + + /** + * Creates a package-local deterministic-fixture request with an explicit byte ceiling. + * + * @param tenantId tenant that owns the conversion request + * @param jobId immutable conversion job identifier + * @param jobGeneration immutable lifecycle generation + * @param sourceFormat normalized source format + * @param policyVersion conversion-policy version + * @param correlationId controlled correlation identifier + * @param sourceBytes untrusted source bytes + * @param maxOutputBytes positive maximum PDF bytes accepted for publication + */ + OfficeConversionRequest( + String tenantId, + UUID jobId, + long jobGeneration, + String sourceFormat, + String policyVersion, + String correlationId, + byte[] sourceBytes, + long maxOutputBytes) { + this( + tenantId, + jobId, + jobGeneration, + sourceFormat, + policyVersion, + correlationId, + sourceBytes, + maxOutputBytes, + DEFAULT_MAX_PDF_PAGES + ); + } + + /** + * Creates a package-local deterministic-fixture request using bounded compatibility limits. + * + * @param tenantId tenant that owns the conversion request + * @param jobId immutable conversion job identifier + * @param jobGeneration immutable lifecycle generation + * @param sourceFormat normalized source format + * @param policyVersion conversion-policy version + * @param correlationId controlled correlation identifier + * @param sourceBytes untrusted source bytes + */ + OfficeConversionRequest( + String tenantId, + UUID jobId, + long jobGeneration, + String sourceFormat, + String policyVersion, + String correlationId, + byte[] sourceBytes) { + this( + tenantId, + jobId, + jobGeneration, + sourceFormat, + policyVersion, + correlationId, + sourceBytes, + DEFAULT_MAX_OUTPUT_BYTES, + DEFAULT_MAX_PDF_PAGES + ); + } + + /** + * Validates immutable conversion identity, qualified adapter identity, + * publication limits, and copies source bytes. + * + * @throws IllegalArgumentException when required identity, source bytes, or limits are invalid + */ + public OfficeConversionRequest { + tenantId = requireText(tenantId, "tenantId"); + if (jobId == null) { + throw new IllegalArgumentException("jobId must not be null"); + } + if (jobGeneration < 0L) { + throw new IllegalArgumentException("jobGeneration must be non-negative"); + } + sourceFormat = normalizeSourceFormat(sourceFormat); + expectedAdapterId = requireText(expectedAdapterId, "expectedAdapterId"); + expectedAdapterVersion = requireText(expectedAdapterVersion, "expectedAdapterVersion"); + policyVersion = requireText(policyVersion, "policyVersion"); + correlationId = requireText(correlationId, "correlationId"); + if (sourceBytes == null || sourceBytes.length == 0) { + throw new IllegalArgumentException("sourceBytes must not be empty"); + } + if (maxOutputBytes <= 0L) { + throw new IllegalArgumentException("maxOutputBytes must be positive"); + } + if (maxPdfPages <= 0) { + throw new IllegalArgumentException("maxPdfPages must be positive"); + } + sourceBytes = sourceBytes.clone(); + } + + /** + * Returns a defensive copy of the source bytes. + * + * @return copied source bytes + */ + @Override + public byte[] sourceBytes() { + return sourceBytes.clone(); + } + + /** + * Returns the SHA-256 digest of the immutable source bytes. + * + * @return lowercase hexadecimal SHA-256 digest + */ + public String sourceSha256() { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(sourceBytes)); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is unavailable", ex); + } + } + + /** + * Returns the full immutable authority tuple for provider-output validation. + * + * @return request binding containing identity, generation, adapter, policy, + * publication limits, and source digest + */ + public OfficeConversionRequestBinding binding() { + return new OfficeConversionRequestBinding( + tenantId, + jobId, + jobGeneration, + sourceFormat, + expectedAdapterId, + expectedAdapterVersion, + policyVersion, + correlationId, + sourceSha256(), + maxOutputBytes, + maxPdfPages + ); + } + + private static String normalizeSourceFormat(String value) { + return requireText(value, "sourceFormat").toLowerCase(Locale.ROOT); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value.strip(); + } +} diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java new file mode 100644 index 00000000..b418d4d0 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBinding.java @@ -0,0 +1,187 @@ +package com.clearfolio.viewer.conversion; + +import java.util.Locale; +import java.util.UUID; + +/** + * Immutable identity tuple that binds converter output to one exact Office request. + * + *

The binding includes every request authority field that may distinguish a + * valid conversion generation even when two jobs carry byte-identical source + * documents. Equality therefore acts as the stale-generation, provider-version, + * policy-limit, and cross-request acceptance boundary after a provider returns + * candidate output.

+ * + * @param tenantId canonical tenant identifier + * @param jobId immutable conversion job identifier + * @param jobGeneration lifecycle generation used for stale-work fencing + * @param sourceFormat canonical lowercase source format + * @param expectedAdapterId qualified adapter implementation identifier + * @param expectedAdapterVersion exact qualified adapter/runtime version + * @param policyVersion conversion-policy version applied to the request + * @param correlationId controlled request correlation identifier + * @param sourceSha256 lowercase SHA-256 digest of the immutable source bytes + * @param maxOutputBytes positive maximum PDF bytes accepted for publication + * @param maxPdfPages positive maximum PDF pages accepted for publication + */ +public record OfficeConversionRequestBinding( + String tenantId, + UUID jobId, + long jobGeneration, + String sourceFormat, + String expectedAdapterId, + String expectedAdapterVersion, + String policyVersion, + String correlationId, + String sourceSha256, + long maxOutputBytes, + int maxPdfPages +) { + + private static final String CONTRACT_FIXTURE_ADAPTER_ID = "deterministic-fixture"; + private static final String CONTRACT_FIXTURE_ADAPTER_VERSION = "1"; + + /** + * Creates a qualified-adapter binding using bounded compatibility publication limits. + * + * @param tenantId canonical tenant identifier + * @param jobId immutable conversion job identifier + * @param jobGeneration lifecycle generation + * @param sourceFormat canonical source format + * @param expectedAdapterId qualified adapter identifier + * @param expectedAdapterVersion exact qualified adapter/runtime version + * @param policyVersion conversion-policy version + * @param correlationId controlled correlation identifier + * @param sourceSha256 lowercase source digest + */ + public OfficeConversionRequestBinding( + String tenantId, + UUID jobId, + long jobGeneration, + String sourceFormat, + String expectedAdapterId, + String expectedAdapterVersion, + String policyVersion, + String correlationId, + String sourceSha256) { + this( + tenantId, + jobId, + jobGeneration, + sourceFormat, + expectedAdapterId, + expectedAdapterVersion, + policyVersion, + correlationId, + sourceSha256, + OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES, + OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES + ); + } + + /** + * Creates a package-local deterministic-fixture binding with an explicit byte ceiling. + * + * @param tenantId canonical tenant identifier + * @param jobId immutable conversion job identifier + * @param jobGeneration lifecycle generation + * @param sourceFormat canonical source format + * @param policyVersion conversion-policy version + * @param correlationId controlled correlation identifier + * @param sourceSha256 lowercase source digest + * @param maxOutputBytes positive maximum PDF bytes accepted for publication + */ + OfficeConversionRequestBinding( + String tenantId, + UUID jobId, + long jobGeneration, + String sourceFormat, + String policyVersion, + String correlationId, + String sourceSha256, + long maxOutputBytes) { + this( + tenantId, + jobId, + jobGeneration, + sourceFormat, + CONTRACT_FIXTURE_ADAPTER_ID, + CONTRACT_FIXTURE_ADAPTER_VERSION, + policyVersion, + correlationId, + sourceSha256, + maxOutputBytes, + OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES + ); + } + + /** + * Creates a package-local deterministic-fixture binding using bounded compatibility limits. + * + * @param tenantId canonical tenant identifier + * @param jobId immutable conversion job identifier + * @param jobGeneration lifecycle generation + * @param sourceFormat canonical source format + * @param policyVersion conversion-policy version + * @param correlationId controlled correlation identifier + * @param sourceSha256 lowercase source digest + */ + OfficeConversionRequestBinding( + String tenantId, + UUID jobId, + long jobGeneration, + String sourceFormat, + String policyVersion, + String correlationId, + String sourceSha256) { + this( + tenantId, + jobId, + jobGeneration, + sourceFormat, + CONTRACT_FIXTURE_ADAPTER_ID, + CONTRACT_FIXTURE_ADAPTER_VERSION, + policyVersion, + correlationId, + sourceSha256, + OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES, + OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES + ); + } + + /** + * Validates and canonicalizes the complete immutable request identity. + * + * @throws IllegalArgumentException when any authority field or limit is invalid + */ + public OfficeConversionRequestBinding { + tenantId = requireText(tenantId, "tenantId"); + if (jobId == null) { + throw new IllegalArgumentException("jobId must not be null"); + } + if (jobGeneration < 0L) { + throw new IllegalArgumentException("jobGeneration must be non-negative"); + } + sourceFormat = requireText(sourceFormat, "sourceFormat").toLowerCase(Locale.ROOT); + expectedAdapterId = requireText(expectedAdapterId, "expectedAdapterId"); + expectedAdapterVersion = requireText(expectedAdapterVersion, "expectedAdapterVersion"); + policyVersion = requireText(policyVersion, "policyVersion"); + correlationId = requireText(correlationId, "correlationId"); + if (sourceSha256 == null || !sourceSha256.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("sourceSha256 must be lowercase SHA-256 hex"); + } + if (maxOutputBytes <= 0L) { + throw new IllegalArgumentException("maxOutputBytes must be positive"); + } + if (maxPdfPages <= 0) { + throw new IllegalArgumentException("maxPdfPages must be positive"); + } + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value.strip(); + } +} diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java new file mode 100644 index 00000000..9310ab03 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeConversionResult.java @@ -0,0 +1,105 @@ +package com.clearfolio.viewer.conversion; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * Candidate PDF result returned by an Office conversion provider. + * + *

The result carries adapter provenance, source provenance, and—when supplied + * by the provider—the complete immutable request binding. PDF bytes are copied + * at construction and on access. A result is not trusted merely because this + * record can be constructed: {@link OfficeConversionAdapter#convert} is the + * authority that verifies source and full request binding before acceptance.

+ * + * @param adapterId stable adapter implementation identifier + * @param adapterVersion qualified adapter/runtime version identifier + * @param sourceSha256 lowercase SHA-256 digest of the source request + * @param requestBinding complete request authority tuple supplied by the provider, + * or {@code null} for an unbound candidate that the adapter must reject + * @param pdfBytes candidate PDF bytes, defensively copied + */ +public record OfficeConversionResult( + String adapterId, + String adapterVersion, + String sourceSha256, + OfficeConversionRequestBinding requestBinding, + byte[] pdfBytes +) { + + /** + * Creates a source-only candidate result for low-level result validation. + * + *

Provider implementations should normally supply the full request binding. + * A source-only result is intentionally rejected by the public adapter + * acceptance boundary.

+ * + * @param adapterId stable adapter implementation identifier + * @param adapterVersion qualified adapter/runtime version identifier + * @param sourceSha256 lowercase SHA-256 source digest + * @param pdfBytes candidate PDF bytes + */ + public OfficeConversionResult( + String adapterId, + String adapterVersion, + String sourceSha256, + byte[] pdfBytes) { + this(adapterId, adapterVersion, sourceSha256, null, pdfBytes); + } + + /** + * Validates candidate provenance and a minimal PDF media signature. + * + * @throws IllegalArgumentException when provenance or PDF bytes are invalid + */ + public OfficeConversionResult { + adapterId = requireText(adapterId, "adapterId"); + adapterVersion = requireText(adapterVersion, "adapterVersion"); + if (sourceSha256 == null || !sourceSha256.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("sourceSha256 must be lowercase SHA-256 hex"); + } + if (requestBinding != null && !sourceSha256.equals(requestBinding.sourceSha256())) { + throw new IllegalArgumentException("request binding source digest mismatch"); + } + if (pdfBytes == null) { + throw new IllegalArgumentException("pdfBytes must not be null"); + } + pdfBytes = pdfBytes.clone(); + String prefix = new String(pdfBytes, 0, Math.min(pdfBytes.length, 5), StandardCharsets.US_ASCII); + if (!"%PDF-".equals(prefix)) { + throw new IllegalArgumentException("converter output is not a PDF"); + } + } + + /** + * Returns a defensive copy of the candidate PDF bytes. + * + * @return copied PDF bytes + */ + @Override + public byte[] pdfBytes() { + return pdfBytes.clone(); + } + + /** + * Returns the SHA-256 digest of the candidate PDF bytes. + * + * @return lowercase hexadecimal SHA-256 digest + */ + public String outputSha256() { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(pdfBytes)); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is unavailable", ex); + } + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value.strip(); + } +} diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java new file mode 100644 index 00000000..38338e7a --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflight.java @@ -0,0 +1,407 @@ +package com.clearfolio.viewer.conversion; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; + +/** + * Performs bounded, non-networked semantic checks on the OpenDocument package manifest. + * + *

The common ZIP preflight runs first and proves the package framing, allowed compression + * methods, entry-name safety, duplicated local/central metadata, required manifest presence, + * and optional {@code mimetype} placement. This second boundary extracts only the manifest + * payload, bounds its expanded size, parses it as non-validating namespace-aware XML with + * DTD and external-entity support disabled, requires the OpenDocument 1.4 manifest version + * and at least one manifest file entry, binds ordinary ZIP files to exactly one manifest entry, + * and enforces the ODF root media-type contract. It intentionally does not attempt full Relax + * NG manifest-schema validation.

+ */ +final class OfficeOdfManifestPreflight { + + private static final Map ODF_MIMETYPE_BY_FORMAT = Map.of( + "odt", "application/vnd.oasis.opendocument.text", + "ods", "application/vnd.oasis.opendocument.spreadsheet", + "odp", "application/vnd.oasis.opendocument.presentation" + ); + private static final String MANIFEST_NAMESPACE = + "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"; + private static final String SUPPORTED_MANIFEST_VERSION = "1.4"; + private static final String MANIFEST_ENTRY_PATH = "META-INF/manifest.xml"; + private static final String MIMETYPE_ENTRY_PATH = "mimetype"; + private static final byte[] MANIFEST_ENTRY_NAME = + MANIFEST_ENTRY_PATH.getBytes(StandardCharsets.UTF_8); + private static final byte[] MIMETYPE_ENTRY_NAME = + MIMETYPE_ENTRY_PATH.getBytes(StandardCharsets.UTF_8); + private static final byte[] ZIP_CENTRAL_DIRECTORY_HEADER = new byte[] { + 0x50, 0x4b, 0x01, 0x02 + }; + private static final byte[] ZIP_END_OF_CENTRAL_DIRECTORY = new byte[] { + 0x50, 0x4b, 0x05, 0x06 + }; + private static final int ZIP_LOCAL_HEADER_FIXED_LENGTH = 30; + private static final int ZIP_CENTRAL_HEADER_FIXED_LENGTH = 46; + private static final int ZIP_EOCD_MINIMUM_LENGTH = 22; + private static final int ZIP_MAXIMUM_COMMENT_LENGTH = 65_535; + private static final int ZIP_STORED_METHOD = 0; + private static final int ZIP_DEFLATED_METHOD = 8; + private static final int MAX_MANIFEST_BYTES = 1_048_576; + + private OfficeOdfManifestPreflight() { + } + + /** + * Validates the ODF manifest when the request is an ODF package candidate. + * + * @param request immutable conversion request that already passed common container preflight + * @throws OfficeConversionException when the manifest cannot be safely extracted or parsed, + * does not advertise the supported OpenDocument manifest version or any file entry, + * ordinary package-file inventory is inconsistent, or the root-document media type + * disagrees with the package {@code mimetype} + */ + static void requireQualifiedManifest(OfficeConversionRequest request) { + String expectedMediaType = ODF_MIMETYPE_BY_FORMAT.get(request.sourceFormat()); + if (expectedMediaType == null) { + return; + } + + LocatedEntries entries = locateEntries(request.sourceBytes()); + byte[] manifestBytes = extractManifest(request.sourceBytes(), entries.manifestEntry()); + requireManifestContract( + manifestBytes, + entries.mimetypeFound(), + expectedMediaType, + entries.ordinaryPackageFiles() + ); + } + + private static LocatedEntries locateEntries(byte[] sourceBytes) { + int eocdOffset = findEocdOffset(sourceBytes); + if (eocdOffset < 0) { + throw invalidManifest(); + } + int entryCount = unsignedShort(sourceBytes, eocdOffset + 10); + long centralOffsetLong = unsignedInt(sourceBytes, eocdOffset + 16); + if (centralOffsetLong > Integer.MAX_VALUE) { + throw invalidManifest(); + } + int cursor = (int) centralOffsetLong; + ManifestEntry manifestEntry = null; + boolean mimetypeFound = false; + Set ordinaryPackageFiles = new HashSet<>(); + + for (int index = 0; index < entryCount; index++) { + if (!matchesAt(sourceBytes, cursor, ZIP_CENTRAL_DIRECTORY_HEADER) + || cursor > sourceBytes.length - ZIP_CENTRAL_HEADER_FIXED_LENGTH) { + throw invalidManifest(); + } + int compressionMethod = unsignedShort(sourceBytes, cursor + 10); + long compressedSize = unsignedInt(sourceBytes, cursor + 20); + long uncompressedSize = unsignedInt(sourceBytes, cursor + 24); + int fileNameLength = unsignedShort(sourceBytes, cursor + 28); + int extraFieldLength = unsignedShort(sourceBytes, cursor + 30); + int fileCommentLength = unsignedShort(sourceBytes, cursor + 32); + long localHeaderOffset = unsignedInt(sourceBytes, cursor + 42); + int nameOffset = cursor + ZIP_CENTRAL_HEADER_FIXED_LENGTH; + long nextCursor = (long) nameOffset + fileNameLength + extraFieldLength + fileCommentLength; + if (nextCursor > sourceBytes.length || localHeaderOffset > Integer.MAX_VALUE) { + throw invalidManifest(); + } + + String entryName = new String(sourceBytes, nameOffset, fileNameLength, StandardCharsets.UTF_8); + if (entryNameMatches(sourceBytes, nameOffset, fileNameLength, MIMETYPE_ENTRY_NAME)) { + mimetypeFound = true; + } else if (entryNameMatches(sourceBytes, nameOffset, fileNameLength, MANIFEST_ENTRY_NAME)) { + int localOffset = (int) localHeaderOffset; + if (localOffset > sourceBytes.length - ZIP_LOCAL_HEADER_FIXED_LENGTH) { + throw invalidManifest(); + } + int localNameLength = unsignedShort(sourceBytes, localOffset + 26); + int localExtraLength = unsignedShort(sourceBytes, localOffset + 28); + long dataOffset = (long) localOffset + + ZIP_LOCAL_HEADER_FIXED_LENGTH + + localNameLength + + localExtraLength; + if (dataOffset > sourceBytes.length) { + throw invalidManifest(); + } + manifestEntry = new ManifestEntry( + compressionMethod, + compressedSize, + uncompressedSize, + (int) dataOffset + ); + } else if (!entryName.startsWith("META-INF/") && !entryName.endsWith("/")) { + ordinaryPackageFiles.add(entryName); + } + cursor = (int) nextCursor; + } + if (manifestEntry == null) { + throw invalidManifest(); + } + return new LocatedEntries(manifestEntry, mimetypeFound, Set.copyOf(ordinaryPackageFiles)); + } + + private static byte[] extractManifest(byte[] sourceBytes, ManifestEntry entry) { + if (entry.uncompressedSize() > MAX_MANIFEST_BYTES) { + throw manifestTooLarge(); + } + if (entry.compressedSize() > Integer.MAX_VALUE) { + throw invalidManifest(); + } + int compressedSize = (int) entry.compressedSize(); + int uncompressedSize = (int) entry.uncompressedSize(); + long dataEnd = (long) entry.dataOffset() + compressedSize; + if (dataEnd > sourceBytes.length) { + throw invalidManifest(); + } + if (entry.compressionMethod() == ZIP_STORED_METHOD) { + if (compressedSize != uncompressedSize) { + throw invalidManifest(); + } + return Arrays.copyOfRange(sourceBytes, entry.dataOffset(), (int) dataEnd); + } + if (entry.compressionMethod() != ZIP_DEFLATED_METHOD) { + throw invalidManifest(); + } + + byte[] result = new byte[uncompressedSize]; + Inflater inflater = new Inflater(true); + try { + inflater.setInput(sourceBytes, entry.dataOffset(), compressedSize); + int written = 0; + while (!inflater.finished() && written < result.length) { + int produced = inflater.inflate(result, written, result.length - written); + if (produced == 0) { + break; + } + written += produced; + } + if (!inflater.finished() || written != result.length || inflater.getRemaining() != 0) { + throw invalidManifest(); + } + return result; + } catch (DataFormatException ex) { + throw invalidManifest(); + } finally { + inflater.end(); + } + } + + private static void requireManifestContract( + byte[] manifestBytes, + boolean mimetypeFound, + String expectedMediaType, + Set ordinaryPackageFiles + ) { + XMLInputFactory factory = XMLInputFactory.newFactory(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + + boolean rootElementSeen = false; + int manifestFileEntryCount = 0; + String rootDocumentMediaType = null; + Map ordinaryManifestEntryCounts = new HashMap<>(); + try (ByteArrayInputStream input = new ByteArrayInputStream(manifestBytes)) { + XMLStreamReader reader = factory.createXMLStreamReader(input); + try { + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.DTD) { + throw invalidManifest(); + } + if (event != XMLStreamConstants.START_ELEMENT) { + continue; + } + if (!rootElementSeen) { + rootElementSeen = true; + if (!MANIFEST_NAMESPACE.equals(reader.getNamespaceURI()) + || !"manifest".equals(reader.getLocalName())) { + throw invalidManifest(); + } + String manifestVersion = reader.getAttributeValue(MANIFEST_NAMESPACE, "version"); + if (!SUPPORTED_MANIFEST_VERSION.equals(manifestVersion)) { + throw unsupportedManifestVersion(); + } + } + if (!MANIFEST_NAMESPACE.equals(reader.getNamespaceURI()) + || !"file-entry".equals(reader.getLocalName())) { + continue; + } + manifestFileEntryCount++; + + String fullPath = reader.getAttributeValue(MANIFEST_NAMESPACE, "full-path"); + if (fullPath == null || fullPath.isEmpty()) { + throw invalidManifest(); + } + if ("/".equals(fullPath)) { + if (rootDocumentMediaType != null) { + throw invalidManifest(); + } + rootDocumentMediaType = reader.getAttributeValue(MANIFEST_NAMESPACE, "media-type"); + continue; + } + if (MANIFEST_ENTRY_PATH.equals(fullPath) || MIMETYPE_ENTRY_PATH.equals(fullPath)) { + throw manifestInventoryMismatch(); + } + if (!fullPath.startsWith("META-INF/") && !fullPath.endsWith("/")) { + ordinaryManifestEntryCounts.merge(fullPath, 1, Integer::sum); + } + } + } finally { + reader.close(); + } + } catch (XMLStreamException | java.io.IOException ex) { + throw invalidManifest(); + } + + if (manifestFileEntryCount == 0) { + throw missingManifestFileEntry(); + } + if (mimetypeFound && rootDocumentMediaType == null) { + throw missingManifestRootEntry(); + } + if (rootDocumentMediaType != null && !mimetypeFound) { + throw missingMimetypeForManifestRoot(); + } + if (rootDocumentMediaType != null && !expectedMediaType.equals(rootDocumentMediaType)) { + throw manifestMediaTypeMismatch(); + } + if (!ordinaryManifestEntryCounts.keySet().equals(ordinaryPackageFiles) + || ordinaryManifestEntryCounts.values().stream().anyMatch(count -> count != 1)) { + throw manifestInventoryMismatch(); + } + } + + private static int findEocdOffset(byte[] sourceBytes) { + if (sourceBytes.length < ZIP_EOCD_MINIMUM_LENGTH) { + return -1; + } + int latest = sourceBytes.length - ZIP_EOCD_MINIMUM_LENGTH; + int earliest = Math.max(0, latest - ZIP_MAXIMUM_COMMENT_LENGTH); + for (int offset = latest; offset >= earliest; offset--) { + if (matchesAt(sourceBytes, offset, ZIP_END_OF_CENTRAL_DIRECTORY) + && offset + ZIP_EOCD_MINIMUM_LENGTH + unsignedShort(sourceBytes, offset + 20) + == sourceBytes.length) { + return offset; + } + } + return -1; + } + + private static boolean entryNameMatches( + byte[] sourceBytes, + int nameOffset, + int nameLength, + byte[] expectedName + ) { + return nameLength == expectedName.length && matchesAt(sourceBytes, nameOffset, expectedName); + } + + private static boolean matchesAt(byte[] sourceBytes, int offset, byte[] expected) { + if (offset > sourceBytes.length - expected.length) { + return false; + } + for (int index = 0; index < expected.length; index++) { + if (sourceBytes[offset + index] != expected[index]) { + return false; + } + } + return true; + } + + private static int unsignedShort(byte[] sourceBytes, int offset) { + return Byte.toUnsignedInt(sourceBytes[offset]) + | (Byte.toUnsignedInt(sourceBytes[offset + 1]) << 8); + } + + private static long unsignedInt(byte[] sourceBytes, int offset) { + return Integer.toUnsignedLong( + Byte.toUnsignedInt(sourceBytes[offset]) + | (Byte.toUnsignedInt(sourceBytes[offset + 1]) << 8) + | (Byte.toUnsignedInt(sourceBytes[offset + 2]) << 16) + | (Byte.toUnsignedInt(sourceBytes[offset + 3]) << 24) + ); + } + + private static OfficeConversionException invalidManifest() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF manifest is invalid" + ); + } + + private static OfficeConversionException unsupportedManifestVersion() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF manifest version is not allowed" + ); + } + + private static OfficeConversionException missingManifestFileEntry() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF manifest has no file entries" + ); + } + + private static OfficeConversionException manifestTooLarge() { + return new OfficeConversionException( + OfficeConversionFailureCode.POLICY_DENIED, + "source ODF manifest exceeds maximum bytes" + ); + } + + private static OfficeConversionException missingManifestRootEntry() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF manifest root entry is missing" + ); + } + + private static OfficeConversionException missingMimetypeForManifestRoot() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF mimetype entry is missing for manifest root" + ); + } + + private static OfficeConversionException manifestMediaTypeMismatch() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF manifest root media type does not match mimetype" + ); + } + + private static OfficeConversionException manifestInventoryMismatch() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF manifest does not match package file inventory" + ); + } + + private record ManifestEntry( + int compressionMethod, + long compressedSize, + long uncompressedSize, + int dataOffset + ) { + } + + private record LocatedEntries( + ManifestEntry manifestEntry, + boolean mimetypeFound, + Set ordinaryPackageFiles + ) { + } +} diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeOoxmlRelationshipPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeOoxmlRelationshipPreflight.java new file mode 100644 index 00000000..65ec38b8 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeOoxmlRelationshipPreflight.java @@ -0,0 +1,271 @@ +package com.clearfolio.viewer.conversion; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +import javax.xml.namespace.QName; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; + +/** + * Rejects unsafe OOXML package metadata before provider invocation. + * + *

The common container preflight runs first and establishes bounded standard ZIP + * framing, safe entry names, allowed compression methods, and matching local/central + * metadata. This second boundary therefore reads only security-relevant XML parts + * identified by the already-validated central directory, caps every expanded metadata + * part at one MiB, parses XML with DTD and external-entity support disabled, rejects + * relationships whose package contract delegates target resolution outside the source + * package, and rejects VBA project content types even when the underlying package part + * has been renamed.

+ */ +final class OfficeOoxmlRelationshipPreflight { + + private static final Set OOXML_FORMATS = Set.of("docx", "xlsx", "pptx"); + private static final Pattern RELATIONSHIP_PART = Pattern.compile("(?:^|.*/)_rels/[^/]*\\.rels"); + private static final String CONTENT_TYPES_PART = "[Content_Types].xml"; + private static final String RELATIONSHIP_NAMESPACE = + "http://schemas.openxmlformats.org/package/2006/relationships"; + private static final String VBA_PROJECT_CONTENT_TYPE = "application/vnd.ms-office.vbaProject"; + private static final QName RELATIONSHIP_ELEMENT = new QName(RELATIONSHIP_NAMESPACE, "Relationship"); + private static final byte[] ZIP_END_OF_CENTRAL_DIRECTORY = new byte[] { + 0x50, 0x4b, 0x05, 0x06 + }; + private static final int ZIP_LOCAL_HEADER_FIXED_LENGTH = 30; + private static final int ZIP_CENTRAL_HEADER_FIXED_LENGTH = 46; + private static final int ZIP_EOCD_MINIMUM_LENGTH = 22; + private static final int ZIP_MAXIMUM_COMMENT_LENGTH = 65_535; + private static final int ZIP_STORED_METHOD = 0; + private static final int MAX_PACKAGE_METADATA_BYTES = 1_048_576; + + private OfficeOoxmlRelationshipPreflight() { + } + + /** + * Rejects external OOXML relationships and VBA project content-type declarations + * while leaving non-OOXML formats unchanged. + * + * @param request request that already passed the common Office container preflight + * @throws OfficeConversionException when a security-relevant metadata part is + * oversized or malformed, declares an external target, or declares VBA + * project active content + */ + static void requireNoExternalRelationships(OfficeConversionRequest request) { + if (!OOXML_FORMATS.contains(request.sourceFormat())) { + return; + } + + byte[] sourceBytes = request.sourceBytes(); + int eocdOffset = findEocdOffset(sourceBytes); + int entryCount = unsignedShort(sourceBytes, eocdOffset + 10); + int cursor = (int) unsignedInt(sourceBytes, eocdOffset + 16); + for (int index = 0; index < entryCount; index++) { + int compressionMethod = unsignedShort(sourceBytes, cursor + 10); + long compressedSize = unsignedInt(sourceBytes, cursor + 20); + long uncompressedSize = unsignedInt(sourceBytes, cursor + 24); + int fileNameLength = unsignedShort(sourceBytes, cursor + 28); + int extraFieldLength = unsignedShort(sourceBytes, cursor + 30); + int fileCommentLength = unsignedShort(sourceBytes, cursor + 32); + int localHeaderOffset = (int) unsignedInt(sourceBytes, cursor + 42); + int nameOffset = cursor + ZIP_CENTRAL_HEADER_FIXED_LENGTH; + String entryName = new String( + sourceBytes, + nameOffset, + fileNameLength, + StandardCharsets.ISO_8859_1 + ); + if (RELATIONSHIP_PART.matcher(entryName).matches()) { + byte[] relationshipBytes = extractMetadataPart( + sourceBytes, + localHeaderOffset, + compressionMethod, + compressedSize, + uncompressedSize, + "source OOXML relationship part exceeds maximum bytes", + "source OOXML relationship part is invalid" + ); + requireNoExternalRelationship(relationshipBytes); + } + if (CONTENT_TYPES_PART.equals(entryName)) { + byte[] contentTypeBytes = extractMetadataPart( + sourceBytes, + localHeaderOffset, + compressionMethod, + compressedSize, + uncompressedSize, + "source OOXML content types part exceeds maximum bytes", + "source OOXML content types part is invalid" + ); + requireNoProhibitedContentType(contentTypeBytes); + } + cursor = nameOffset + fileNameLength + extraFieldLength + fileCommentLength; + } + } + + private static byte[] extractMetadataPart( + byte[] sourceBytes, + int localHeaderOffset, + int compressionMethod, + long compressedSizeLong, + long uncompressedSizeLong, + String oversizedMessage, + String invalidMessage + ) { + if (uncompressedSizeLong > MAX_PACKAGE_METADATA_BYTES) { + throw metadataFailure(OfficeConversionFailureCode.POLICY_DENIED, oversizedMessage); + } + int compressedSize = Math.toIntExact(compressedSizeLong); + int uncompressedSize = Math.toIntExact(uncompressedSizeLong); + int localNameLength = unsignedShort(sourceBytes, localHeaderOffset + 26); + int localExtraLength = unsignedShort(sourceBytes, localHeaderOffset + 28); + int dataOffset = localHeaderOffset + + ZIP_LOCAL_HEADER_FIXED_LENGTH + + localNameLength + + localExtraLength; + if (compressionMethod == ZIP_STORED_METHOD) { + return Arrays.copyOfRange(sourceBytes, dataOffset, dataOffset + compressedSize); + } + + Inflater inflater = new Inflater(true); + try (ByteArrayInputStream compressed = new ByteArrayInputStream( + sourceBytes, + dataOffset, + compressedSize + ); InflaterInputStream input = new InflaterInputStream(compressed, inflater)) { + byte[] expanded = input.readNBytes(uncompressedSize + 1); + if (expanded.length != uncompressedSize) { + throw metadataFailure(OfficeConversionFailureCode.MALFORMED_INPUT, invalidMessage); + } + return expanded; + } catch (IOException ex) { + throw metadataFailure(OfficeConversionFailureCode.MALFORMED_INPUT, invalidMessage); + } finally { + inflater.end(); + } + } + + private static void requireNoExternalRelationship(byte[] relationshipBytes) { + XMLInputFactory factory = secureXmlInputFactory(); + try (ByteArrayInputStream input = new ByteArrayInputStream(relationshipBytes)) { + XMLStreamReader reader = factory.createXMLStreamReader(input); + try { + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT + && RELATIONSHIP_ELEMENT.equals(reader.getName()) + && "External".equalsIgnoreCase(reader.getAttributeValue(null, "TargetMode"))) { + throw externalRelationship(); + } + } + } finally { + reader.close(); + } + } catch (XMLStreamException | IOException ex) { + throw metadataFailure( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source OOXML relationship part is invalid" + ); + } + } + + private static void requireNoProhibitedContentType(byte[] contentTypeBytes) { + XMLInputFactory factory = secureXmlInputFactory(); + try (ByteArrayInputStream input = new ByteArrayInputStream(contentTypeBytes)) { + XMLStreamReader reader = factory.createXMLStreamReader(input); + try { + while (reader.hasNext()) { + int event = reader.next(); + if (event != XMLStreamConstants.START_ELEMENT) { + continue; + } + String contentType = reader.getAttributeValue(null, "ContentType"); + if (VBA_PROJECT_CONTENT_TYPE.equalsIgnoreCase(contentType)) { + throw prohibitedActiveContent(); + } + } + } finally { + reader.close(); + } + } catch (XMLStreamException | IOException ex) { + throw metadataFailure( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source OOXML content types part is invalid" + ); + } + } + + private static XMLInputFactory secureXmlInputFactory() { + XMLInputFactory factory = XMLInputFactory.newFactory(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + return factory; + } + + private static int findEocdOffset(byte[] sourceBytes) { + int latest = sourceBytes.length - ZIP_EOCD_MINIMUM_LENGTH; + int earliest = Math.max(0, latest - ZIP_MAXIMUM_COMMENT_LENGTH); + for (int offset = latest; offset >= earliest; offset--) { + if (matchesAt(sourceBytes, offset, ZIP_END_OF_CENTRAL_DIRECTORY) + && offset + ZIP_EOCD_MINIMUM_LENGTH + unsignedShort(sourceBytes, offset + 20) + == sourceBytes.length) { + return offset; + } + } + throw metadataFailure( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source OOXML relationship part is invalid" + ); + } + + private static boolean matchesAt(byte[] sourceBytes, int offset, byte[] expected) { + for (int index = 0; index < expected.length; index++) { + if (sourceBytes[offset + index] != expected[index]) { + return false; + } + } + return true; + } + + private static int unsignedShort(byte[] sourceBytes, int offset) { + return Byte.toUnsignedInt(sourceBytes[offset]) + | (Byte.toUnsignedInt(sourceBytes[offset + 1]) << 8); + } + + private static long unsignedInt(byte[] sourceBytes, int offset) { + return Integer.toUnsignedLong( + Byte.toUnsignedInt(sourceBytes[offset]) + | (Byte.toUnsignedInt(sourceBytes[offset + 1]) << 8) + | (Byte.toUnsignedInt(sourceBytes[offset + 2]) << 16) + | (Byte.toUnsignedInt(sourceBytes[offset + 3]) << 24) + ); + } + + private static OfficeConversionException externalRelationship() { + return new OfficeConversionException( + OfficeConversionFailureCode.POLICY_DENIED, + "source Office package contains an external relationship" + ); + } + + private static OfficeConversionException prohibitedActiveContent() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source Office package contains prohibited active content" + ); + } + + private static OfficeConversionException metadataFailure( + OfficeConversionFailureCode failureCode, + String message + ) { + return new OfficeConversionException(failureCode, message); + } +} diff --git a/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java new file mode 100644 index 00000000..6152f66c --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflight.java @@ -0,0 +1,637 @@ +package com.clearfolio.viewer.conversion; + +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Performs format-neutral source-container checks shared by qualified Office converters. + * + *

This preflight intentionally proves only a bounded set of facts before untrusted + * bytes reach a sidecar or remote converter: the declared source format belongs to the + * current Office conversion candidate set, the leading container signature matches that + * format family, and ZIP-family candidates contain self-consistent standard single-disk + * local-header, central-directory, and end-of-central-directory framing. ZIP entries are + * limited to the current Stored/Deflate compression qualification boundary, Stored entry + * sizes must be internally consistent, Deflate entries cannot claim non-empty expansion + * from an empty compressed payload, local and central data-descriptor flags plus CRC and + * size metadata must agree where applicable, advertised compressed bytes cannot extend + * beyond the local-data area before the central directory, duplicate or ASCII-case-equivalent + * package part names are rejected, and entry names are rejected when they are absolute, + * contain parent traversal or empty path segments, contain a segment ending in a dot, end + * with a path separator, use backslash path separators, or contain NUL bytes. Known OOXML + * active-content and external-resource parts, including VBA projects, embedded objects, + * external links, and ActiveX content, are rejected before provider invocation. OpenDocument + * candidates additionally require the package manifest, restrict META-INF content to the + * manifest plus signature-named entries, and, when a mimetype entry is present, require it + * to be the first local ZIP entry, stored without compression, free of a local-header extra + * field, and equal to the media type implied by the declared ODF format. Passing this + * preflight is not complete package, macro, embedded-object, + * external-resource, archive-expansion, malware, or fidelity qualification. Those deeper + * controls remain separate sandbox/content-policy acceptance gates.

+ */ +final class OfficeSourceContainerPreflight { + + private static final Set ZIP_PACKAGE_FORMATS = Set.of( + "docx", "xlsx", "pptx", "odt", "ods", "odp" + ); + private static final Set ODF_PACKAGE_FORMATS = Set.of( + "odt", "ods", "odp" + ); + private static final Set COMPOUND_FILE_FORMATS = Set.of( + "doc", "xls", "ppt" + ); + private static final Map ODF_MIMETYPE_BY_FORMAT = Map.of( + "odt", "application/vnd.oasis.opendocument.text".getBytes(StandardCharsets.US_ASCII), + "ods", "application/vnd.oasis.opendocument.spreadsheet".getBytes(StandardCharsets.US_ASCII), + "odp", "application/vnd.oasis.opendocument.presentation".getBytes(StandardCharsets.US_ASCII) + ); + private static final byte[] ODF_MANIFEST_ENTRY_NAME = + "META-INF/manifest.xml".getBytes(StandardCharsets.UTF_8); + private static final byte[] ODF_MIMETYPE_ENTRY_NAME = + "mimetype".getBytes(StandardCharsets.UTF_8); + private static final byte[] ODF_META_INF_PREFIX = + "META-INF/".getBytes(StandardCharsets.UTF_8); + private static final byte[] ODF_SIGNATURES_NAME_FRAGMENT = + "signatures".getBytes(StandardCharsets.UTF_8); + private static final byte[] ZIP_LOCAL_FILE_HEADER = new byte[] { + 0x50, 0x4b, 0x03, 0x04 + }; + private static final byte[] ZIP_CENTRAL_DIRECTORY_HEADER = new byte[] { + 0x50, 0x4b, 0x01, 0x02 + }; + private static final byte[] ZIP_END_OF_CENTRAL_DIRECTORY = new byte[] { + 0x50, 0x4b, 0x05, 0x06 + }; + private static final byte[] COMPOUND_FILE_HEADER = new byte[] { + (byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0, + (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1 + }; + private static final int ZIP_EOCD_MINIMUM_LENGTH = 22; + private static final int ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH = 30; + private static final int ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH = 46; + private static final int ZIP_MAXIMUM_COMMENT_LENGTH = 65_535; + private static final int ZIP16_SENTINEL = 0xffff; + private static final long ZIP32_SENTINEL = 0xffff_ffffL; + private static final int ZIP_ENCRYPTED_FLAG = 0x0001; + private static final int ZIP_DATA_DESCRIPTOR_FLAG = 0x0008; + private static final int ZIP_STORED_METHOD = 0; + private static final int ZIP_DEFLATED_METHOD = 8; + private static final byte ZIP_PATH_SEPARATOR = (byte) '/'; + private static final byte ZIP_WINDOWS_PATH_SEPARATOR = (byte) '\\'; + private static final byte ZIP_NUL = 0; + private static final byte ZIP_DOT = (byte) '.'; + private static final byte ZIP_COLON = (byte) ':'; + + private OfficeSourceContainerPreflight() { + } + + /** + * Rejects unknown candidate formats and invalid source-container framing. + * + * @param request immutable conversion request containing declared format and source bytes + * @throws OfficeConversionException when the format is not a current candidate, the + * source does not match that format family's required container signature, a + * ZIP-family source has invalid local/central-directory framing, has inconsistent + * Stored/Deflate entry sizes or duplicated metadata, contains duplicate or + * ASCII-case-equivalent package part names, advertises compressed bytes beyond its + * local-data region, uses a ZIP compression method outside the current Stored/Deflate + * qualification boundary, contains an encrypted entry, has an unsafe ZIP entry path, + * contains prohibited OOXML active content or external-resource parts, or an ODF + * candidate violates required package structure + */ + static void requireQualifiedContainer(OfficeConversionRequest request) { + String sourceFormat = request.sourceFormat(); + byte[] sourceBytes = request.sourceBytes(); + + if (ZIP_PACKAGE_FORMATS.contains(sourceFormat)) { + requireSignature(sourceBytes, ZIP_LOCAL_FILE_HEADER); + requireStandardZipFraming(sourceBytes, sourceFormat); + return; + } + if (COMPOUND_FILE_FORMATS.contains(sourceFormat)) { + requireSignature(sourceBytes, COMPOUND_FILE_HEADER); + return; + } + throw new OfficeConversionException( + OfficeConversionFailureCode.UNSUPPORTED_FORMAT, + "source format is not an Office conversion candidate" + ); + } + + private static void requireSignature(byte[] sourceBytes, byte[] expectedSignature) { + if (!matchesAt(sourceBytes, 0, expectedSignature)) { + throw new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source container signature does not match declared format" + ); + } + } + + private static void requireStandardZipFraming(byte[] sourceBytes, String sourceFormat) { + int eocdOffset = findEocdOffset(sourceBytes); + if (eocdOffset < 0 || !isStandardSingleDiskEocd(sourceBytes, eocdOffset)) { + throw invalidZipFraming(); + } + + int entryCount = unsignedShort(sourceBytes, eocdOffset + 10); + long centralDirectorySize = unsignedInt(sourceBytes, eocdOffset + 12); + long centralDirectoryOffset = unsignedInt(sourceBytes, eocdOffset + 16); + if (entryCount == ZIP16_SENTINEL + || centralDirectorySize == ZIP32_SENTINEL + || centralDirectoryOffset == ZIP32_SENTINEL + || centralDirectorySize == 0L + || centralDirectoryOffset > Integer.MAX_VALUE) { + throw invalidZipFraming(); + } + + long centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize; + if (centralDirectoryEnd > eocdOffset + || !matchesAt(sourceBytes, (int) centralDirectoryOffset, ZIP_CENTRAL_DIRECTORY_HEADER)) { + throw invalidZipFraming(); + } + boolean requireOdfManifest = ODF_PACKAGE_FORMATS.contains(sourceFormat); + requireCentralDirectoryRecords( + sourceBytes, + (int) centralDirectoryOffset, + (int) centralDirectoryEnd, + entryCount, + requireOdfManifest, + ODF_MIMETYPE_BY_FORMAT.get(sourceFormat) + ); + } + + private static void requireCentralDirectoryRecords( + byte[] sourceBytes, + int centralDirectoryOffset, + int centralDirectoryEnd, + int entryCount, + boolean requireOdfManifest, + byte[] expectedOdfMimetype + ) { + int cursor = centralDirectoryOffset; + boolean odfManifestFound = false; + Set normalizedEntryNames = new HashSet<>(); + for (int entryIndex = 0; entryIndex < entryCount; entryIndex++) { + if (cursor > centralDirectoryEnd - ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH + || !matchesAt(sourceBytes, cursor, ZIP_CENTRAL_DIRECTORY_HEADER)) { + throw invalidCentralDirectory(); + } + + int flags = unsignedShort(sourceBytes, cursor + 8); + if ((flags & ZIP_ENCRYPTED_FLAG) != 0) { + throw encryptedZipEntry(); + } + int compressionMethod = unsignedShort(sourceBytes, cursor + 10); + if (!isAllowedCompressionMethod(compressionMethod)) { + throw unsupportedCompressionMethod(); + } + + long crc32 = unsignedInt(sourceBytes, cursor + 16); + long compressedSize = unsignedInt(sourceBytes, cursor + 20); + long uncompressedSize = unsignedInt(sourceBytes, cursor + 24); + int fileNameLength = unsignedShort(sourceBytes, cursor + 28); + int extraFieldLength = unsignedShort(sourceBytes, cursor + 30); + int fileCommentLength = unsignedShort(sourceBytes, cursor + 32); + int diskStart = unsignedShort(sourceBytes, cursor + 34); + long localHeaderOffset = unsignedInt(sourceBytes, cursor + 42); + if (compressedSize == ZIP32_SENTINEL + || uncompressedSize == ZIP32_SENTINEL + || diskStart == ZIP16_SENTINEL + || diskStart != 0 + || localHeaderOffset == ZIP32_SENTINEL + || localHeaderOffset >= centralDirectoryOffset + || !matchesAt(sourceBytes, (int) localHeaderOffset, ZIP_LOCAL_FILE_HEADER)) { + throw invalidCentralDirectory(); + } + if (compressionMethod == ZIP_STORED_METHOD && compressedSize != uncompressedSize) { + throw inconsistentStoredEntrySizes(); + } + if (compressionMethod == ZIP_DEFLATED_METHOD + && compressedSize == 0L + && uncompressedSize > 0L) { + throw inconsistentDeflatedEntrySizes(); + } + + long recordLength = (long) ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH + + fileNameLength + + extraFieldLength + + fileCommentLength; + long nextCursor = (long) cursor + recordLength; + if (nextCursor > centralDirectoryEnd) { + throw invalidCentralDirectory(); + } + int centralNameOffset = cursor + ZIP_CENTRAL_DIRECTORY_MINIMUM_LENGTH; + String rawEntryName = new String( + sourceBytes, + centralNameOffset, + fileNameLength, + StandardCharsets.ISO_8859_1 + ); + String normalizedEntryName = rawEntryName.toLowerCase(java.util.Locale.ROOT); + if (!normalizedEntryNames.add(normalizedEntryName)) { + throw duplicateEntryName(); + } + String entryBaseName = rawEntryName.substring(rawEntryName.lastIndexOf('/') + 1); + if ("vbaProject.bin".equalsIgnoreCase(entryBaseName) + || normalizedEntryName.contains("/embeddings/") + || normalizedEntryName.contains("/externallinks/") + || normalizedEntryName.contains("/activex/")) { + throw prohibitedOfficeActiveContent(); + } + long localDataOffset = requireMatchingLocalHeaderMetadata( + sourceBytes, + (int) localHeaderOffset, + centralDirectoryOffset, + centralNameOffset, + fileNameLength, + flags, + compressionMethod, + crc32, + compressedSize, + uncompressedSize + ); + if (localDataOffset + compressedSize > centralDirectoryOffset) { + throw invalidEntryDataRange(); + } + requireSafeEntryPath(sourceBytes, centralNameOffset, fileNameLength); + boolean odfManifestEntry = requireOdfManifest + && entryNameMatches( + sourceBytes, + centralNameOffset, + fileNameLength, + ODF_MANIFEST_ENTRY_NAME + ); + if (requireOdfManifest + && entryNameStartsWith( + sourceBytes, + centralNameOffset, + fileNameLength, + ODF_META_INF_PREFIX + ) + && !odfManifestEntry + && !entryNameContains( + sourceBytes, + centralNameOffset, + fileNameLength, + ODF_SIGNATURES_NAME_FRAGMENT + )) { + throw invalidOdfMetaInfEntry(); + } + boolean odfMimetypeEntry = requireOdfManifest + && entryNameMatches( + sourceBytes, + centralNameOffset, + fileNameLength, + ODF_MIMETYPE_ENTRY_NAME + ); + if (odfMimetypeEntry && localHeaderOffset != 0L) { + throw invalidOdfMimetypePlacement(); + } + if (odfMimetypeEntry && compressionMethod != ZIP_STORED_METHOD) { + throw compressedOdfMimetype(); + } + if (odfMimetypeEntry + && unsignedShort(sourceBytes, (int) localHeaderOffset + 28) != 0) { + throw invalidOdfMimetypeExtraField(); + } + if (odfMimetypeEntry + && (compressedSize != expectedOdfMimetype.length + || !matchesAt(sourceBytes, (int) localDataOffset, expectedOdfMimetype))) { + throw invalidOdfMimetypePayload(); + } + if (odfManifestEntry) { + odfManifestFound = true; + } + cursor = (int) nextCursor; + } + if (cursor != centralDirectoryEnd) { + throw invalidCentralDirectory(); + } + if (requireOdfManifest && !odfManifestFound) { + throw missingOdfManifest(); + } + } + + private static long requireMatchingLocalHeaderMetadata( + byte[] sourceBytes, + int localHeaderOffset, + int centralDirectoryOffset, + int centralNameOffset, + int centralNameLength, + int centralFlags, + int centralCompressionMethod, + long centralCrc32, + long centralCompressedSize, + long centralUncompressedSize + ) { + if (localHeaderOffset > centralDirectoryOffset - ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH) { + throw invalidLocalHeader(); + } + int localFlags = unsignedShort(sourceBytes, localHeaderOffset + 6); + if ((localFlags & ZIP_ENCRYPTED_FLAG) != 0) { + throw encryptedZipEntry(); + } + int localCompressionMethod = unsignedShort(sourceBytes, localHeaderOffset + 8); + int localNameLength = unsignedShort(sourceBytes, localHeaderOffset + 26); + int localExtraFieldLength = unsignedShort(sourceBytes, localHeaderOffset + 28); + long localNameOffset = (long) localHeaderOffset + ZIP_LOCAL_FILE_HEADER_MINIMUM_LENGTH; + long localHeaderMetadataEnd = localNameOffset + localNameLength + localExtraFieldLength; + if (localCompressionMethod != centralCompressionMethod + || localNameLength != centralNameLength + || localHeaderMetadataEnd > centralDirectoryOffset) { + throw invalidLocalHeader(); + } + boolean centralUsesDataDescriptor = (centralFlags & ZIP_DATA_DESCRIPTOR_FLAG) != 0; + boolean localUsesDataDescriptor = (localFlags & ZIP_DATA_DESCRIPTOR_FLAG) != 0; + if (centralUsesDataDescriptor != localUsesDataDescriptor) { + throw invalidLocalHeader(); + } + if (!centralUsesDataDescriptor) { + long localCrc32 = unsignedInt(sourceBytes, localHeaderOffset + 14); + long localCompressedSize = unsignedInt(sourceBytes, localHeaderOffset + 18); + long localUncompressedSize = unsignedInt(sourceBytes, localHeaderOffset + 22); + if (localCrc32 != centralCrc32 + || localCompressedSize != centralCompressedSize + || localUncompressedSize != centralUncompressedSize) { + throw invalidLocalHeader(); + } + } + for (int index = 0; index < centralNameLength; index++) { + if (sourceBytes[(int) localNameOffset + index] != sourceBytes[centralNameOffset + index]) { + throw invalidLocalHeader(); + } + } + return localHeaderMetadataEnd; + } + + private static boolean entryNameMatches( + byte[] sourceBytes, + int nameOffset, + int nameLength, + byte[] expectedName + ) { + if (nameLength != expectedName.length) { + return false; + } + for (int index = 0; index < expectedName.length; index++) { + if (sourceBytes[nameOffset + index] != expectedName[index]) { + return false; + } + } + return true; + } + + private static boolean entryNameStartsWith( + byte[] sourceBytes, + int nameOffset, + int nameLength, + byte[] expectedPrefix + ) { + if (nameLength < expectedPrefix.length) { + return false; + } + for (int index = 0; index < expectedPrefix.length; index++) { + if (sourceBytes[nameOffset + index] != expectedPrefix[index]) { + return false; + } + } + return true; + } + + private static boolean entryNameContains( + byte[] sourceBytes, + int nameOffset, + int nameLength, + byte[] expectedFragment + ) { + int lastStart = nameLength - expectedFragment.length; + for (int start = 0; start <= lastStart; start++) { + boolean match = true; + for (int index = 0; index < expectedFragment.length; index++) { + if (sourceBytes[nameOffset + start + index] != expectedFragment[index]) { + match = false; + break; + } + } + if (match) { + return true; + } + } + return false; + } + + private static boolean isAllowedCompressionMethod(int compressionMethod) { + return compressionMethod == ZIP_STORED_METHOD || compressionMethod == ZIP_DEFLATED_METHOD; + } + + private static void requireSafeEntryPath(byte[] sourceBytes, int nameOffset, int nameLength) { + if (nameLength == 0) { + throw unsafeEntryPath(); + } + int nameEnd = nameOffset + nameLength; + byte first = sourceBytes[nameOffset]; + if (first == ZIP_PATH_SEPARATOR || first == ZIP_WINDOWS_PATH_SEPARATOR) { + throw unsafeEntryPath(); + } + if (nameLength >= 2 && isAsciiLetter(first) && sourceBytes[nameOffset + 1] == ZIP_COLON) { + throw unsafeEntryPath(); + } + + int segmentStart = nameOffset; + for (int cursor = nameOffset; cursor < nameEnd; cursor++) { + byte current = sourceBytes[cursor]; + if (current == ZIP_NUL || current == ZIP_WINDOWS_PATH_SEPARATOR) { + throw unsafeEntryPath(); + } + if (current == ZIP_PATH_SEPARATOR) { + if (cursor == segmentStart || sourceBytes[cursor - 1] == ZIP_DOT) { + throw unsafeEntryPath(); + } + segmentStart = cursor + 1; + } + } + if (segmentStart == nameEnd || sourceBytes[nameEnd - 1] == ZIP_DOT) { + throw unsafeEntryPath(); + } + } + + private static boolean isAsciiLetter(byte value) { + return (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z'); + } + + private static int findEocdOffset(byte[] sourceBytes) { + if (sourceBytes.length < ZIP_EOCD_MINIMUM_LENGTH) { + return -1; + } + int latest = sourceBytes.length - ZIP_EOCD_MINIMUM_LENGTH; + int earliest = Math.max(0, latest - ZIP_MAXIMUM_COMMENT_LENGTH); + for (int offset = latest; offset >= earliest; offset--) { + if (!matchesAt(sourceBytes, offset, ZIP_END_OF_CENTRAL_DIRECTORY)) { + continue; + } + int commentLength = unsignedShort(sourceBytes, offset + 20); + if (offset + ZIP_EOCD_MINIMUM_LENGTH + commentLength == sourceBytes.length) { + return offset; + } + } + return -1; + } + + private static boolean isStandardSingleDiskEocd(byte[] sourceBytes, int eocdOffset) { + int diskNumber = unsignedShort(sourceBytes, eocdOffset + 4); + int centralDirectoryDisk = unsignedShort(sourceBytes, eocdOffset + 6); + int entriesOnDisk = unsignedShort(sourceBytes, eocdOffset + 8); + int totalEntries = unsignedShort(sourceBytes, eocdOffset + 10); + return diskNumber == 0 + && centralDirectoryDisk == 0 + && entriesOnDisk > 0 + && entriesOnDisk == totalEntries; + } + + private static boolean matchesAt(byte[] sourceBytes, int offset, byte[] signature) { + if (offset > sourceBytes.length - signature.length) { + return false; + } + for (int index = 0; index < signature.length; index++) { + if (sourceBytes[offset + index] != signature[index]) { + return false; + } + } + return true; + } + + private static int unsignedShort(byte[] sourceBytes, int offset) { + return Byte.toUnsignedInt(sourceBytes[offset]) + | (Byte.toUnsignedInt(sourceBytes[offset + 1]) << 8); + } + + private static long unsignedInt(byte[] sourceBytes, int offset) { + return Integer.toUnsignedLong( + Byte.toUnsignedInt(sourceBytes[offset]) + | (Byte.toUnsignedInt(sourceBytes[offset + 1]) << 8) + | (Byte.toUnsignedInt(sourceBytes[offset + 2]) << 16) + | (Byte.toUnsignedInt(sourceBytes[offset + 3]) << 24) + ); + } + + private static OfficeConversionException encryptedZipEntry() { + return new OfficeConversionException( + OfficeConversionFailureCode.PASSWORD_PROTECTED, + "source ZIP entry is encrypted" + ); + } + + private static OfficeConversionException invalidZipFraming() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ZIP container framing is invalid" + ); + } + + private static OfficeConversionException invalidCentralDirectory() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ZIP central directory is invalid" + ); + } + + private static OfficeConversionException invalidLocalHeader() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ZIP local header does not match central directory" + ); + } + + private static OfficeConversionException inconsistentStoredEntrySizes() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ZIP stored entry sizes are inconsistent" + ); + } + + private static OfficeConversionException inconsistentDeflatedEntrySizes() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ZIP deflated entry sizes are inconsistent" + ); + } + + private static OfficeConversionException invalidEntryDataRange() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ZIP entry data exceeds local data region" + ); + } + + private static OfficeConversionException duplicateEntryName() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ZIP contains duplicate entry name" + ); + } + + private static OfficeConversionException prohibitedOfficeActiveContent() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source Office package contains prohibited active content" + ); + } + + private static OfficeConversionException missingOdfManifest() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF package manifest is missing" + ); + } + + private static OfficeConversionException invalidOdfMetaInfEntry() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF META-INF entry is not allowed" + ); + } + + private static OfficeConversionException invalidOdfMimetypePlacement() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF mimetype entry must be first" + ); + } + + private static OfficeConversionException compressedOdfMimetype() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF mimetype entry must be stored without compression" + ); + } + + private static OfficeConversionException invalidOdfMimetypeExtraField() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF mimetype entry must not use a local extra field" + ); + } + + private static OfficeConversionException invalidOdfMimetypePayload() { + return new OfficeConversionException( + OfficeConversionFailureCode.MALFORMED_INPUT, + "source ODF mimetype does not match declared format" + ); + } + + private static OfficeConversionException unsupportedCompressionMethod() { + return new OfficeConversionException( + OfficeConversionFailureCode.POLICY_DENIED, + "source ZIP compression method is not allowed" + ); + } + + private static OfficeConversionException unsafeEntryPath() { + return new OfficeConversionException( + OfficeConversionFailureCode.POLICY_DENIED, + "source ZIP entry path is unsafe" + ); + } +} diff --git a/src/main/java/com/clearfolio/viewer/exception/UnsupportedDocumentFormatException.java b/src/main/java/com/clearfolio/viewer/exception/UnsupportedDocumentFormatException.java index 9d1b4f9c..5971e55b 100644 --- a/src/main/java/com/clearfolio/viewer/exception/UnsupportedDocumentFormatException.java +++ b/src/main/java/com/clearfolio/viewer/exception/UnsupportedDocumentFormatException.java @@ -7,6 +7,7 @@ public class UnsupportedDocumentFormatException extends IllegalArgumentException private static final long serialVersionUID = 1L; + /** Blocked extension supplied to the constructor. */ private final String extension; /** diff --git a/src/main/java/com/clearfolio/viewer/model/ConversionJobStatus.java b/src/main/java/com/clearfolio/viewer/model/ConversionJobStatus.java index 742d6440..32abf292 100644 --- a/src/main/java/com/clearfolio/viewer/model/ConversionJobStatus.java +++ b/src/main/java/com/clearfolio/viewer/model/ConversionJobStatus.java @@ -4,8 +4,12 @@ * Lifecycle states for a conversion job. */ public enum ConversionJobStatus { + /** The service accepted and persisted the job but processing has not started. */ SUBMITTED, + /** A worker currently owns the processing lease and is attempting conversion. */ PROCESSING, + /** Conversion completed and the generated artifact is available. */ SUCCEEDED, + /** The latest conversion attempt failed, with retry metadata stored separately. */ FAILED } diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index 7d7ee638..bb89b302 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -32,6 +32,13 @@ public class InMemoryConversionJobRepository implements ConversionJobRepository, private final ConcurrentHashMap jobsByTenantAndContentHash = new ConcurrentHashMap<>(); private final ConcurrentLinkedQueue lifecycleEvents = new ConcurrentLinkedQueue<>(); + /** + * Creates an empty thread-safe process-local job repository and lifecycle ledger. + */ + public InMemoryConversionJobRepository() { + // Concurrent collections are initialized eagerly for immediate multi-worker use. + } + /** * {@inheritDoc} */ diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java new file mode 100644 index 00000000..222261ba --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -0,0 +1,111 @@ +package com.clearfolio.viewer.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Objects; + +import org.springframework.stereotype.Component; + +import com.clearfolio.viewer.config.ConversionProperties; + +/** + * Validates policy-override key material before an override-capable component + * can accept traffic. + * + *

The policy-override key protects an administrative authorization decision, + * so a configured value must contain at least 32 UTF-8 bytes. Enabling that + * signing key also requires a dedicated audit pseudonym key: accepting an + * override while emitting only an unavailable marker would prevent operators + * from distinguishing approvers during an investigation. The policy and audit + * HMAC purposes remain separate security domains, and their configured values + * must therefore be distinct.

+ * + *

Spring creates this component during application startup. Modular or + * standalone callers that construct an override-capable service directly use + * {@link #validate(ConversionProperties)} so they receive the same fail-closed + * contract without depending on the Spring container.

+ */ +@Component +public final class AuditKeySeparationGuard { + + private static final int MINIMUM_POLICY_SECRET_BYTES = 32; + + /** + * Validates the bound conversion security configuration during bean startup. + * + * @param properties bound conversion configuration + * @throws NullPointerException if {@code properties} is {@code null} + * @throws IllegalStateException if configured key material is weak, + * incomplete, or reused across security purposes + */ + public AuditKeySeparationGuard(ConversionProperties properties) { + validate(properties); + } + + /** + * Applies the complete policy-override key contract for Spring-managed, + * standalone, and modular service construction. + * + *

When policy override is disabled, both keys may be absent. When policy + * signing is enabled, its key must contain at least 32 UTF-8 bytes, a + * dedicated audit pseudonym key must be present, and the two values must be + * different. The audit key performs its own strength validation when the + * pseudonymizer is constructed.

+ * + * @param properties conversion security configuration to validate + * @throws NullPointerException if {@code properties} is {@code null} + * @throws IllegalStateException if configured key material is weak, + * incomplete, or reused across security purposes + */ + public static void validate(ConversionProperties properties) { + ConversionProperties requiredProperties = Objects.requireNonNull( + properties, + "properties" + ); + String policySecret = requiredProperties.getPolicyOverrideSecret(); + String auditSecret = requiredProperties.getAuditPseudonymSecret(); + requireStrongPolicySecret(policySecret); + requireAuditKeyWhenPolicySigningIsEnabled(policySecret, auditSecret); + requireDistinct(policySecret, auditSecret); + } + + static void requireStrongPolicySecret(String policySecret) { + if (!isConfigured(policySecret)) { + return; + } + if (policySecret.getBytes(StandardCharsets.UTF_8).length + < MINIMUM_POLICY_SECRET_BYTES) { + throw new IllegalStateException( + "policy override key must contain at least 32 UTF-8 bytes" + ); + } + } + + static void requireAuditKeyWhenPolicySigningIsEnabled( + String policySecret, + String auditSecret + ) { + if (isConfigured(policySecret) && !isConfigured(auditSecret)) { + throw new IllegalStateException( + "audit pseudonym key is required when policy override signing is enabled" + ); + } + } + + static void requireDistinct(String policySecret, String auditSecret) { + if (!isConfigured(policySecret) || !isConfigured(auditSecret)) { + return; + } + if (MessageDigest.isEqual( + policySecret.getBytes(StandardCharsets.UTF_8), + auditSecret.getBytes(StandardCharsets.UTF_8))) { + throw new IllegalStateException( + "policy override and audit pseudonym keys must be different" + ); + } + } + + private static boolean isConfigured(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java new file mode 100644 index 00000000..ed3bd31d --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java @@ -0,0 +1,140 @@ +package com.clearfolio.viewer.security; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Produces privacy-safe, domain-separated audit fingerprints for identifiers. + * + *

The pseudonymizer deliberately uses a dedicated keyed HMAC rather than an + * unkeyed digest. This prevents practical dictionary attacks against common + * low-entropy identifiers such as usernames, employee numbers, and email + * addresses. Fingerprints are stable only within the configured key version + * and domain.

+ */ +public final class AuditPseudonymizer { + + private static final String HMAC_SHA_256 = "HmacSHA256"; + private static final String DEFAULT_KEY_VERSION = "v1"; + private static final String APPROVER_DOMAIN = "clearfolio:audit-approver:v1"; + private static final int FINGERPRINT_BYTES = 16; + private static final int MIN_SECRET_BYTES = 32; + private static final int MAX_KEY_VERSION_LENGTH = 32; + private static final HexFormat HEX_FORMAT = HexFormat.of(); + + private final byte[] secretBytes; + private final String keyVersion; + private final String domain; + + /** + * Creates an approver audit pseudonymizer. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; blank disables correlation + * @param keyVersion non-sensitive key-rotation identifier + */ + public AuditPseudonymizer(String secret, String keyVersion) { + this(secret, keyVersion, APPROVER_DOMAIN); + } + + /** + * Creates a pseudonymizer with an explicit domain for isolated internal use + * and domain-separation verification. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; blank disables correlation + * @param keyVersion non-sensitive key-rotation identifier + * @param domain stable protocol-specific domain separator + */ + AuditPseudonymizer(String secret, String keyVersion, String domain) { + this.secretBytes = configuredSecretBytes(secret); + this.keyVersion = normalizeKeyVersion(keyVersion); + this.domain = requireDomain(domain); + } + + /** + * Returns a stable keyed fingerprint without exposing the supplied identifier. + * + *

Null input is represented by a fixed absent marker. If no dedicated key + * is configured, the method emits a fixed non-correlatable unavailable marker + * instead of falling back to plaintext or an unkeyed hash. Empty input remains + * distinct from absent input and is HMACed exactly as supplied.

+ * + * @param identifier exact identifier bytes represented as a Java string + * @return versioned fingerprint or a fixed safe marker + */ + public String fingerprint(String identifier) { + if (identifier == null) { + return "absent:" + keyVersion; + } + if (secretBytes == null) { + return "unavailable:" + keyVersion; + } + + String payload = domain + "\n" + identifier; + try { + Mac mac = Mac.getInstance(HMAC_SHA_256); + mac.init(new SecretKeySpec(secretBytes, HMAC_SHA_256)); + byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return keyVersion + ":" + HEX_FORMAT.formatHex(digest, 0, FINGERPRINT_BYTES); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("audit pseudonym HMAC unavailable", ex); + } + } + + private static byte[] configuredSecretBytes(String secret) { + String configuredSecret = cleanSecret(secret); + if (configuredSecret == null) { + return null; + } + + byte[] bytes = configuredSecret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < MIN_SECRET_BYTES) { + throw new IllegalArgumentException( + "audit pseudonym secret must contain at least 32 UTF-8 bytes" + ); + } + return bytes; + } + + private static String cleanSecret(String secret) { + if (secret == null || secret.isBlank()) { + return null; + } + return secret; + } + + private static String normalizeKeyVersion(String keyVersion) { + if (keyVersion == null) { + return DEFAULT_KEY_VERSION; + } + if (keyVersion.isEmpty()) { + throw new IllegalArgumentException("audit pseudonym key version must not be blank"); + } + if (keyVersion.length() > MAX_KEY_VERSION_LENGTH) { + throw new IllegalArgumentException("audit pseudonym key version is too long"); + } + for (int index = 0; index < keyVersion.length(); index++) { + char character = keyVersion.charAt(index); + boolean safe = Character.isLetterOrDigit(character) + || character == '.' + || character == '_' + || character == '-'; + if (!safe) { + throw new IllegalArgumentException("audit pseudonym key version contains unsafe characters"); + } + } + return keyVersion; + } + + private static String requireDomain(String domain) { + if (domain == null || domain.isBlank()) { + throw new IllegalArgumentException("audit pseudonym domain is required"); + } + return domain; + } +} diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java b/src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java index 42ea19b4..abe17667 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultConversionWorker.java @@ -28,9 +28,11 @@ * Default background worker that executes conversion jobs with retry backoff. * *

PDF uploads are served passthrough: the original bytes seeded into the - * artifact store at submit time become the artifact unchanged. Non-PDF sources - * still produce a placeholder preview PDF because real document conversion - * (docx, hwp, and similar formats) remains future work. + * artifact store at submit time become the artifact unchanged. Sources that + * require transformation are delegated to the configured + * {@link PdfArtifactGenerator}. The production application selects a qualified + * converter boundary; when no such adapter is available it fails closed rather + * than marking a metadata-only placeholder PDF as successful conversion. */ @Component public class DefaultConversionWorker implements ConversionWorker { @@ -54,6 +56,8 @@ public class DefaultConversionWorker implements ConversionWorker { * @param repository conversion job repository * @param stateStore conversion job lifecycle state store * @param conversionExecutor asynchronous conversion executor + * @param artifactStore generated and passthrough PDF artifact store + * @param pdfArtifactGenerator qualified transformed-format generator or fail-closed boundary * @param conversionProperties conversion configuration values */ @Autowired @@ -218,11 +222,7 @@ private void process(UUID jobId) { } private String failureReason(Throwable error) { - String message = error.getMessage(); - if (message == null || message.isBlank()) { - return "conversion failed: " + error.getClass().getSimpleName(); - } - return "conversion failed: " + message; + return "conversion failed: " + error.getClass().getSimpleName(); } private void onFailure(ConversionJob job, String reason) { diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java index ec1d22cc..3ee96b9e 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java @@ -102,6 +102,15 @@ public DefaultDocumentConversionService( ); } + /** + * Creates the conversion service with repository-backed lifecycle transitions + * and an isolated in-memory artifact store for legacy or test wiring. + * + * @param repository conversion job repository and optional lifecycle-state source + * @param validationService document validation service + * @param conversionWorker asynchronous conversion worker + * @param conversionProperties conversion configuration values + */ public DefaultDocumentConversionService( ConversionJobRepository repository, DocumentValidationService validationService, @@ -116,6 +125,16 @@ public DefaultDocumentConversionService( ); } + /** + * Creates the conversion service with repository-backed lifecycle transitions + * and the supplied artifact store. + * + * @param repository conversion job repository and optional lifecycle-state source + * @param validationService document validation service + * @param conversionWorker asynchronous conversion worker + * @param artifactStore artifact store used for PDF passthrough and deletion + * @param conversionProperties conversion configuration values + */ public DefaultDocumentConversionService( ConversionJobRepository repository, DocumentValidationService validationService, @@ -220,7 +239,7 @@ public void deleteJob(UUID jobId) { try { artifactStore.deletePdf(jobId); } catch (Exception ex) { - log.warn("Failed to delete artifact for job {}", jobId, ex); + log.warn("Artifact deletion failed failureType={}", ex.getClass().getSimpleName()); } repository.deleteById(jobId); } diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java index 95e67228..f00b8b8d 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java @@ -18,9 +18,15 @@ import com.clearfolio.viewer.config.ConversionProperties; import com.clearfolio.viewer.exception.UnsupportedDocumentFormatException; +import com.clearfolio.viewer.security.AuditKeySeparationGuard; +import com.clearfolio.viewer.security.AuditPseudonymizer; /** * Default document validator that enforces extension and size constraints. + * + *

Construction also validates the complete policy-override key contract, so + * direct standalone or modular use cannot bypass the same fail-closed security + * checks that Spring applies during application startup.

*/ @Service public class DefaultDocumentValidationService implements DocumentValidationService { @@ -32,16 +38,31 @@ public class DefaultDocumentValidationService implements DocumentValidationServi private final Set blockedExtensions; private final long maxUploadSizeBytes; private final String policyOverrideSecret; + private final AuditPseudonymizer auditPseudonymizer; /** * Creates the validation service from conversion configuration values. * + *

When policy override is enabled, construction rejects a weak signing + * key, a missing dedicated audit pseudonym key, or reuse of one key for both + * security purposes. This invariant applies even when callers construct the + * service outside the Spring container.

+ * * @param conversionProperties conversion configuration values + * @throws NullPointerException if {@code conversionProperties} is + * {@code null} + * @throws IllegalStateException if policy-override key material is weak, + * incomplete, or reused across security purposes */ public DefaultDocumentValidationService(ConversionProperties conversionProperties) { + AuditKeySeparationGuard.validate(conversionProperties); this.blockedExtensions = conversionProperties.getBlockedExtensions(); this.maxUploadSizeBytes = conversionProperties.getMaxUploadSizeBytes(); this.policyOverrideSecret = conversionProperties.getPolicyOverrideSecret(); + this.auditPseudonymizer = new AuditPseudonymizer( + conversionProperties.getAuditPseudonymSecret(), + conversionProperties.getAuditPseudonymKeyVersion() + ); } /** @@ -87,7 +108,7 @@ public void validateOrThrow(MultipartFile file, PolicyOverrideRequest overrideRe PolicyOverrideRequest.APPROVER_ID_HEADER + " is required when policy override is true." ); - if (policyOverrideSecret == null || policyOverrideSecret.isBlank()) { + if (policyOverrideSecret.isBlank()) { throw new IllegalStateException("Policy override secret is not configured."); } @@ -113,9 +134,9 @@ public void validateOrThrow(MultipartFile file, PolicyOverrideRequest overrideRe if (blockedExtension) { LOGGER.info( - "Blocked-format override accepted extension={} approverId={} tokenFingerprint={}", + "Blocked-format override accepted extension={} approverFingerprint={} tokenFingerprint={}", sanitizeForLog(extension), - sanitizeForLog(overrideApproverIdForAudit), + auditPseudonymizer.fingerprint(overrideApproverIdForAudit), tokenFingerprint(overrideTokenForAudit) ); } @@ -202,7 +223,6 @@ private String tokenFingerprint(String approvalToken) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hashed = digest.digest(approvalToken.getBytes(StandardCharsets.UTF_8)); - // Reused HexFormat for performance return HEX_FORMAT.formatHex(hashed, 0, FINGERPRINT_TRUNCATE_BYTES); } catch (NoSuchAlgorithmException ex) { throw new IllegalStateException("SHA-256 digest unavailable", ex); @@ -213,8 +233,6 @@ private String sanitizeForLog(final String value) { if (value == null) { return ""; } - // ⚡ Bolt: Single-pass string sanitization - // Avoids multiple allocations from chained replace() calls. StringBuilder sb = null; for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); diff --git a/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java b/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java index 76790b55..0d77ef62 100644 --- a/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java +++ b/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java @@ -101,12 +101,22 @@ private static String normalizeHeader(final String value) { return sb == null ? value : sb.toString(); } + /** + * Returns a log-safe diagnostic representation. + * + *

The approval token and approver identifier are always redacted, even + * when absent, so callers cannot accidentally disclose either secret value + * or infer whether an approver identifier was supplied from this string.

+ * + * @return diagnostic text containing only the sanitized override flag and + * fixed redaction markers for sensitive headers + */ @Override public String toString() { return "PolicyOverrideRequest{" + "policyOverride='" + normalizeHeader(policyOverride) + '\'' + ", approvalToken='[redacted]'" - + ", approverId='" + normalizeHeader(approverId) + '\'' + + ", approverId='[redacted]'" + '}'; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index df4f72aa..87a025b0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,14 @@ spring: application: name: clearfolio-viewer + config: + # Runtime key material is mounted as a Spring Boot config tree rather than + # carried in process environment variables. The environment variable below + # selects only the bootstrap directory. Mount files named + # `conversion.policy-override-secret`, + # `conversion.audit-pseudonym-secret`, and + # `conversion.audit-pseudonym-key-version` in this directory. + import: "optional:configtree:${CLEARFOLIO_SECRET_CONFIG_DIR:/run/secrets/clearfolio/}" codec: max-in-memory-size: ${conversion.max-upload-size-bytes} @@ -9,7 +17,6 @@ conversion: - hwp - hwpx worker-threads: 4 - policy-override-secret: "${CONVERSION_POLICY_OVERRIDE_SECRET:}" queue-capacity: 200 max-retry-attempts: 3 retry-initial-delay-ms: 500 diff --git a/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStoreCoverageTest.java b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStoreCoverageTest.java new file mode 100644 index 00000000..69fb0f6f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStoreCoverageTest.java @@ -0,0 +1,36 @@ +package com.clearfolio.viewer.artifact; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies that filesystem deletion failures remain visible to operators. + */ +class FileSystemArtifactStoreCoverageTest { + + @TempDir + Path temporaryDirectory; + + @Test + void deleteFailsClosedWhenTheArtifactPathCannotBeRemoved() throws Exception { + FileSystemArtifactStore store = new FileSystemArtifactStore(temporaryDirectory); + UUID docId = UUID.randomUUID(); + Path nonEmptyArtifactDirectory = temporaryDirectory.resolve(docId + ".pdf"); + Files.createDirectory(nonEmptyArtifactDirectory); + Files.writeString(nonEmptyArtifactDirectory.resolve("child"), "retained"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> store.deletePdf(docId) + ); + + assertEquals("failed to delete artifact for docId " + docId, exception.getMessage()); + } +} diff --git a/src/test/java/com/clearfolio/viewer/artifact/QualifiedConversionRequiredArtifactGeneratorTest.java b/src/test/java/com/clearfolio/viewer/artifact/QualifiedConversionRequiredArtifactGeneratorTest.java new file mode 100644 index 00000000..2e8eab68 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/artifact/QualifiedConversionRequiredArtifactGeneratorTest.java @@ -0,0 +1,61 @@ +package com.clearfolio.viewer.artifact; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.stereotype.Component; + +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Regression coverage ensuring unqualified transformed formats cannot be + * reported as successful placeholder conversions by the production bean graph. + */ +class QualifiedConversionRequiredArtifactGeneratorTest { + + @Test + void productionBeanGraphPrefersFailClosedGeneratorOverPlaceholderGenerator() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(PdfBoxArtifactGenerator.class); + context.registerBean(QualifiedConversionRequiredArtifactGenerator.class); + context.refresh(); + + PdfArtifactGenerator selected = context.getBean(PdfArtifactGenerator.class); + assertInstanceOf(QualifiedConversionRequiredArtifactGenerator.class, selected); + } + } + + @Test + void developmentPlaceholderIsNotAProductionScannedComponent() { + assertFalse(PdfBoxArtifactGenerator.class.isAnnotationPresent(Component.class)); + } + + @Test + void rejectsGenerationUntilQualifiedOfficeAdapterIsConfigured() { + QualifiedConversionRequiredArtifactGenerator generator = + new QualifiedConversionRequiredArtifactGenerator(); + ConversionJob job = new ConversionJob( + UUID.randomUUID(), + "tenant-a", + "subject-a", + "buyer-deck.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "content-hash", + 128L, + 3 + ); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> generator.generatePdf(job) + ); + + assertTrue(error.getMessage().contains("qualified document converter")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/config/ConversionPropertiesCoverageTest.java b/src/test/java/com/clearfolio/viewer/config/ConversionPropertiesCoverageTest.java new file mode 100644 index 00000000..bc3f7fd9 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/config/ConversionPropertiesCoverageTest.java @@ -0,0 +1,21 @@ +package com.clearfolio.viewer.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Covers the fail-safe normalization contract for optional policy secrets. + */ +class ConversionPropertiesCoverageTest { + + @Test + void nullPolicyOverrideSecretDisablesTheOverrideInsteadOfStoringNull() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("configured-secret"); + + properties.setPolicyOverrideSecret(null); + + assertEquals("", properties.getPolicyOverrideSecret()); + } +} diff --git a/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java b/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java index f2932094..2c3f0f2b 100644 --- a/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java +++ b/src/test/java/com/clearfolio/viewer/config/DependencyPolicyTest.java @@ -1,5 +1,6 @@ package com.clearfolio.viewer.config; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -12,6 +13,7 @@ import javax.xml.parsers.DocumentBuilderFactory; import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; @@ -39,6 +41,68 @@ void pomUsesBuyerReleaseFriendlyRuntimeDependencyPolicy() throws Exception { assertSpringStarterExcludes(dependencies, "org.springframework.boot:spring-boot-starter-validation"); } + @Test + void pomPinsPatchedNettyLineForReactiveHttpServing() throws Exception { + Document document = parsedPom(); + Element properties = (Element) document.getElementsByTagName("properties").item(0); + + assertEquals( + "4.1.136.Final", + directChildTextOf(properties, "netty.version"), + "Spring Boot's managed Netty line must be overridden to the reviewed 4.1.136.Final security release" + ); + } + + @Test + void mavenVerifyGeneratesWarningFreePublicApiJavadocs() throws Exception { + Document document = parsedPom(); + Element properties = (Element) document.getElementsByTagName("properties").item(0); + + assertEquals( + "3.12.0", + directChildTextOf(properties, "maven.javadoc.version"), + "the warning-free public Javadoc gate must use the reviewed Maven Javadoc Plugin release" + ); + + Element plugin = buildPlugin( + document, + "org.apache.maven.plugins", + "maven-javadoc-plugin" + ); + assertTrue( + plugin != null, + "mvn verify must include the Maven Javadoc Plugin instead of relying on undocumented local checks" + ); + assertEquals( + "${maven.javadoc.version}", + directChildTextOf(plugin, "version"), + "the Javadoc plugin version must be controlled by the authoritative version property" + ); + + Element configuration = directChildElementOf(plugin, "configuration"); + assertTrue(configuration != null, "the Javadoc plugin must declare fail-closed configuration"); + assertEquals("all", directChildTextOf(configuration, "doclint")); + assertEquals("true", directChildTextOf(configuration, "failOnError")); + assertEquals("true", directChildTextOf(configuration, "failOnWarnings")); + assertEquals("public", directChildTextOf(configuration, "show")); + + Element execution = executionById(plugin, "validate-public-api-documentation"); + assertTrue( + execution != null, + "the public Javadoc acceptance gate must have a stable execution identifier" + ); + assertEquals( + "verify", + directChildTextOf(execution, "phase"), + "public Javadoc validation must run in the authoritative Maven verify lifecycle" + ); + assertTrue( + directChildTextsOf(directChildElementOf(execution, "goals"), "goal") + .contains("javadoc"), + "the verify-bound execution must invoke the Javadoc goal" + ); + } + private static void assertSpringStarterExcludes( Map dependencies, String coordinate @@ -60,15 +124,7 @@ private static void assertSpringStarterExcludes( } private static Map declaredDependencies() throws Exception { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - - var document = factory.newDocumentBuilder().parse(Path.of("pom.xml").toFile()); - var dependencyNodes = document.getElementsByTagName("dependency"); + var dependencyNodes = parsedPom().getElementsByTagName("dependency"); Map dependencies = new TreeMap<>(); for (int i = 0; i < dependencyNodes.getLength(); i++) { Element dependency = (Element) dependencyNodes.item(i); @@ -82,6 +138,16 @@ private static Map declaredDependencies() throws return dependencies; } + private static Document parsedPom() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder().parse(Path.of("pom.xml").toFile()); + } + private static Set exclusionsOf(Element dependency) { var exclusionNodes = dependency.getElementsByTagName("exclusion"); Set exclusions = new HashSet<>(); @@ -92,13 +158,65 @@ private static Set exclusionsOf(Element dependency) { return exclusions; } - private static String directChildTextOf(Element dependency, String tagName) { - for (Node node = dependency.getFirstChild(); node != null; node = node.getNextSibling()) { + private static Element buildPlugin( + Document document, + String groupId, + String artifactId + ) { + var pluginNodes = document.getElementsByTagName("plugin"); + for (int index = 0; index < pluginNodes.getLength(); index++) { + Element plugin = (Element) pluginNodes.item(index); + if (groupId.equals(directChildTextOf(plugin, "groupId")) + && artifactId.equals(directChildTextOf(plugin, "artifactId"))) { + return plugin; + } + } + return null; + } + + private static Element executionById(Element plugin, String executionId) { + Element executions = directChildElementOf(plugin, "executions"); + if (executions == null) { + return null; + } + for (Node node = executions.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element execution + && "execution".equals(execution.getTagName()) + && executionId.equals(directChildTextOf(execution, "id"))) { + return execution; + } + } + return null; + } + + private static Set directChildTextsOf(Element parent, String tagName) { + Set values = new HashSet<>(); + if (parent == null) { + return values; + } + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element element && tagName.equals(element.getTagName())) { + values.add(element.getTextContent().strip()); + } + } + return values; + } + + private static Element directChildElementOf(Element parent, String tagName) { + if (parent == null) { + return null; + } + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { if (node instanceof Element element && tagName.equals(element.getTagName())) { - return element.getTextContent().strip(); + return element; } } - return ""; + return null; + } + + private static String directChildTextOf(Element dependency, String tagName) { + Element child = directChildElementOf(dependency, tagName); + return child == null ? "" : child.getTextContent().strip(); } private record DependencyDeclaration(String groupId, String artifactId, Set exclusions) { diff --git a/src/test/java/com/clearfolio/viewer/controller/ApiExceptionHandlerCoverageTest.java b/src/test/java/com/clearfolio/viewer/controller/ApiExceptionHandlerCoverageTest.java new file mode 100644 index 00000000..6c4ffd53 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/controller/ApiExceptionHandlerCoverageTest.java @@ -0,0 +1,58 @@ +package com.clearfolio.viewer.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.server.ServerWebExchange; + +import com.clearfolio.viewer.api.ApiErrorResponse; + +/** + * Verifies security-sensitive boundary behavior in {@link ApiExceptionHandler}. + */ +class ApiExceptionHandlerCoverageTest { + + @Test + void logSanitizationReplacesBlockedCharactersAndPreservesTheNextCodePoint() throws Exception { + ApiExceptionHandler handler = new ApiExceptionHandler(); + Method method = ApiExceptionHandler.class.getDeclaredMethod("sanitizeForLog", String.class); + method.setAccessible(true); + + assertEquals("\u202F", (String) method.invoke(handler, "\u202F")); + assertEquals("_", (String) method.invoke(handler, "\u202E")); + assertEquals("__", (String) method.invoke(handler, "\r\n")); + } + + @Test + void typeMismatchRedactsTheRejectedExternalValue() { + ApiExceptionHandler handler = new ApiExceptionHandler(); + MethodArgumentTypeMismatchException mismatch = new MethodArgumentTypeMismatchException( + "customer-secret-value", + Boolean.class, + "deadLettered", + null, + new IllegalArgumentException("bad boolean") + ); + ServerWebExchange exchange = mock(ServerWebExchange.class); + ServerHttpRequest request = mock(ServerHttpRequest.class); + when(exchange.getRequest()).thenReturn(request); + when(request.getHeaders()).thenReturn(new HttpHeaders()); + when(request.getId()).thenReturn("request-redaction"); + + ResponseEntity response = handler.handleTypeMismatch(mismatch, exchange); + + ApiErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals("deadLettered", body.details().get("parameter")); + assertEquals("[redacted]", body.details().get("value")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/controller/ApiExceptionHandlerFailurePrivacyTest.java b/src/test/java/com/clearfolio/viewer/controller/ApiExceptionHandlerFailurePrivacyTest.java new file mode 100644 index 00000000..c5dabccb --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/controller/ApiExceptionHandlerFailurePrivacyTest.java @@ -0,0 +1,96 @@ +package com.clearfolio.viewer.controller; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; + +/** + * Verifies that unexpected-error diagnostics retain a controlled failure class + * without logging provider-controlled exception messages. + */ +class ApiExceptionHandlerFailurePrivacyTest { + + @Test + void unexpectedErrorLogExcludesRawExceptionMessageButKeepsFailureClass() { + ApiExceptionHandler handler = new ApiExceptionHandler(); + ServerWebExchange exchange = mock(ServerWebExchange.class); + ServerHttpRequest request = mock(ServerHttpRequest.class); + HttpHeaders headers = new HttpHeaders(); + headers.add("X-Trace-Id", "trace-safe-42"); + when(exchange.getRequest()).thenReturn(request); + when(request.getHeaders()).thenReturn(headers); + when(request.getId()).thenReturn("request-safe-42"); + when(request.getURI()).thenReturn(URI.create("https://example.test/api/v1/test")); + + String providerMessage = "customer@example.com /tenant/private/report.pdf"; + CapturingAppender appender = attachAppender(); + try { + handler.handleUnexpected(new RuntimeException(providerMessage), exchange); + } finally { + appender.closeAndDetach(); + } + + String renderedLog = appender.renderedLog(); + assertFalse(renderedLog.contains(providerMessage)); + assertFalse(renderedLog.contains("customer@example.com")); + assertFalse(renderedLog.contains("/tenant/private/report.pdf")); + assertTrue(renderedLog.contains("RuntimeException")); + assertTrue(renderedLog.contains("trace-safe-42")); + } + + private static CapturingAppender attachAppender() { + Logger logger = (Logger) LogManager.getLogger(ApiExceptionHandler.class); + CapturingAppender appender = new CapturingAppender(logger); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.ERROR); + return appender; + } + + private static final class CapturingAppender extends AbstractAppender { + + private final Logger logger; + private final List renderedEvents = new ArrayList<>(); + + private CapturingAppender(Logger logger) { + super( + "api-exception-handler-failure-privacy-test", + null, + PatternLayout.newBuilder().withPattern("%m%throwable").build(), + false, + null + ); + this.logger = logger; + } + + @Override + public void append(LogEvent event) { + renderedEvents.add(getLayout().toSerializable(event).toString()); + } + + private String renderedLog() { + return String.join("\n", renderedEvents); + } + + private void closeAndDetach() { + logger.removeAppender(this); + stop(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/controller/ArtifactHttpRangeTest.java b/src/test/java/com/clearfolio/viewer/controller/ArtifactHttpRangeTest.java new file mode 100644 index 00000000..91f8640b --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/controller/ArtifactHttpRangeTest.java @@ -0,0 +1,111 @@ +package com.clearfolio.viewer.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Boundary regressions for Clearfolio's shared single-range parser. + */ +class ArtifactHttpRangeTest { + + @Test + void rejectsSuffixRangeForEmptyArtifact() { + var range = ArtifactHttpRange.resolveSingleRange("bytes=-1", 0); + + assertTrue(range.isPresent()); + assertTrue(range.get().rejected()); + } + + @Test + void rejectsExplicitPlusSignInFirstPosition() { + var range = ArtifactHttpRange.resolveSingleRange("bytes=+1-2", 10); + + assertTrue(range.isPresent()); + assertTrue(range.get().rejected()); + } + + @Test + void rejectsExplicitPlusSignInSuffixLength() { + var range = ArtifactHttpRange.resolveSingleRange("bytes=-+1", 10); + + assertTrue(range.isPresent()); + assertTrue(range.get().rejected()); + } + + @Test + void acceptsCaseInsensitiveBytesRangeUnit() { + var range = ArtifactHttpRange.resolveSingleRange("BYTES=1-2", 10); + + assertTrue(range.isPresent()); + assertFalse(range.get().rejected()); + assertEquals(1, range.get().startInclusive()); + assertEquals(2, range.get().endInclusive()); + } + + @Test + void rejectsWhitespaceInsideBytePositions() { + var range = ArtifactHttpRange.resolveSingleRange("bytes=1 - 2", 10); + + assertTrue(range.isPresent()); + assertTrue(range.get().rejected()); + } + + @Test + void rejectsRangeHeaderWithoutEqualsDelimiter() { + var range = ArtifactHttpRange.resolveSingleRange("bytes", 10); + + assertTrue(range.isPresent()); + assertTrue(range.get().rejected()); + } + + @Test + void rejectsUnsupportedRangeUnitAfterEqualsDelimiter() { + var range = ArtifactHttpRange.resolveSingleRange("items=1-2", 10); + + assertTrue(range.isPresent()); + assertTrue(range.get().rejected()); + } + + @Test + void rejectsStartPositionThatOverflowsLong() { + var range = ArtifactHttpRange.resolveSingleRange( + "bytes=999999999999999999999999999999-2", + 10 + ); + + assertTrue(range.isPresent()); + assertTrue(range.get().rejected()); + } + + @Test + void rejectsEndPositionThatOverflowsLong() { + var range = ArtifactHttpRange.resolveSingleRange( + "bytes=1-999999999999999999999999999999", + 10 + ); + + assertTrue(range.isPresent()); + assertTrue(range.get().rejected()); + } + + @Test + void rejectsSuffixLengthThatOverflowsLong() { + var range = ArtifactHttpRange.resolveSingleRange( + "bytes=-999999999999999999999999999999", + 10 + ); + + assertTrue(range.isPresent()); + assertTrue(range.get().rejected()); + } + + @Test + void rejectsOutcomeWhenBothFailureFlagsAreSet() { + var range = new ArtifactHttpRange.ResolvedRange(0, 0, true, true); + + assertTrue(range.rejected()); + } +} diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerCoverageTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerCoverageTest.java new file mode 100644 index 00000000..25d0fc19 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerCoverageTest.java @@ -0,0 +1,41 @@ +package com.clearfolio.viewer.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Exercises download-filename normalization paths using realistic hostile and + * unusual source metadata. + * + *

Artifact checksum generation and fail-closed digest-provider behavior are + * owned by {@code ArtifactLinkService}; the download controller now reuses the + * checksum from the verified signed-token claims rather than hashing the same + * bytes a second time.

+ */ +class ConversionControllerCoverageTest { + + @Test + void downloadFilenameHandlesBlankExtensionlessAndMeaninglessNames() { + assertEquals("document.pdf", ConversionController.pdfDownloadFilename(" ")); + assertEquals("report.pdf", ConversionController.pdfDownloadFilename("report")); + assertEquals("document.pdf", ConversionController.pdfDownloadFilename("___")); + assertEquals("document.pdf", ConversionController.pdfDownloadFilename("....")); + } + + @Test + void downloadFilenamePreservesEveryAllowedCharacterAfterAnUnsafeCharacter() { + assertEquals( + "safe.name.pdf", + ConversionController.pdfDownloadFilename("safe.name.txt") + ); + assertEquals( + "safe_name.pdf", + ConversionController.pdfDownloadFilename("safe_name.txt") + ); + assertEquals( + "bad_safe.name-test_value.pdf", + ConversionController.pdfDownloadFilename("bad safe.name-test_value.docx") + ); + } +} diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java index ff776274..fed3bf8b 100644 --- a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java @@ -3,31 +3,32 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; -import org.springframework.http.MediaType; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; -import java.nio.charset.StandardCharsets; -import java.util.HexFormat; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.BodyInserters; +import com.clearfolio.viewer.artifact.ArtifactLinkService; +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; import com.clearfolio.viewer.auth.TenantAccessService; import com.clearfolio.viewer.auth.TenantContext; import com.clearfolio.viewer.auth.TenantPermissions; -import com.clearfolio.viewer.artifact.ArtifactLinkService; -import com.clearfolio.viewer.artifact.InMemoryArtifactStore; import com.clearfolio.viewer.config.ConversionProperties; import com.clearfolio.viewer.repository.ConversionJobRepository; import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; @@ -46,11 +47,15 @@ properties = { "conversion.max-upload-size-bytes=1024", "spring.codec.max-in-memory-size=2048", - "conversion.policy-override-secret=test-secret" + "conversion.policy-override-secret=0123456789abcdef0123456789abcdef", + "conversion.audit-pseudonym-secret=fedcba9876543210fedcba9876543210" } ) class ConversionControllerMultipartLimitTest { + private static final String POLICY_OVERRIDE_KEY = + "0123456789abcdef0123456789abcdef"; + @SpringBootConfiguration @EnableAutoConfiguration @EnableConfigurationProperties(ConversionProperties.class) @@ -98,7 +103,7 @@ DocumentConversionService documentConversionService( repository, validationService, conversionWorker, - new com.clearfolio.viewer.artifact.InMemoryArtifactStore(), + new InMemoryArtifactStore(), conversionProperties ); } @@ -157,7 +162,7 @@ private String generateSignature(String approverId, String extension, String sec @Test void submitAcceptsBlockedExtensionWhenPolicyOverrideHeadersAreValid() { - String validSignature = generateSignature("approver-99", "hwp", "test-secret"); + String validSignature = generateSignature("approver-99", "hwp", POLICY_OVERRIDE_KEY); submit("contract.hwp", "hello".getBytes(), "true", validSignature, "approver-99") .expectStatus().isAccepted() .expectBody() diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java index ffa30536..33ff6113 100644 --- a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java @@ -12,7 +12,9 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.net.URI; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -28,6 +30,8 @@ import org.springframework.util.unit.DataSize; import org.springframework.web.reactive.function.BodyInserters; +import com.clearfolio.viewer.api.ArtifactLinkRequest; +import com.clearfolio.viewer.api.ArtifactLinkResponse; import com.clearfolio.viewer.auth.TenantAccessService; import com.clearfolio.viewer.auth.TenantContext; import com.clearfolio.viewer.auth.TenantPermissions; @@ -46,6 +50,7 @@ class ConversionControllerTest { private DocumentConversionService conversionService; private InMemoryArtifactStore artifactStore; + private ArtifactLinkService artifactLinkService; private ConversionController controller; @@ -53,16 +58,58 @@ class ConversionControllerTest { void setUp() { conversionService = mock(DocumentConversionService.class); artifactStore = new InMemoryArtifactStore(); + artifactLinkService = new ArtifactLinkService(artifactStore, "test-secret"); controller = new ConversionController( conversionService, new TenantAccessService(), - new ArtifactLinkService(artifactStore, "test-secret"), + artifactLinkService, artifactStore, DataSize.ofBytes(262_144L) ); webTestClient = WebTestClient.bindToController( controller - ).controllerAdvice(new ApiExceptionHandler()).build(); + ).controllerAdvice(new ApiExceptionHandler()) + .configureClient() + .filter((request, next) -> { + if (!request.url().getPath().endsWith("/download")) { + return next.exchange(request); + } + org.springframework.web.reactive.function.client.ClientRequest.Builder builder = + org.springframework.web.reactive.function.client.ClientRequest.from(request) + .header(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID) + .header(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID) + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.ARTIFACT_READ); + String[] pathSegments = request.url().getPath().split("/"); + if (pathSegments.length >= 2) { + try { + UUID jobId = UUID.fromString(pathSegments[pathSegments.length - 2]); + Optional job = conversionService.getJob(jobId); + if (job.isPresent() + && job.get().getStatus() == ConversionJobStatus.SUCCEEDED + && artifactStore.getPdf(jobId).isPresent()) { + ArtifactLinkResponse link = artifactLinkService.createLink( + job.get(), + directDownloadTenantContext(), + ArtifactLinkRequest.viewerPreview() + ); + String marker = ArtifactLinkService.ARTIFACT_TOKEN_PARAM + "="; + int markerIndex = link.artifactUrl().indexOf(marker); + String token = link.artifactUrl().substring(markerIndex + marker.length()); + builder.url(URI.create( + request.url().toString() + + "?" + + ArtifactLinkService.ARTIFACT_TOKEN_PARAM + + "=" + + token + )); + } + } catch (IllegalArgumentException ignored) { + // Let the controller exercise its own malformed or missing-resource response. + } + } + return next.exchange(builder.build()); + }) + .build(); } @Test @@ -793,7 +840,6 @@ void deleteJobDeletesJobAndArtifact() { // artifactStore deletion is verified in service tests } - @Test void deleteJobReturnsNotFoundForCrossTenantAccess() { UUID jobId = UUID.randomUUID(); @@ -856,6 +902,14 @@ private WebTestClient.ResponseSpec submit( .exchange(); } + private static TenantContext directDownloadTenantContext() { + return new TenantContext( + TenantContext.DEMO_TENANT_ID, + TenantContext.DEMO_SUBJECT_ID, + Set.of(TenantPermissions.ARTIFACT_READ) + ); + } + private static void addAllPermissions(HttpHeaders headers) { addAuth( headers, diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionDownloadAuthorizationTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionDownloadAuthorizationTest.java new file mode 100644 index 00000000..d468f70e --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/controller/ConversionDownloadAuthorizationTest.java @@ -0,0 +1,284 @@ +package com.clearfolio.viewer.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.unit.DataSize; + +import com.clearfolio.viewer.api.ArtifactLinkRequest; +import com.clearfolio.viewer.api.ArtifactLinkResponse; +import com.clearfolio.viewer.api.ArtifactReadEventResponse; +import com.clearfolio.viewer.artifact.ArtifactLinkService; +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.service.DocumentConversionService; + +/** + * Security regressions for tenant-scoped direct conversion artifact downloads. + */ +class ConversionDownloadAuthorizationTest { + + private DocumentConversionService conversionService; + private ArtifactStore artifactStore; + private ArtifactLinkService artifactLinkService; + private WebTestClient webTestClient; + + @BeforeEach + void setUp() { + conversionService = mock(DocumentConversionService.class); + artifactStore = mock(ArtifactStore.class); + artifactLinkService = new ArtifactLinkService(artifactStore, "test-secret"); + ConversionController controller = new ConversionController( + conversionService, + new TenantAccessService(), + artifactLinkService, + artifactStore, + DataSize.ofBytes(262_144L) + ); + webTestClient = WebTestClient.bindToController(controller) + .controllerAdvice(new ApiExceptionHandler()) + .build(); + } + + @Test + void downloadRejectsMissingTenantClaimsBeforeResourceLookup() { + webTestClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", UUID.randomUUID()) + .exchange() + .expectStatus().isUnauthorized() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("UNAUTHORIZED") + .jsonPath("$.message").isEqualTo("auth token required"); + + verifyNoInteractions(conversionService, artifactStore); + } + + @Test + void downloadRejectsJobReadWithoutArtifactReadBeforeResourceLookup() { + webTestClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", UUID.randomUUID()) + .headers(headers -> addAuth(headers, TenantPermissions.JOB_READ)) + .exchange() + .expectStatus().isForbidden() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("FORBIDDEN") + .jsonPath("$.message").isEqualTo("missing permission: " + TenantPermissions.ARTIFACT_READ); + + verifyNoInteractions(conversionService, artifactStore); + } + + @Test + void downloadConcealsCrossTenantJobBeforeArtifactLookup() { + UUID jobId = UUID.randomUUID(); + ConversionJob foreignJob = new ConversionJob( + jobId, + "tenant-b", + "subject-b", + "confidential.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "hash", + 12L, + 3 + ); + foreignJob.markSucceeded("/artifacts/confidential.pdf", "conversion completed"); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(foreignJob)); + + webTestClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(headers -> addAuth(headers, TenantPermissions.ARTIFACT_READ)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND") + .jsonPath("$.message").isEqualTo("job not found"); + + verify(artifactStore, never()).getPdf(jobId); + } + + @Test + void downloadRejectsOwnedSucceededArtifactWithoutSignedToken() { + UUID jobId = UUID.randomUUID(); + prepareOwnedSucceededJob(jobId); + + webTestClient.get() + .uri("/api/v1/convert/jobs/{jobId}/download", jobId) + .headers(headers -> addAuth(headers, TenantPermissions.ARTIFACT_READ)) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + void downloadReturnsOwnedSucceededArtifactWithSignedToken() { + UUID jobId = UUID.randomUUID(); + byte[] pdfBytes = pdfBytes(); + ConversionJob ownedJob = prepareOwnedSucceededJob(jobId, pdfBytes); + ArtifactLinkResponse link = createLink(ownedJob); + + webTestClient.get() + .uri(uriBuilder -> uriBuilder + .path("/api/v1/convert/jobs/{jobId}/download") + .queryParam(ArtifactLinkService.ARTIFACT_TOKEN_PARAM, tokenFrom(link)) + .build(jobId)) + .headers(headers -> addAuth(headers, TenantPermissions.ARTIFACT_READ)) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_PDF) + .expectHeader().valueEquals(HttpHeaders.ACCEPT_RANGES, "bytes") + .expectHeader().valueEquals(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report.pdf\"") + .expectBody(byte[].class).isEqualTo(pdfBytes); + + ArtifactReadEventResponse event = onlyReadEvent(jobId); + assertEquals(200, event.statusCode()); + assertEquals(link.tokenId(), event.tokenId()); + } + + @Test + void downloadSupportsSingleRangeAndRecordsAuditEvidence() { + UUID jobId = UUID.randomUUID(); + byte[] pdfBytes = pdfBytes(); + ConversionJob ownedJob = prepareOwnedSucceededJob(jobId, pdfBytes); + ArtifactLinkResponse link = createLink(ownedJob); + + webTestClient.get() + .uri(uriBuilder -> uriBuilder + .path("/api/v1/convert/jobs/{jobId}/download") + .queryParam(ArtifactLinkService.ARTIFACT_TOKEN_PARAM, tokenFrom(link)) + .build(jobId)) + .headers(headers -> addAuth(headers, TenantPermissions.ARTIFACT_READ)) + .header(HttpHeaders.RANGE, "bytes=0-3") + .header("X-Request-Id", "direct-download-range") + .exchange() + .expectStatus().isEqualTo(206) + .expectHeader().valueEquals(HttpHeaders.CONTENT_RANGE, "bytes 0-3/9") + .expectHeader().valueEquals(HttpHeaders.ACCEPT_RANGES, "bytes") + .expectHeader().valueEquals(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report.pdf\"") + .expectBody(byte[].class) + .isEqualTo("%PDF".getBytes(StandardCharsets.US_ASCII)); + + ArtifactReadEventResponse event = onlyReadEvent(jobId); + assertEquals("bytes=0-3", event.rangeRequested()); + assertEquals(206, event.statusCode()); + assertEquals("direct-download-range", event.traceId()); + } + + @Test + void downloadRejectsUnsatisfiableRangeAndRecordsAuditEvidence() { + UUID jobId = UUID.randomUUID(); + byte[] pdfBytes = pdfBytes(); + ConversionJob ownedJob = prepareOwnedSucceededJob(jobId, pdfBytes); + ArtifactLinkResponse link = createLink(ownedJob); + + webTestClient.get() + .uri(uriBuilder -> uriBuilder + .path("/api/v1/convert/jobs/{jobId}/download") + .queryParam(ArtifactLinkService.ARTIFACT_TOKEN_PARAM, tokenFrom(link)) + .build(jobId)) + .headers(headers -> addAuth(headers, TenantPermissions.ARTIFACT_READ)) + .header(HttpHeaders.RANGE, "bytes=99-100") + .exchange() + .expectStatus().isEqualTo(416) + .expectHeader().valueEquals(HttpHeaders.CONTENT_RANGE, "bytes */9") + .expectHeader().valueEquals(HttpHeaders.ACCEPT_RANGES, "bytes") + .expectHeader().valueEquals(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report.pdf\""); + + ArtifactReadEventResponse event = onlyReadEvent(jobId); + assertEquals("bytes=99-100", event.rangeRequested()); + assertEquals(416, event.statusCode()); + } + + @Test + void downloadRejectsRevokedSignedToken() { + UUID jobId = UUID.randomUUID(); + ConversionJob ownedJob = prepareOwnedSucceededJob(jobId); + ArtifactLinkResponse link = createLink(ownedJob); + artifactLinkService.revokeLink(link.tokenId(), tenantContext(), null); + + webTestClient.get() + .uri(uriBuilder -> uriBuilder + .path("/api/v1/convert/jobs/{jobId}/download") + .queryParam(ArtifactLinkService.ARTIFACT_TOKEN_PARAM, tokenFrom(link)) + .build(jobId)) + .headers(headers -> addAuth(headers, TenantPermissions.ARTIFACT_READ)) + .exchange() + .expectStatus().isForbidden(); + } + + private ConversionJob prepareOwnedSucceededJob(UUID jobId) { + return prepareOwnedSucceededJob(jobId, pdfBytes()); + } + + private ConversionJob prepareOwnedSucceededJob(UUID jobId, byte[] pdfBytes) { + ConversionJob ownedJob = new ConversionJob( + jobId, + TenantContext.DEMO_TENANT_ID, + TenantContext.DEMO_SUBJECT_ID, + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "hash", + pdfBytes.length, + 3 + ); + ownedJob.markSucceeded("/artifacts/report.pdf", "conversion completed"); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(ownedJob)); + when(artifactStore.getPdf(jobId)).thenReturn(Optional.of(pdfBytes)); + return ownedJob; + } + + private ArtifactLinkResponse createLink(ConversionJob job) { + return artifactLinkService.createLink(job, tenantContext(), ArtifactLinkRequest.viewerPreview()); + } + + private ArtifactReadEventResponse onlyReadEvent(UUID jobId) { + var events = artifactLinkService.readEvents(jobId, tenantContext()); + assertEquals(1, events.size()); + ArtifactReadEventResponse event = events.get(0); + assertNotNull(event.readAt()); + return event; + } + + private static String tokenFrom(ArtifactLinkResponse response) { + String marker = ArtifactLinkService.ARTIFACT_TOKEN_PARAM + "="; + int markerIndex = response.artifactUrl().indexOf(marker); + return URLDecoder.decode( + response.artifactUrl().substring(markerIndex + marker.length()), + StandardCharsets.UTF_8 + ); + } + + private static TenantContext tenantContext() { + return new TenantContext( + TenantContext.DEMO_TENANT_ID, + TenantContext.DEMO_SUBJECT_ID, + Set.of(TenantPermissions.ARTIFACT_READ) + ); + } + + private static byte[] pdfBytes() { + return "%PDF-1.7\n".getBytes(StandardCharsets.US_ASCII); + } + + private static void addAuth(HttpHeaders headers, String permissions) { + headers.add(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID); + headers.add(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID); + headers.add(TenantContext.PERMISSIONS_HEADER, permissions); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapterClaimTest.java b/src/test/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapterClaimTest.java new file mode 100644 index 00000000..ac4805ea --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapterClaimTest.java @@ -0,0 +1,31 @@ +package com.clearfolio.viewer.conversion; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +/** + * Protects the public documentation boundary of the deterministic Office fixture adapter. + */ +class DeterministicFixtureOfficeConversionAdapterClaimTest { + + /** + * Prevents a byte-replay fixture from being described as evidence of Office rendering fidelity. + * + * @throws IOException when the production source cannot be read by the contract test + */ + @Test + void deterministicFixtureIsDocumentedAsContractOracleNotFidelityImplementation() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/com/clearfolio/viewer/conversion/DeterministicFixtureOfficeConversionAdapter.java" + )); + + assertThat(source) + .contains("contract oracle") + .doesNotContain("contract and fidelity test implementation"); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java new file mode 100644 index 00000000..eb69f0b4 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActionBoundaryTest.java @@ -0,0 +1,240 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.UUID; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.Test; + +/** + * Edge-case regressions for the fail-closed PDF action publication boundary. + */ +class OfficeConversionActionBoundaryTest { + + @Test + void preservesNamedDocumentDestination() throws IOException { + assertDoesNotThrow(() -> convert(pdfWithOpenAction(COSName.getPDFName("section-one")))); + } + + @Test + void preservesStringDocumentDestination() throws IOException { + assertDoesNotThrow(() -> convert(pdfWithOpenAction(new COSString("section-one")))); + } + + @Test + void preservesBenignChainedGoToDictionary() throws IOException { + COSDictionary primary = goToAction(); + primary.setItem(COSName.getPDFName("Next"), goToAction()); + + assertDoesNotThrow(() -> convert(pdfWithAnnotationAction(primary))); + } + + @Test + void preservesBenignChainedGoToArray() throws IOException { + COSArray next = new COSArray(); + next.add(goToAction()); + next.add(goToAction()); + COSDictionary primary = goToAction(); + primary.setItem(COSName.getPDFName("Next"), next); + + assertDoesNotThrow(() -> convert(pdfWithAnnotationAction(primary))); + } + + @Test + void rejectsActionWithoutType() throws IOException { + assertPolicyDenied(pdfWithAnnotationAction(new COSDictionary())); + } + + @Test + void rejectsGoToWithoutDestination() throws IOException { + assertPolicyDenied(pdfWithAnnotationAction(action("GoTo"))); + } + + @Test + void rejectsUriWithoutStringTarget() throws IOException { + COSDictionary uri = action("URI"); + uri.setItem(COSName.getPDFName("URI"), COSName.getPDFName("not-a-string")); + + assertPolicyDenied(pdfWithAnnotationAction(uri)); + } + + @Test + void rejectsUriWhenConfiguredAsAutomaticPageAction() throws IOException { + assertPolicyDenied(pdfWithPageAdditionalAction(uriAction())); + } + + @Test + void rejectsMalformedAdditionalActionContainer() throws IOException { + assertPolicyDenied(pdfWithPageAdditionalActions(new COSString("not-an-action-dictionary"))); + } + + @Test + void rejectsMalformedAnnotationContainer() throws IOException { + assertPolicyDenied(pdfWithMalformedAnnotations(new COSString("not-an-annotation-array"))); + } + + @Test + void rejectsMalformedAnnotationEntry() throws IOException { + assertPolicyDenied(pdfWithAnnotationEntry(new COSString("not-an-annotation-dictionary"))); + } + + @Test + void rejectsMalformedNextActionValue() throws IOException { + COSDictionary primary = goToAction(); + primary.setItem(COSName.getPDFName("Next"), new COSString("not-an-action")); + + assertPolicyDenied(pdfWithAnnotationAction(primary)); + } + + @Test + void rejectsChainedArrayContainingProhibitedAction() throws IOException { + COSArray next = new COSArray(); + next.add(goToAction()); + next.add(action("SubmitForm")); + COSDictionary primary = goToAction(); + primary.setItem(COSName.getPDFName("Next"), next); + + assertPolicyDenied(pdfWithAnnotationAction(primary)); + } + + @Test + void rejectsActionChainBeyondPublicationDepthLimit() throws IOException { + COSDictionary primary = goToAction(); + COSDictionary cursor = primary; + for (int index = 1; index < 33; index++) { + COSDictionary next = goToAction(); + cursor.setItem(COSName.getPDFName("Next"), next); + cursor = next; + } + + assertPolicyDenied(pdfWithAnnotationAction(primary)); + } + + private static void convert(byte[] pdf) { + adapterReturning(pdf).convert(request()); + } + + private static void assertPolicyDenied(byte[] pdf) { + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> convert(pdf) + ); + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + private static OfficeConversionAdapter adapterReturning(byte[] pdf) { + return input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + } + + private static OfficeConversionRequest request() { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"), + 9L, + "docx", + "policy-v1", + "trace-action-boundary", + OfficeConversionTestSource.zipPackage("fixture-source"), + 1_000_000L, + 10 + ); + } + + private static byte[] pdfWithOpenAction(COSBase openAction) throws IOException { + try (PDDocument document = onePageDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("OpenAction"), openAction); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithAnnotationAction(COSDictionary action) throws IOException { + COSDictionary annotation = new COSDictionary(); + annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot")); + annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link")); + annotation.setItem(COSName.getPDFName("A"), action); + return pdfWithAnnotationEntry(annotation); + } + + private static byte[] pdfWithAnnotationEntry(COSBase annotationEntry) throws IOException { + try (PDDocument document = onePageDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + COSArray annotations = new COSArray(); + annotations.add(annotationEntry); + document.getPage(0).getCOSObject() + .setItem(COSName.getPDFName("Annots"), annotations); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithPageAdditionalAction(COSDictionary action) throws IOException { + COSDictionary additionalActions = new COSDictionary(); + additionalActions.setItem(COSName.getPDFName("O"), action); + return pdfWithPageAdditionalActions(additionalActions); + } + + private static byte[] pdfWithPageAdditionalActions(COSBase additionalActions) throws IOException { + try (PDDocument document = onePageDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.getPage(0).getCOSObject() + .setItem(COSName.getPDFName("AA"), additionalActions); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithMalformedAnnotations(COSBase annotations) throws IOException { + try (PDDocument document = onePageDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.getPage(0).getCOSObject() + .setItem(COSName.getPDFName("Annots"), annotations); + document.save(output); + return output.toByteArray(); + } + } + + private static PDDocument onePageDocument() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage()); + return document; + } + + private static COSDictionary goToAction() { + COSDictionary action = action("GoTo"); + action.setItem(COSName.getPDFName("D"), COSName.getPDFName("section-one")); + return action; + } + + private static COSDictionary uriAction() { + COSDictionary action = action("URI"); + action.setString(COSName.getPDFName("URI"), "https://example.invalid/clearfolio"); + return action; + } + + private static COSDictionary action(String actionType) { + COSDictionary action = new COSDictionary(); + action.setItem(COSName.getPDFName("S"), COSName.getPDFName(actionType)); + return action; + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java new file mode 100644 index 00000000..22f7aedf --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionActiveContentPolicyTest.java @@ -0,0 +1,411 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.UUID; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.Test; + +/** + * Active-content policy regressions for converter-produced PDF candidates. + */ +class OfficeConversionActiveContentPolicyTest { + + @Test + void adapterRejectsJavaScriptDocumentOpenAction() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithJavaScriptOpenAction()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsDocumentJavaScriptNameTreeWithoutOpenAction() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithJavaScriptNameTree()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsEmbeddedFileNameTreeWithoutExecutableAction() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithEmbeddedFilesNameTree()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsMalformedDocumentNameContainer() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithMalformedNameContainer()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsCatalogAssociatedFiles() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithCatalogAssociatedFiles()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsCatalogAdditionalActions() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithCatalogAdditionalActions()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsCatalogAutomaticGoToAdditionalAction() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithCatalogAutomaticGoToAdditionalAction()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterAcceptsEmptyCatalogAdditionalActionDictionary() throws IOException { + byte[] pdf = pdfWithEmptyCatalogAdditionalActions(); + OfficeConversionAdapter adapter = adapterReturning(pdf); + + assertDoesNotThrow(() -> adapter.convert(request())); + } + + @Test + void adapterRejectsPageAdditionalActions() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithPageAdditionalActions()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsPageAutomaticGoToAdditionalAction() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithPageAutomaticGoToAdditionalAction()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsAnnotationJavaScriptAction() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationAction(javascriptAction())); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsAnnotationLaunchAction() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationAction(launchAction())); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsAnnotationAdditionalActions() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationAdditionalActions()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterRejectsAnnotationAutomaticGoToAdditionalAction() throws IOException { + OfficeConversionException failure = assertPolicyDenied(pdfWithAnnotationAutomaticGoToAdditionalAction()); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + @Test + void adapterPreservesBenignAnnotationUriAction() throws IOException { + byte[] pdf = pdfWithAnnotationAction(uriAction()); + OfficeConversionAdapter adapter = adapterReturning(pdf); + + assertDoesNotThrow(() -> adapter.convert(request())); + } + + @Test + void adapterAcceptsBenignEmptyDocumentNameDictionary() throws IOException { + byte[] pdf = pdfWithEmptyNameDictionary(); + OfficeConversionAdapter adapter = adapterReturning(pdf); + + assertDoesNotThrow(() -> adapter.convert(request())); + } + + private static OfficeConversionException assertPolicyDenied(byte[] pdf) { + OfficeConversionAdapter adapter = adapterReturning(pdf); + return assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request()) + ); + } + + private static OfficeConversionAdapter adapterReturning(byte[] pdf) { + return input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + } + + private static OfficeConversionRequest request() { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"), + 9L, + "docx", + "policy-v1", + "trace-active-content", + OfficeConversionTestSource.zipPackage("fixture-source"), + 1_000_000L, + 10 + ); + } + + private static byte[] pdfWithJavaScriptOpenAction() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + COSDictionary javascriptAction = javascriptAction(); + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("OpenAction"), javascriptAction); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithJavaScriptNameTree() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + + COSArray entries = new COSArray(); + entries.add(new COSString("clearfolio-startup")); + entries.add(javascriptAction()); + + COSDictionary javaScriptTree = new COSDictionary(); + javaScriptTree.setItem(COSName.getPDFName("Names"), entries); + + COSDictionary names = new COSDictionary(); + names.setItem(COSName.getPDFName("JavaScript"), javaScriptTree); + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("Names"), names); + + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithEmbeddedFilesNameTree() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + + COSDictionary embeddedFilesTree = new COSDictionary(); + embeddedFilesTree.setItem(COSName.getPDFName("Names"), new COSArray()); + + COSDictionary names = new COSDictionary(); + names.setItem(COSName.getPDFName("EmbeddedFiles"), embeddedFilesTree); + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("Names"), names); + + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithMalformedNameContainer() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("Names"), new COSString("not-a-name-dictionary")); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithCatalogAssociatedFiles() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + + COSDictionary fileSpecification = new COSDictionary(); + fileSpecification.setItem(COSName.TYPE, COSName.getPDFName("Filespec")); + fileSpecification.setString(COSName.getPDFName("F"), "attachment.txt"); + fileSpecification.setItem( + COSName.getPDFName("AFRelationship"), + COSName.getPDFName("Data") + ); + COSArray associatedFiles = new COSArray(); + associatedFiles.add(fileSpecification); + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("AF"), associatedFiles); + + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithCatalogAdditionalActions() throws IOException { + return pdfWithCatalogAdditionalAction(javascriptAction()); + } + + private static byte[] pdfWithCatalogAutomaticGoToAdditionalAction() throws IOException { + return pdfWithCatalogAdditionalAction(goToAction()); + } + + private static byte[] pdfWithEmptyCatalogAdditionalActions() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("AA"), new COSDictionary()); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithCatalogAdditionalAction(COSDictionary action) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + COSDictionary additionalActions = new COSDictionary(); + additionalActions.setItem(COSName.getPDFName("WC"), action); + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("AA"), additionalActions); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithPageAdditionalActions() throws IOException { + return pdfWithPageAdditionalAction(javascriptAction()); + } + + private static byte[] pdfWithPageAutomaticGoToAdditionalAction() throws IOException { + return pdfWithPageAdditionalAction(goToAction()); + } + + private static byte[] pdfWithPageAdditionalAction(COSDictionary action) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + PDPage page = new PDPage(); + COSDictionary additionalActions = new COSDictionary(); + additionalActions.setItem(COSName.getPDFName("O"), action); + page.getCOSObject().setItem(COSName.getPDFName("AA"), additionalActions); + document.addPage(page); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithAnnotationAction(COSDictionary action) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + PDPage page = new PDPage(); + + COSDictionary annotation = linkAnnotation(); + annotation.setItem(COSName.getPDFName("A"), action); + + COSArray annotations = new COSArray(); + annotations.add(annotation); + page.getCOSObject().setItem(COSName.getPDFName("Annots"), annotations); + document.addPage(page); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithAnnotationAdditionalActions() throws IOException { + return pdfWithAnnotationAdditionalAction(javascriptAction()); + } + + private static byte[] pdfWithAnnotationAutomaticGoToAdditionalAction() throws IOException { + return pdfWithAnnotationAdditionalAction(goToAction()); + } + + private static byte[] pdfWithAnnotationAdditionalAction(COSDictionary action) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + PDPage page = new PDPage(); + + COSDictionary additionalActions = new COSDictionary(); + additionalActions.setItem(COSName.getPDFName("E"), action); + COSDictionary annotation = linkAnnotation(); + annotation.setItem(COSName.getPDFName("AA"), additionalActions); + + COSArray annotations = new COSArray(); + annotations.add(annotation); + page.getCOSObject().setItem(COSName.getPDFName("Annots"), annotations); + document.addPage(page); + document.save(output); + return output.toByteArray(); + } + } + + private static COSDictionary linkAnnotation() { + COSDictionary annotation = new COSDictionary(); + annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot")); + annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link")); + return annotation; + } + + private static COSDictionary uriAction() { + COSDictionary uriAction = new COSDictionary(); + uriAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("URI")); + uriAction.setString(COSName.getPDFName("URI"), "https://example.invalid/clearfolio"); + return uriAction; + } + + private static COSDictionary goToAction() { + COSDictionary goToAction = new COSDictionary(); + goToAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("GoTo")); + goToAction.setString(COSName.getPDFName("D"), "destination-one"); + return goToAction; + } + + private static COSDictionary launchAction() { + COSDictionary launchAction = new COSDictionary(); + launchAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("Launch")); + launchAction.setString(COSName.getPDFName("F"), "clearfolio-helper.exe"); + return launchAction; + } + + private static byte[] pdfWithEmptyNameDictionary() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("Names"), new COSDictionary()); + document.save(output); + return output.toByteArray(); + } + } + + private static COSDictionary javascriptAction() { + COSDictionary javascriptAction = new COSDictionary(); + javascriptAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("JavaScript")); + javascriptAction.setString(COSName.getPDFName("JS"), "app.alert('clearfolio')"); + return javascriptAction; + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java new file mode 100644 index 00000000..60d52d58 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterContractTest.java @@ -0,0 +1,201 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** + * Contract tests for the provider-neutral Office conversion boundary. + */ +class OfficeConversionAdapterContractTest { + + @Test + void requestDefensivelyCopiesSourceBytesAndBindsImmutableIdentity() { + byte[] source = "office-source".getBytes(StandardCharsets.UTF_8); + UUID jobId = UUID.randomUUID(); + + OfficeConversionRequest request = new OfficeConversionRequest( + "tenant-a", + jobId, + 7L, + "docx", + "policy-v3", + "trace-123", + source + ); + + String expectedDigest = request.sourceSha256(); + source[0] = 'X'; + byte[] exposed = request.sourceBytes(); + exposed[1] = 'Y'; + + assertEquals("tenant-a", request.tenantId()); + assertEquals(jobId, request.jobId()); + assertEquals(7L, request.jobGeneration()); + assertEquals("docx", request.sourceFormat()); + assertEquals("policy-v3", request.policyVersion()); + assertEquals("trace-123", request.correlationId()); + assertArrayEquals("office-source".getBytes(StandardCharsets.UTF_8), request.sourceBytes()); + assertEquals(expectedDigest, request.sourceSha256()); + assertEquals(expectedDigest, request.binding().sourceSha256()); + assertEquals(7L, request.binding().jobGeneration()); + assertEquals(64, expectedDigest.length()); + } + + @Test + void requestCanonicalizesTextIdentityBeforeCrossBoundaryUse() { + OfficeConversionRequest request = new OfficeConversionRequest( + " tenant-a ", + UUID.randomUUID(), + 1L, + " DoCx ", + " policy-v1 ", + " trace-1 ", + "source".getBytes(StandardCharsets.UTF_8) + ); + + assertEquals("tenant-a", request.tenantId()); + assertEquals("docx", request.sourceFormat()); + assertEquals("policy-v1", request.policyVersion()); + assertEquals("trace-1", request.correlationId()); + } + + @Test + void requestRejectsMissingIdentityAndEmptySource() { + byte[] source = "x".getBytes(StandardCharsets.UTF_8); + UUID jobId = UUID.randomUUID(); + + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest( + null, jobId, 0L, "docx", "policy", "trace", source)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest( + " ", jobId, 0L, "docx", "policy", "trace", source)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest( + "tenant", null, 0L, "docx", "policy", "trace", source)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest( + "tenant", jobId, -1L, "docx", "policy", "trace", source)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest( + "tenant", jobId, 0L, " ", "policy", "trace", source)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest( + "tenant", jobId, 0L, "docx", " ", "trace", source)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest( + "tenant", jobId, 0L, "docx", "policy", " ", source)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest( + "tenant", jobId, 0L, "docx", "policy", "trace", null)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest( + "tenant", jobId, 0L, "docx", "policy", "trace", new byte[0])); + } + + @Test + void resultDefensivelyCopiesVerifiedPdfAndCarriesProvenance() { + byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII); + OfficeConversionResult result = new OfficeConversionResult( + " fixture-adapter ", + " 1.0.0 ", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + pdf + ); + + String outputDigest = result.outputSha256(); + pdf[0] = 'X'; + byte[] exposed = result.pdfBytes(); + exposed[1] = 'Y'; + + assertEquals("fixture-adapter", result.adapterId()); + assertEquals("1.0.0", result.adapterVersion()); + assertEquals(64, result.sourceSha256().length()); + assertEquals(null, result.requestBinding()); + assertArrayEquals("%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII), result.pdfBytes()); + assertEquals(outputDigest, result.outputSha256()); + assertEquals(64, outputDigest.length()); + } + + @Test + void resultRejectsInvalidProvenanceAndNonPdfOutput() { + byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII); + byte[] notPdf = "not-pdf".getBytes(StandardCharsets.US_ASCII); + String digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + String otherDigest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + OfficeConversionRequestBinding otherBinding = new OfficeConversionRequestBinding( + "tenant", UUID.randomUUID(), 0L, "docx", "policy", "trace", otherDigest); + + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionResult(null, "1", digest, pdf)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionResult(" ", "1", digest, pdf)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionResult("adapter", " ", digest, pdf)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionResult("adapter", "1", null, pdf)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionResult("adapter", "1", "bad", pdf)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionResult("adapter", "1", digest.toUpperCase(), pdf)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionResult("adapter", "1", digest, otherBinding, pdf)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionResult("adapter", "1", digest, null)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionResult("adapter", "1", digest, new byte[0])); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionResult("adapter", "1", digest, notPdf)); + } + + @Test + void failureCodesExposeExplicitRetryPolicy() { + assertFalse(OfficeConversionFailureCode.UNSUPPORTED_FORMAT.isRetryable()); + assertFalse(OfficeConversionFailureCode.POLICY_DENIED.isRetryable()); + assertFalse(OfficeConversionFailureCode.PASSWORD_PROTECTED.isRetryable()); + assertFalse(OfficeConversionFailureCode.MALFORMED_INPUT.isRetryable()); + assertFalse(OfficeConversionFailureCode.CANCELLED.isRetryable()); + assertFalse(OfficeConversionFailureCode.INVALID_OUTPUT.isRetryable()); + assertFalse(OfficeConversionFailureCode.OUTPUT_LIMIT_EXCEEDED.isRetryable()); + assertTrue(OfficeConversionFailureCode.ENGINE_UNAVAILABLE.isRetryable()); + assertTrue(OfficeConversionFailureCode.TIMEOUT.isRetryable()); + assertTrue(OfficeConversionFailureCode.ENGINE_CRASH.isRetryable()); + } + + @Test + void adapterFailuresCarryStableClassAndRetryability() { + OfficeConversionException failure = new OfficeConversionException( + OfficeConversionFailureCode.TIMEOUT, + " conversion deadline exceeded " + ); + + assertEquals(OfficeConversionFailureCode.TIMEOUT, failure.failureCode()); + assertTrue(failure.isRetryable()); + assertEquals("conversion deadline exceeded", failure.getMessage()); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionException(null, "failure")); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionException(OfficeConversionFailureCode.ENGINE_CRASH, " ")); + } + + @Test + void adapterContractCanReturnDeterministicFixtureEvidence() { + byte[] source = OfficeConversionTestSource.zipPackage("fixture-docx"); + OfficeConversionRequest request = new OfficeConversionRequest( + "tenant-a", UUID.randomUUID(), 1L, "docx", "policy-v1", "trace-1", source); + byte[] pdf = OfficeConversionTestPdf.onePage(); + + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + + OfficeConversionResult result = adapter.convert(request); + + assertEquals(request.sourceSha256(), result.sourceSha256()); + assertEquals(request.binding(), result.requestBinding()); + assertArrayEquals(pdf, result.pdfBytes()); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java new file mode 100644 index 00000000..f07b1cfc --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterIdentityBindingTest.java @@ -0,0 +1,105 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** + * Integrity regressions for binding Office output to the qualified adapter identity. + */ +class OfficeConversionAdapterIdentityBindingTest { + + @Test + void requestBindingIncludesExpectedAdapterIdentity() { + OfficeConversionRequest baseline = request("sandboxed-office-sidecar", "24.8.5"); + OfficeConversionRequest otherAdapter = request("remote-office-service", "24.8.5"); + OfficeConversionRequest otherVersion = request("sandboxed-office-sidecar", "24.8.6"); + + assertEquals("sandboxed-office-sidecar", baseline.expectedAdapterId()); + assertEquals("24.8.5", baseline.expectedAdapterVersion()); + assertEquals("sandboxed-office-sidecar", baseline.binding().expectedAdapterId()); + assertEquals("24.8.5", baseline.binding().expectedAdapterVersion()); + assertNotEquals(baseline.binding(), otherAdapter.binding()); + assertNotEquals(baseline.binding(), otherVersion.binding()); + } + + @Test + void publicAuthorityConstructorsRequireExplicitQualifiedAdapterIdentity() { + boolean requestConstructorsAreStrict = Arrays.stream(OfficeConversionRequest.class.getConstructors()) + .allMatch(constructor -> constructor.getParameterCount() >= 9); + boolean bindingConstructorsAreStrict = Arrays.stream(OfficeConversionRequestBinding.class.getConstructors()) + .allMatch(constructor -> constructor.getParameterCount() >= 9); + + assertTrue( + requestConstructorsAreStrict, + "public conversion requests must not silently bind a fixture/default adapter identity" + ); + assertTrue( + bindingConstructorsAreStrict, + "public conversion bindings must not silently bind a fixture/default adapter identity" + ); + } + + @Test + void adapterRejectsResultFromUnexpectedAdapterIdentity() { + OfficeConversionRequest request = request("sandboxed-office-sidecar", "24.8.5"); + byte[] pdf = OfficeConversionTestPdf.onePage(); + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "remote-office-service", + "24.8.5", + input.sourceSha256(), + input.binding(), + pdf + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request) + ); + + assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode()); + assertEquals("conversion result adapter identity mismatch", failure.getMessage()); + } + + @Test + void adapterRejectsResultFromUnexpectedAdapterVersion() { + OfficeConversionRequest request = request("sandboxed-office-sidecar", "24.8.5"); + byte[] pdf = OfficeConversionTestPdf.onePage(); + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "sandboxed-office-sidecar", + "24.8.6", + input.sourceSha256(), + input.binding(), + pdf + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request) + ); + + assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode()); + assertEquals("conversion result adapter identity mismatch", failure.getMessage()); + } + + private static OfficeConversionRequest request(String adapterId, String adapterVersion) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("ce0a17f5-cdee-44db-9547-c7ed5e6d2f19"), + 4L, + "docx", + adapterId, + adapterVersion, + "policy-v3", + "trace-adapter-binding", + OfficeConversionTestSource.zipPackage("fixture-source"), + OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES + ); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java new file mode 100644 index 00000000..30420cec --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionAdapterProvenanceTest.java @@ -0,0 +1,62 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** + * Security and integrity regressions for adapter result provenance. + */ +class OfficeConversionAdapterProvenanceTest { + + @Test + void convertRejectsResultBoundToDifferentSourceDigest() { + OfficeConversionRequest request = request("source-a"); + OfficeConversionRequest differentSource = request("source-b"); + byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII); + OfficeConversionAdapter adapter = ignored -> new OfficeConversionResult( + "deterministic-fixture", + "1", + differentSource.sourceSha256(), + pdf + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request) + ); + + assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode()); + assertEquals("conversion result source digest mismatch", failure.getMessage()); + } + + @Test + void convertRejectsMissingAdapterResult() { + OfficeConversionRequest request = request("source-a"); + OfficeConversionAdapter adapter = ignored -> null; + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request) + ); + + assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode()); + assertEquals("conversion adapter returned no result", failure.getMessage()); + } + + private static OfficeConversionRequest request(String sourceText) { + return new OfficeConversionRequest( + "tenant-a", + UUID.randomUUID(), + 1L, + "docx", + "policy-v1", + "trace-1", + OfficeConversionTestSource.zipPackage(sourceText) + ); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionDigestFailureCoverageTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionDigestFailureCoverageTest.java new file mode 100644 index 00000000..1e9a44eb --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionDigestFailureCoverageTest.java @@ -0,0 +1,71 @@ +package com.clearfolio.viewer.conversion; + +import static com.clearfolio.viewer.testsupport.SecurityProviderTestSupport.SECURITY_PROVIDERS_LOCK; +import static com.clearfolio.viewer.testsupport.SecurityProviderTestSupport.sha256ProviderPositions; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.security.Security; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; + +import com.clearfolio.viewer.testsupport.SecurityProviderTestSupport.ProviderPosition; + +/** Covers fail-closed digest behavior when the JVM cannot provide SHA-256. */ +@ResourceLock("java.security.Security.providers") +class OfficeConversionDigestFailureCoverageTest { + + @Test + void requestAndResultDigestMethodsFailClosedWithoutSha256Provider() { + OfficeConversionRequest request = new OfficeConversionRequest( + "tenant-a", + UUID.fromString("2af086d7-5739-4b74-9791-5ed4a899f5e8"), + 3L, + "docx", + "adapter", + "2", + "policy", + "trace", + OfficeConversionTestSource.zipPackage("digest-source") + ); + String digest = request.sourceSha256(); + OfficeConversionResult result = new OfficeConversionResult( + "adapter", + "2", + digest, + request.binding(), + OfficeConversionTestPdf.onePage() + ); + + synchronized (SECURITY_PROVIDERS_LOCK) { + List providers = sha256ProviderPositions(); + assertFalse(providers.isEmpty()); + providers.forEach(position -> Security.removeProvider(position.provider().getName())); + try { + IllegalStateException sourceFailure = assertThrows( + IllegalStateException.class, + request::sourceSha256 + ); + assertEquals("SHA-256 is unavailable", sourceFailure.getMessage()); + + IllegalStateException outputFailure = assertThrows( + IllegalStateException.class, + result::outputSha256 + ); + assertEquals("SHA-256 is unavailable", outputFailure.getMessage()); + } finally { + providers.stream() + .sorted(Comparator.comparingInt(ProviderPosition::position)) + .forEach(position -> Security.insertProviderAt( + position.provider(), + position.position() + )); + } + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java new file mode 100644 index 00000000..967e361b --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionOutputLimitTest.java @@ -0,0 +1,93 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** + * Resource-boundary regressions for Office conversion output acceptance. + */ +class OfficeConversionOutputLimitTest { + + @Test + void requestBindsPositiveMaximumOutputBytes() { + OfficeConversionRequest request = requestWithLimit(20L); + + assertEquals(20L, request.maxOutputBytes()); + assertEquals(20L, request.binding().maxOutputBytes()); + assertThrows(IllegalArgumentException.class, () -> requestWithLimit(0L)); + assertThrows(IllegalArgumentException.class, () -> requestWithLimit(-1L)); + } + + @Test + void outputLimitChangesImmutableRequestBinding() { + OfficeConversionRequest small = requestWithLimit(20L); + OfficeConversionRequest large = new OfficeConversionRequest( + small.tenantId(), + small.jobId(), + small.jobGeneration(), + small.sourceFormat(), + small.policyVersion(), + small.correlationId(), + small.sourceBytes(), + 21L + ); + + org.junit.jupiter.api.Assertions.assertNotEquals(small.binding(), large.binding()); + } + + @Test + void adapterRejectsPdfThatExceedsBoundOutputLimit() { + OfficeConversionRequest request = requestWithLimit(8L); + byte[] pdf = "%PDF-1.7\nreference".getBytes(StandardCharsets.US_ASCII); + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request) + ); + + assertEquals(OfficeConversionFailureCode.OUTPUT_LIMIT_EXCEEDED, failure.failureCode()); + assertEquals("conversion output exceeds maximum bytes", failure.getMessage()); + } + + @Test + void adapterAcceptsParseablePdfAtExactOutputLimit() { + byte[] pdf = OfficeConversionTestPdf.onePage(); + OfficeConversionRequest request = requestWithLimit(pdf.length); + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + + OfficeConversionResult result = adapter.convert(request); + + assertEquals(pdf.length, result.pdfBytes().length); + } + + private static OfficeConversionRequest requestWithLimit(long maxOutputBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("1eaf3d24-f238-4a14-a909-47c20d264282"), + 3L, + "docx", + "policy-v1", + "trace-output-limit", + OfficeConversionTestSource.zipPackage("fixture-source"), + maxOutputBytes + ); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java new file mode 100644 index 00000000..7a1b41db --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPageLimitTest.java @@ -0,0 +1,101 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.UUID; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.Test; + +/** + * Resource-boundary regressions for request-bound PDF page-count acceptance. + */ +class OfficeConversionPageLimitTest { + + @Test + void requestBindsPositiveMaximumPdfPages() { + OfficeConversionRequest request = requestWithLimits(1_000_000L, 2); + + assertEquals(2, request.maxPdfPages()); + assertEquals(2, request.binding().maxPdfPages()); + assertThrows(IllegalArgumentException.class, () -> requestWithLimits(1_000_000L, 0)); + assertThrows(IllegalArgumentException.class, () -> requestWithLimits(1_000_000L, -1)); + } + + @Test + void pageLimitChangesImmutableRequestBinding() { + OfficeConversionRequest twoPages = requestWithLimits(1_000_000L, 2); + OfficeConversionRequest threePages = requestWithLimits(1_000_000L, 3); + + assertNotEquals(twoPages.binding(), threePages.binding()); + } + + @Test + void adapterRejectsPdfThatExceedsBoundPageLimit() throws IOException { + OfficeConversionRequest request = requestWithLimits(1_000_000L, 1); + byte[] pdf = pdfWithPages(2); + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request) + ); + + assertEquals(OfficeConversionFailureCode.PAGE_LIMIT_EXCEEDED, failure.failureCode()); + assertEquals("conversion output exceeds maximum pages", failure.getMessage()); + } + + @Test + void adapterAcceptsPdfAtExactPageLimit() throws IOException { + OfficeConversionRequest request = requestWithLimits(1_000_000L, 2); + byte[] pdf = pdfWithPages(2); + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + + OfficeConversionResult result = adapter.convert(request); + + assertEquals(2, request.maxPdfPages()); + assertEquals(pdf.length, result.pdfBytes().length); + } + + private static OfficeConversionRequest requestWithLimits(long maxOutputBytes, int maxPdfPages) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("bd7bd272-61d5-4558-937f-2180d00ec4dd"), + 4L, + "docx", + "policy-v1", + "trace-page-limit", + OfficeConversionTestSource.zipPackage("fixture-source"), + maxOutputBytes, + maxPdfPages + ); + } + + private static byte[] pdfWithPages(int pageCount) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + for (int index = 0; index < pageCount; index++) { + document.addPage(new PDPage()); + } + document.save(output); + return output.toByteArray(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java new file mode 100644 index 00000000..86e2d2aa --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfActionClassificationTest.java @@ -0,0 +1,186 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.UUID; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.Test; + +/** + * Behavior-level PDF action-policy regressions for converter output. + * + *

Network-independent conversion forbids dereferencing remote resources during + * conversion, but it does not make explicit user navigation metadata executable. + * The publication boundary therefore preserves direct internal navigation and + * approved user-activated URI links while rejecting event-triggered additional + * actions, executable behavior, malformed actions, chained-active actions, and + * unknown action behavior.

+ */ +class OfficeConversionPdfActionClassificationTest { + + @Test + void adapterPreservesInternalDocumentGoToOpenAction() throws IOException { + byte[] pdf = pdfWithDocumentOpenAction(goToAction()); + + assertDoesNotThrow(() -> adapterReturning(pdf).convert(request())); + } + + @Test + void adapterPreservesExplicitInternalAnnotationGoToAction() throws IOException { + byte[] pdf = pdfWithAnnotationAction(goToAction()); + + assertDoesNotThrow(() -> adapterReturning(pdf).convert(request())); + } + + @Test + void adapterRejectsInternalPageAdditionalGoToAction() throws IOException { + assertPolicyDenied(pdfWithPageAdditionalAction(goToAction())); + } + + @Test + void adapterRejectsInternalAnnotationAdditionalGoToAction() throws IOException { + assertPolicyDenied(pdfWithAnnotationAdditionalAction(goToAction())); + } + + @Test + void adapterRejectsAnnotationSubmitFormAction() throws IOException { + assertPolicyDenied(pdfWithAnnotationAction(action("SubmitForm"))); + } + + @Test + void adapterRejectsAnnotationImportDataAction() throws IOException { + assertPolicyDenied(pdfWithAnnotationAction(action("ImportData"))); + } + + @Test + void adapterRejectsUnknownAnnotationActionType() throws IOException { + assertPolicyDenied(pdfWithAnnotationAction(action("ClearfolioUnknown"))); + } + + @Test + void adapterRejectsBenignPrimaryActionChainedToJavaScript() throws IOException { + COSDictionary chainedAction = goToAction(); + chainedAction.setItem(COSName.getPDFName("Next"), action("JavaScript")); + + assertPolicyDenied(pdfWithAnnotationAction(chainedAction)); + } + + private static void assertPolicyDenied(byte[] pdf) { + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapterReturning(pdf).convert(request()) + ); + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + private static OfficeConversionAdapter adapterReturning(byte[] pdf) { + return input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + } + + private static OfficeConversionRequest request() { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"), + 9L, + "docx", + "policy-v1", + "trace-action-policy", + OfficeConversionTestSource.zipPackage("fixture-source"), + 1_000_000L, + 10 + ); + } + + private static byte[] pdfWithDocumentOpenAction(COSDictionary action) throws IOException { + try (PDDocument document = onePageDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("OpenAction"), action); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithPageAdditionalAction(COSDictionary action) throws IOException { + try (PDDocument document = onePageDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + COSDictionary additionalActions = new COSDictionary(); + additionalActions.setItem(COSName.getPDFName("O"), action); + document.getPage(0).getCOSObject() + .setItem(COSName.getPDFName("AA"), additionalActions); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithAnnotationAdditionalAction(COSDictionary action) throws IOException { + try (PDDocument document = onePageDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + COSDictionary additionalActions = new COSDictionary(); + additionalActions.setItem(COSName.getPDFName("E"), action); + COSDictionary annotation = linkAnnotation(); + annotation.setItem(COSName.getPDFName("AA"), additionalActions); + attachAnnotation(document.getPage(0), annotation); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithAnnotationAction(COSDictionary action) throws IOException { + try (PDDocument document = onePageDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + COSDictionary annotation = linkAnnotation(); + annotation.setItem(COSName.getPDFName("A"), action); + attachAnnotation(document.getPage(0), annotation); + document.save(output); + return output.toByteArray(); + } + } + + private static PDDocument onePageDocument() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage()); + return document; + } + + private static void attachAnnotation(PDPage page, COSDictionary annotation) { + COSArray annotations = new COSArray(); + annotations.add(annotation); + page.getCOSObject().setItem(COSName.getPDFName("Annots"), annotations); + } + + private static COSDictionary linkAnnotation() { + COSDictionary annotation = new COSDictionary(); + annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot")); + annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link")); + return annotation; + } + + private static COSDictionary goToAction() { + COSDictionary action = action("GoTo"); + action.setItem(COSName.getPDFName("D"), COSName.getPDFName("section-one")); + return action; + } + + private static COSDictionary action(String actionType) { + COSDictionary action = new COSDictionary(); + action.setItem(COSName.getPDFName("S"), COSName.getPDFName(actionType)); + return action; + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfBoundaryCoverageTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfBoundaryCoverageTest.java new file mode 100644 index 00000000..696b4403 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfBoundaryCoverageTest.java @@ -0,0 +1,128 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.UUID; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.Test; + +/** Covers benign and malformed PDF action shapes at the publication boundary. */ +class OfficeConversionPdfBoundaryCoverageTest { + + @Test + void adapterPreservesEveryDirectInternalOpenDestinationShape() throws IOException { + COSArray arrayDestination = new COSArray(); + arrayDestination.add(COSName.getPDFName("section-one")); + + assertDoesNotThrow(() -> convert(pdfWithOpenAction(arrayDestination))); + assertDoesNotThrow(() -> convert(pdfWithOpenAction(COSName.getPDFName("section-one")))); + assertDoesNotThrow(() -> convert(pdfWithOpenAction(new COSString("section-one")))); + } + + @Test + void adapterPreservesAnnotationWithoutActionAndEmptyAdditionalActions() throws IOException { + COSDictionary annotation = linkAnnotation(); + annotation.setItem(COSName.getPDFName("AA"), new COSDictionary()); + + assertDoesNotThrow(() -> convert(pdfWithAnnotation(annotation))); + } + + @Test + void adapterRejectsMalformedAdditionalActionsContainer() throws IOException { + COSDictionary annotation = linkAnnotation(); + annotation.setItem(COSName.getPDFName("AA"), COSName.getPDFName("malformed")); + + assertPolicyDenied(pdfWithAnnotation(annotation)); + } + + @Test + void adapterRejectsDocumentUriActionEvenWhenSchemeWouldBeAllowedOnUserLink() throws IOException { + COSDictionary uriAction = new COSDictionary(); + uriAction.setItem(COSName.getPDFName("S"), COSName.getPDFName("URI")); + uriAction.setString(COSName.getPDFName("URI"), "https://example.invalid/report"); + + assertPolicyDenied(pdfWithOpenAction(uriAction)); + } + + private static void convert(byte[] pdf) { + adapterReturning(pdf).convert(request()); + } + + private static void assertPolicyDenied(byte[] pdf) { + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> convert(pdf) + ); + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + private static OfficeConversionAdapter adapterReturning(byte[] pdf) { + return input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + } + + private static OfficeConversionRequest request() { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"), + 9L, + "docx", + "policy-v1", + "trace-pdf-boundary-coverage", + OfficeConversionTestSource.zipPackage("fixture-source"), + 1_000_000L, + 10 + ); + } + + private static byte[] pdfWithOpenAction(COSBase openAction) throws IOException { + try (PDDocument document = onePageDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.getDocumentCatalog().getCOSObject() + .setItem(COSName.getPDFName("OpenAction"), openAction); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] pdfWithAnnotation(COSDictionary annotation) throws IOException { + try (PDDocument document = onePageDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + COSArray annotations = new COSArray(); + annotations.add(annotation); + document.getPage(0).getCOSObject().setItem(COSName.getPDFName("Annots"), annotations); + document.save(output); + return output.toByteArray(); + } + } + + private static PDDocument onePageDocument() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage()); + return document; + } + + private static COSDictionary linkAnnotation() { + COSDictionary annotation = new COSDictionary(); + annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot")); + annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link")); + return annotation; + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java new file mode 100644 index 00000000..6fef3251 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionPdfValidationTest.java @@ -0,0 +1,145 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.encryption.AccessPermission; +import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy; +import org.junit.jupiter.api.Test; + +/** + * Output-structure regressions for converter-produced PDF candidates. + */ +class OfficeConversionPdfValidationTest { + + @Test + void adapterRejectsTruncatedMagicOnlyPdf() { + OfficeConversionRequest request = request(); + byte[] truncated = "%PDF-1.7\nnot-a-complete-document".getBytes(StandardCharsets.US_ASCII); + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + truncated + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request) + ); + + assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode()); + assertEquals("conversion output is not a valid PDF", failure.getMessage()); + } + + @Test + void adapterRejectsParseablePdfWithoutPages() throws IOException { + OfficeConversionRequest request = request(); + byte[] zeroPagePdf = zeroPagePdf(); + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + zeroPagePdf + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request) + ); + + assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode()); + assertEquals("conversion output PDF has no pages", failure.getMessage()); + } + + @Test + void adapterRejectsEncryptedPdf() throws IOException { + OfficeConversionRequest request = request(); + byte[] encryptedPdf = encryptedOnePagePdf(); + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + encryptedPdf + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request) + ); + + assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode()); + assertEquals("conversion output PDF must not be encrypted", failure.getMessage()); + } + + @Test + void adapterAcceptsParseablePdf() throws IOException { + OfficeConversionRequest request = request(); + byte[] pdf = onePagePdf(); + OfficeConversionAdapter adapter = input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + + OfficeConversionResult result = adapter.convert(request); + + assertArrayEquals(pdf, result.pdfBytes()); + } + + private static OfficeConversionRequest request() { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("d031f25a-8d92-4c9d-a89f-362e0324c8ef"), + 8L, + "docx", + "policy-v1", + "trace-pdf-validation", + OfficeConversionTestSource.zipPackage("fixture-source"), + OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES + ); + } + + private static byte[] zeroPagePdf() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] encryptedOnePagePdf() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + AccessPermission permissions = new AccessPermission(); + StandardProtectionPolicy policy = new StandardProtectionPolicy("owner-secret", "", permissions); + policy.setEncryptionKeyLength(128); + document.protect(policy); + document.save(output); + return output.toByteArray(); + } + } + + private static byte[] onePagePdf() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + document.save(output); + return output.toByteArray(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java new file mode 100644 index 00000000..fe35dd7d --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionRequestBindingTest.java @@ -0,0 +1,154 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** + * Integrity regressions for immutable request identity across converter boundaries. + */ +class OfficeConversionRequestBindingTest { + + @Test + void convertRejectsStaleGenerationEvenWhenSourceBytesMatch() { + UUID jobId = UUID.randomUUID(); + OfficeConversionRequest current = request( + "tenant-a", jobId, 2L, "docx", "policy-v2", "trace-current", "same"); + OfficeConversionRequest stale = request( + "tenant-a", jobId, 1L, "docx", "policy-v2", "trace-current", "same"); + byte[] pdf = "%PDF-1.7\nfixture".getBytes(StandardCharsets.US_ASCII); + OfficeConversionAdapter adapter = ignored -> new OfficeConversionResult( + "deterministic-fixture", + "1", + stale.sourceSha256(), + stale.binding(), + pdf + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(current) + ); + + assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode()); + assertEquals("conversion result request binding mismatch", failure.getMessage()); + } + + @Test + void bindingChangesAcrossEveryRequestAuthorityField() { + UUID jobId = UUID.randomUUID(); + OfficeConversionRequest baseline = request( + "tenant-a", jobId, 2L, "docx", "policy-v2", "trace-a", "same"); + OfficeConversionRequestBinding binding = baseline.binding(); + + assertNotEquals(binding, + request("tenant-b", jobId, 2L, "docx", "policy-v2", "trace-a", "same").binding()); + assertNotEquals(binding, + request("tenant-a", UUID.randomUUID(), 2L, "docx", "policy-v2", "trace-a", "same").binding()); + assertNotEquals(binding, + request("tenant-a", jobId, 3L, "docx", "policy-v2", "trace-a", "same").binding()); + assertNotEquals(binding, + request("tenant-a", jobId, 2L, "xlsx", "policy-v2", "trace-a", "same").binding()); + assertNotEquals(binding, + request("tenant-a", jobId, 2L, "docx", "policy-v3", "trace-a", "same").binding()); + assertNotEquals(binding, + request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-b", "same").binding()); + assertNotEquals(binding, + request("tenant-a", jobId, 2L, "docx", "policy-v2", "trace-a", "different").binding()); + } + + @Test + void bindingCanonicalizesTextAndRejectsInvalidAuthority() { + UUID jobId = UUID.randomUUID(); + String digest = request( + "tenant", jobId, 0L, "docx", "policy", "trace", "source").sourceSha256(); + OfficeConversionRequestBinding binding = new OfficeConversionRequestBinding( + " tenant-a ", jobId, 4L, " DoCx ", " policy-v4 ", " trace-4 ", digest); + + assertEquals("tenant-a", binding.tenantId()); + assertEquals("docx", binding.sourceFormat()); + assertEquals("policy-v4", binding.policyVersion()); + assertEquals("trace-4", binding.correlationId()); + assertEquals(digest, binding.sourceSha256()); + + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionRequestBinding(null, jobId, 0L, "docx", "policy", "trace", digest)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionRequestBinding(" ", jobId, 0L, "docx", "policy", "trace", digest)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionRequestBinding("tenant", null, 0L, "docx", "policy", "trace", digest)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionRequestBinding("tenant", jobId, -1L, "docx", "policy", "trace", digest)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionRequestBinding("tenant", jobId, 0L, " ", "policy", "trace", digest)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionRequestBinding("tenant", jobId, 0L, "docx", " ", "trace", digest)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionRequestBinding("tenant", jobId, 0L, "docx", "policy", " ", digest)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionRequestBinding("tenant", jobId, 0L, "docx", "policy", "trace", null)); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionRequestBinding("tenant", jobId, 0L, "docx", "policy", "trace", "bad")); + assertThrows(IllegalArgumentException.class, + () -> new OfficeConversionRequestBinding( + "tenant", jobId, 0L, "docx", "policy", "trace", digest.toUpperCase())); + } + + @Test + void deterministicFixtureAdapterIsExactAndDefensivelyOwned() { + UUID jobId = UUID.randomUUID(); + OfficeConversionRequest current = request( + "tenant-a", jobId, 5L, "docx", "policy-v1", "trace-1", "fixture-source"); + OfficeConversionRequest stale = request( + "tenant-a", jobId, 4L, "docx", "policy-v1", "trace-1", "fixture-source"); + byte[] pdf = OfficeConversionTestPdf.onePage(); + byte[] canonicalPdf = pdf.clone(); + DeterministicFixtureOfficeConversionAdapter adapter = new DeterministicFixtureOfficeConversionAdapter( + Map.of(current.binding(), pdf) + ); + pdf[0] = 'X'; + + OfficeConversionResult first = adapter.convert(current); + OfficeConversionResult second = adapter.convert(current); + + assertArrayEquals(canonicalPdf, first.pdfBytes()); + assertArrayEquals(first.pdfBytes(), second.pdfBytes()); + assertEquals(first.outputSha256(), second.outputSha256()); + assertEquals(current.binding(), first.requestBinding()); + assertEquals("deterministic-fixture", first.adapterId()); + assertEquals("1", first.adapterVersion()); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(stale) + ); + assertEquals(OfficeConversionFailureCode.INVALID_OUTPUT, failure.failureCode()); + assertEquals("deterministic fixture not registered for request binding", failure.getMessage()); + } + + private static OfficeConversionRequest request( + String tenantId, + UUID jobId, + long generation, + String sourceFormat, + String policyVersion, + String correlationId, + String sourceText) { + return new OfficeConversionRequest( + tenantId, + jobId, + generation, + sourceFormat, + policyVersion, + correlationId, + OfficeConversionTestSource.forFormat(sourceFormat, sourceText) + ); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionResidualCoverageTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionResidualCoverageTest.java new file mode 100644 index 00000000..22897f09 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionResidualCoverageTest.java @@ -0,0 +1,332 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; +import java.util.UUID; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.junit.jupiter.api.Test; + +/** Covers residual fail-closed branches in Office package and action preflight. */ +class OfficeConversionResidualCoverageTest { + + private static final int LOCAL_FIXED = 30; + private static final int CENTRAL_FIXED = 46; + private static final int EOCD_LENGTH = 22; + private static final byte[] ODT_MIMETYPE = + "application/vnd.oasis.opendocument.text".getBytes(StandardCharsets.US_ASCII); + private static final String ODF_MANIFEST = "" + + "" + + "" + + ""; + + @Test + void repeatedActionIdentityFailsClosedBeforeFollowingCycle() throws Exception { + COSDictionary action = new COSDictionary(); + action.setItem(COSName.getPDFName("S"), COSName.getPDFName("GoTo")); + action.setItem(COSName.getPDFName("D"), COSName.getPDFName("section-one")); + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + visited.add(action); + + Method method = OfficeConversionAdapter.class.getDeclaredMethod( + "isProhibitedAction", + COSBase.class, + boolean.class, + Set.class, + int.class + ); + method.setAccessible(true); + + assertTrue((boolean) method.invoke(null, action, false, visited, 1)); + } + + @Test + void emptyDeflateInputFailsClosedWithoutSpinning() throws IOException { + byte[] source = deflatedOdfPackage(); + int central = findSignature(source, 0x02014b50L); + putUnsignedInt(source, central + 20, 0L); + + assertMalformedManifest(source); + } + + @Test + void manifestLocatorRejectsSecondCentralCursorOutsideSignatureBounds() { + byte[] source = new byte[70]; + putUnsignedInt(source, 0, 0x02014b50L); + putUnsignedShort(source, 28, 22); + int eocd = 48; + putUnsignedInt(source, eocd, 0x06054b50L); + putUnsignedShort(source, eocd + 8, 2); + putUnsignedShort(source, eocd + 10, 2); + putUnsignedInt(source, eocd + 16, 0L); + + assertMalformedManifest(source); + } + + @Test + void sourceContainerRejectsMissingSecondCentralRecord() { + byte[] source = oneEntryZip("content.xml"); + int eocd = source.length - EOCD_LENGTH; + putUnsignedShort(source, eocd + 8, 2); + putUnsignedShort(source, eocd + 10, 2); + + assertMalformedContainer("docx", source); + } + + @Test + void sourceContainerRejectsInBoundsSecondRecordWithMissingSignature() { + byte[] base = oneEntryZip("content.xml"); + int oldEocd = base.length - EOCD_LENGTH; + byte[] source = new byte[base.length + CENTRAL_FIXED]; + System.arraycopy(base, 0, source, 0, oldEocd); + int newEocd = oldEocd + CENTRAL_FIXED; + System.arraycopy(base, oldEocd, source, newEocd, EOCD_LENGTH); + putUnsignedShort(source, newEocd + 8, 2); + putUnsignedShort(source, newEocd + 10, 2); + putUnsignedInt(source, newEocd + 12, centralRecordLength("content.xml") + CENTRAL_FIXED); + + assertMalformedContainer("docx", source); + } + + @Test + void sourceContainerRejectsUnaccountedCentralDirectoryPadding() { + byte[] base = oneEntryZip("content.xml"); + int oldEocd = base.length - EOCD_LENGTH; + byte[] source = new byte[base.length + 1]; + System.arraycopy(base, 0, source, 0, oldEocd); + System.arraycopy(base, oldEocd, source, oldEocd + 1, EOCD_LENGTH); + int newEocd = oldEocd + 1; + putUnsignedInt(source, newEocd + 12, centralRecordLength("content.xml") + 1L); + + assertMalformedContainer("docx", source); + } + + @Test + void sourceContainerAcceptsMatchingDataDescriptorAuthority() { + assertDoesNotThrow(() -> OfficeSourceContainerPreflight.requireQualifiedContainer( + request("docx", oneEntryZipWithDescriptor("content.xml")) + )); + } + + @Test + void odfMimetypePayloadMustMatchWhenDeclaredLengthMatches() throws IOException { + byte[] source = storedOdfPackage(); + int localNameLength = unsignedShort(source, 26); + int localExtraLength = unsignedShort(source, 28); + int mimetypeData = LOCAL_FIXED + localNameLength + localExtraLength; + source[mimetypeData] ^= 0x01; + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> OfficeSourceContainerPreflight.requireQualifiedContainer(request("odt", source)) + ); + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF mimetype does not match declared format", failure.getMessage()); + } + + @Test + void equalLengthNearManifestNameExercisesByteMismatchPath() { + byte[] source = oneEntryZip("META-INF/manifest.xmL"); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> OfficeSourceContainerPreflight.requireQualifiedContainer(request("odt", source)) + ); + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF META-INF entry is not allowed", failure.getMessage()); + } + + @Test + void safePathAcceptsShortAndLetterCaseAndNonLetterLeadingNames() { + assertDoesNotThrow(() -> OfficeSourceContainerPreflight.requireQualifiedContainer( + request("docx", oneEntryZip("a")) + )); + assertDoesNotThrow(() -> OfficeSourceContainerPreflight.requireQualifiedContainer( + request("docx", oneEntryZip("Ax")) + )); + assertDoesNotThrow(() -> OfficeSourceContainerPreflight.requireQualifiedContainer( + request("docx", oneEntryZip("1x")) + )); + assertDoesNotThrow(() -> OfficeSourceContainerPreflight.requireQualifiedContainer( + request("docx", oneEntryZip("{x")) + )); + assertDoesNotThrow(() -> OfficeSourceContainerPreflight.requireQualifiedContainer( + request("docx", oneEntryZip("a/.x")) + )); + } + + private static void assertMalformedManifest(byte[] source) { + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> OfficeOdfManifestPreflight.requireQualifiedManifest(request("odt", source)) + ); + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF manifest is invalid", failure.getMessage()); + } + + private static void assertMalformedContainer(String format, byte[] source) { + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> OfficeSourceContainerPreflight.requireQualifiedContainer(request(format, source)) + ); + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + } + + private static OfficeConversionRequest request(String format, byte[] source) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("61d93e0c-c1b5-45c6-a1c1-29cff383977e"), + 23L, + format, + "policy-v1", + "trace-residual-coverage", + source, + 2_000_000L, + 20 + ); + } + + private static byte[] deflatedOdfPackage() throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + ZipEntry manifest = new ZipEntry("META-INF/manifest.xml"); + manifest.setMethod(ZipEntry.DEFLATED); + zip.putNextEntry(manifest); + zip.write(ODF_MANIFEST.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return output.toByteArray(); + } + + private static byte[] storedOdfPackage() throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + writeStored(zip, "mimetype", ODT_MIMETYPE); + writeStored(zip, "META-INF/manifest.xml", ODF_MANIFEST.getBytes(StandardCharsets.UTF_8)); + } + return output.toByteArray(); + } + + private static void writeStored(ZipOutputStream zip, String name, byte[] payload) throws IOException { + CRC32 crc32 = new CRC32(); + crc32.update(payload); + ZipEntry entry = new ZipEntry(name); + entry.setMethod(ZipEntry.STORED); + entry.setSize(payload.length); + entry.setCompressedSize(payload.length); + entry.setCrc(crc32.getValue()); + zip.putNextEntry(entry); + zip.write(payload); + zip.closeEntry(); + } + + private static byte[] oneEntryZip(String entryName) { + byte[] name = entryName.getBytes(StandardCharsets.ISO_8859_1); + int centralOffset = LOCAL_FIXED + name.length; + int centralRecordLength = CENTRAL_FIXED + name.length; + int eocdOffset = centralOffset + centralRecordLength; + byte[] bytes = new byte[eocdOffset + EOCD_LENGTH]; + + putUnsignedInt(bytes, 0, 0x04034b50L); + putUnsignedShort(bytes, 4, 20); + putUnsignedShort(bytes, 26, name.length); + System.arraycopy(name, 0, bytes, LOCAL_FIXED, name.length); + + putUnsignedInt(bytes, centralOffset, 0x02014b50L); + putUnsignedShort(bytes, centralOffset + 28, name.length); + putUnsignedInt(bytes, centralOffset + 42, 0L); + System.arraycopy(name, 0, bytes, centralOffset + CENTRAL_FIXED, name.length); + + putUnsignedInt(bytes, eocdOffset, 0x06054b50L); + putUnsignedShort(bytes, eocdOffset + 8, 1); + putUnsignedShort(bytes, eocdOffset + 10, 1); + putUnsignedInt(bytes, eocdOffset + 12, centralRecordLength); + putUnsignedInt(bytes, eocdOffset + 16, centralOffset); + return bytes; + } + + private static byte[] oneEntryZipWithDescriptor(String entryName) { + byte[] name = entryName.getBytes(StandardCharsets.ISO_8859_1); + int descriptorOffset = LOCAL_FIXED + name.length; + int descriptorLength = 16; + int centralOffset = descriptorOffset + descriptorLength; + int centralRecordLength = CENTRAL_FIXED + name.length; + int eocdOffset = centralOffset + centralRecordLength; + byte[] bytes = new byte[eocdOffset + EOCD_LENGTH]; + + putUnsignedInt(bytes, 0, 0x04034b50L); + putUnsignedShort(bytes, 4, 20); + putUnsignedShort(bytes, 6, 0x0008); + putUnsignedShort(bytes, 26, name.length); + System.arraycopy(name, 0, bytes, LOCAL_FIXED, name.length); + putUnsignedInt(bytes, descriptorOffset, 0x08074b50L); + + putUnsignedInt(bytes, centralOffset, 0x02014b50L); + putUnsignedShort(bytes, centralOffset + 8, 0x0008); + putUnsignedShort(bytes, centralOffset + 28, name.length); + putUnsignedInt(bytes, centralOffset + 42, 0L); + System.arraycopy(name, 0, bytes, centralOffset + CENTRAL_FIXED, name.length); + + putUnsignedInt(bytes, eocdOffset, 0x06054b50L); + putUnsignedShort(bytes, eocdOffset + 8, 1); + putUnsignedShort(bytes, eocdOffset + 10, 1); + putUnsignedInt(bytes, eocdOffset + 12, centralRecordLength); + putUnsignedInt(bytes, eocdOffset + 16, centralOffset); + return bytes; + } + + private static long centralRecordLength(String entryName) { + return CENTRAL_FIXED + entryName.getBytes(StandardCharsets.ISO_8859_1).length; + } + + private static int findSignature(byte[] source, long signature) { + for (int offset = 0; offset <= source.length - 4; offset++) { + if (unsignedInt(source, offset) == signature) { + return offset; + } + } + throw new IllegalArgumentException("signature not found"); + } + + private static int unsignedShort(byte[] bytes, int offset) { + return Byte.toUnsignedInt(bytes[offset]) | (Byte.toUnsignedInt(bytes[offset + 1]) << 8); + } + + private static long unsignedInt(byte[] bytes, int offset) { + return Integer.toUnsignedLong( + Byte.toUnsignedInt(bytes[offset]) + | (Byte.toUnsignedInt(bytes[offset + 1]) << 8) + | (Byte.toUnsignedInt(bytes[offset + 2]) << 16) + | (Byte.toUnsignedInt(bytes[offset + 3]) << 24) + ); + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestPdf.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestPdf.java new file mode 100644 index 00000000..d9b799cb --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestPdf.java @@ -0,0 +1,32 @@ +package com.clearfolio.viewer.conversion; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; + +/** + * Deterministic parseable PDF fixtures shared by Office conversion contract tests. + */ +final class OfficeConversionTestPdf { + + private OfficeConversionTestPdf() { + } + + /** + * Creates a deterministic one-page PDF suitable for parser acceptance tests. + * + * @return parseable one-page PDF bytes + */ + static byte[] onePage() { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + document.save(output); + return output.toByteArray(); + } catch (IOException ex) { + throw new IllegalStateException("failed to create test PDF", ex); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java new file mode 100644 index 00000000..c307366f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionTestSource.java @@ -0,0 +1,146 @@ +package com.clearfolio.viewer.conversion; + +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.Set; + +/** + * Creates deterministic test-only Office source bytes for conversion-boundary tests. + * + *

ZIP-family fixtures contain only enough framing to satisfy the common source + * preflight: matching local/central entry metadata, deterministic marker bytes, and a + * self-consistent standard single-disk end-of-central-directory record. Legacy fixtures + * contain only the compound-file family signature plus marker bytes. These are + * not valid complete OOXML, ODF, or compound-file documents and must + * never be used as fidelity, archive-expansion, macro, malware, or production converter + * fixtures. Real document-fidelity qualification uses separate authorized or + * redistributable Office fixtures.

+ */ +final class OfficeConversionTestSource { + + private static final Set ZIP_PACKAGE_FORMATS = Set.of( + "docx", "xlsx", "pptx", "odt", "ods", "odp" + ); + private static final Set COMPOUND_FILE_FORMATS = Set.of( + "doc", "xls", "ppt" + ); + private static final byte[] ZIP_LOCAL_FILE_HEADER = new byte[] { + 0x50, 0x4b, 0x03, 0x04 + }; + private static final byte[] ZIP_CENTRAL_DIRECTORY_HEADER = new byte[] { + 0x50, 0x4b, 0x01, 0x02 + }; + private static final byte[] ZIP_END_OF_CENTRAL_DIRECTORY = new byte[] { + 0x50, 0x4b, 0x05, 0x06 + }; + private static final byte[] COMPOUND_FILE_HEADER = new byte[] { + (byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0, + (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1 + }; + private static final byte[] ZIP_SAFE_ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8); + private static final int ZIP_LOCAL_FILE_HEADER_FIXED_LENGTH = 30; + private static final int ZIP_CENTRAL_DIRECTORY_FIXED_LENGTH = 46; + private static final int ZIP_EOCD_LENGTH = 22; + + private OfficeConversionTestSource() { + } + + /** + * Creates one preflight-qualified source fixture for the declared candidate format. + * + * @param sourceFormat Office candidate format + * @param marker deterministic marker kept inside the test container framing + * @return test-only source bytes + */ + static byte[] forFormat(String sourceFormat, String marker) { + String normalized = sourceFormat.strip().toLowerCase(Locale.ROOT); + if (ZIP_PACKAGE_FORMATS.contains(normalized)) { + return zipPackage(marker); + } + if (COMPOUND_FILE_FORMATS.contains(normalized)) { + return compoundFile(marker); + } + return marker.getBytes(StandardCharsets.UTF_8); + } + + /** + * Creates a bounded-framing ZIP-package source fixture. + * + * @param marker deterministic marker + * @return test-only ZIP-family source bytes + */ + static byte[] zipPackage(String marker) { + byte[] markerBytes = marker.getBytes(StandardCharsets.UTF_8); + int localNameOffset = ZIP_LOCAL_FILE_HEADER_FIXED_LENGTH; + int markerOffset = localNameOffset + ZIP_SAFE_ENTRY_NAME.length; + int centralDirectoryOffset = markerOffset + markerBytes.length; + int centralDirectoryLength = ZIP_CENTRAL_DIRECTORY_FIXED_LENGTH + ZIP_SAFE_ENTRY_NAME.length; + int eocdOffset = centralDirectoryOffset + centralDirectoryLength; + byte[] bytes = new byte[eocdOffset + ZIP_EOCD_LENGTH]; + + System.arraycopy(ZIP_LOCAL_FILE_HEADER, 0, bytes, 0, ZIP_LOCAL_FILE_HEADER.length); + putUnsignedShort(bytes, 4, 20); + putUnsignedShort(bytes, 26, ZIP_SAFE_ENTRY_NAME.length); + System.arraycopy(ZIP_SAFE_ENTRY_NAME, 0, bytes, localNameOffset, ZIP_SAFE_ENTRY_NAME.length); + System.arraycopy(markerBytes, 0, bytes, markerOffset, markerBytes.length); + + System.arraycopy( + ZIP_CENTRAL_DIRECTORY_HEADER, + 0, + bytes, + centralDirectoryOffset, + ZIP_CENTRAL_DIRECTORY_HEADER.length + ); + putUnsignedShort(bytes, centralDirectoryOffset + 28, ZIP_SAFE_ENTRY_NAME.length); + putUnsignedInt(bytes, centralDirectoryOffset + 42, 0L); + System.arraycopy( + ZIP_SAFE_ENTRY_NAME, + 0, + bytes, + centralDirectoryOffset + ZIP_CENTRAL_DIRECTORY_FIXED_LENGTH, + ZIP_SAFE_ENTRY_NAME.length + ); + System.arraycopy( + ZIP_END_OF_CENTRAL_DIRECTORY, + 0, + bytes, + eocdOffset, + ZIP_END_OF_CENTRAL_DIRECTORY.length + ); + putUnsignedShort(bytes, eocdOffset + 8, 1); + putUnsignedShort(bytes, eocdOffset + 10, 1); + putUnsignedInt(bytes, eocdOffset + 12, centralDirectoryLength); + putUnsignedInt(bytes, eocdOffset + 16, centralDirectoryOffset); + putUnsignedShort(bytes, eocdOffset + 20, 0); + return bytes; + } + + /** + * Creates a signature-qualified compound-file source fixture. + * + * @param marker deterministic marker + * @return test-only legacy Office source bytes + */ + static byte[] compoundFile(String marker) { + return concatenate(COMPOUND_FILE_HEADER, marker.getBytes(StandardCharsets.UTF_8)); + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } + + private static byte[] concatenate(byte[] prefix, byte[] suffix) { + byte[] combined = new byte[prefix.length + suffix.length]; + System.arraycopy(prefix, 0, combined, 0, prefix.length); + System.arraycopy(suffix, 0, combined, prefix.length, suffix.length); + return combined; + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java new file mode 100644 index 00000000..491dafdf --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionUriActionPolicyTest.java @@ -0,0 +1,143 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.UUID; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.Test; + +/** + * URI-scheme regressions for user-activated PDF link preservation. + * + *

Ordinary web and mail hyperlinks are inert navigation metadata at the + * conversion boundary. Executable, local-file, embedded-data, malformed, and + * custom-protocol URI actions fail closed because a PDF viewer may dispatch + * those schemes to behavior outside ordinary hyperlink navigation.

+ */ +class OfficeConversionUriActionPolicyTest { + + @Test + void adapterPreservesHttpsAnnotationUri() throws IOException { + assertDoesNotThrow(() -> convert(pdfWithUri("https://example.invalid/report"))); + } + + @Test + void adapterPreservesHttpAnnotationUri() throws IOException { + assertDoesNotThrow(() -> convert(pdfWithUri("http://example.invalid/report"))); + } + + @Test + void adapterPreservesMailtoAnnotationUri() throws IOException { + assertDoesNotThrow(() -> convert(pdfWithUri("mailto:security@example.invalid"))); + } + + @Test + void adapterRejectsJavaScriptUriScheme() throws IOException { + assertPolicyDenied(pdfWithUri("javascript:alert(1)")); + } + + @Test + void adapterRejectsLocalFileUriScheme() throws IOException { + assertPolicyDenied(pdfWithUri("file:///etc/passwd")); + } + + @Test + void adapterRejectsEmbeddedDataUriScheme() throws IOException { + assertPolicyDenied(pdfWithUri("data:text/html,%3Cscript%3Ealert(1)%3C/script%3E")); + } + + @Test + void adapterRejectsUnknownCustomUriScheme() throws IOException { + assertPolicyDenied(pdfWithUri("clearfolio-custom:payload")); + } + + @Test + void adapterRejectsRelativeUriAction() throws IOException { + assertPolicyDenied(pdfWithUri("relative/path")); + } + + @Test + void adapterRejectsOpaqueHttpsUriAction() throws IOException { + assertPolicyDenied(pdfWithUri("https:example.invalid/report")); + } + + @Test + void adapterRejectsHttpUriWithoutAuthority() throws IOException { + assertPolicyDenied(pdfWithUri("http:/report")); + } + + @Test + void adapterRejectsMalformedUriAction() throws IOException { + assertPolicyDenied(pdfWithUri("https://example.invalid/has space")); + } + + private static void convert(byte[] pdf) { + adapterReturning(pdf).convert(request()); + } + + private static void assertPolicyDenied(byte[] pdf) { + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> convert(pdf) + ); + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("conversion output contains prohibited active content", failure.getMessage()); + } + + private static OfficeConversionAdapter adapterReturning(byte[] pdf) { + return input -> new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + pdf + ); + } + + private static OfficeConversionRequest request() { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"), + 9L, + "docx", + "policy-v1", + "trace-uri-action-policy", + OfficeConversionTestSource.zipPackage("fixture-source"), + 1_000_000L, + 10 + ); + } + + private static byte[] pdfWithUri(String uri) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + PDPage page = new PDPage(); + document.addPage(page); + + COSDictionary action = new COSDictionary(); + action.setItem(COSName.getPDFName("S"), COSName.getPDFName("URI")); + action.setString(COSName.getPDFName("URI"), uri); + + COSDictionary annotation = new COSDictionary(); + annotation.setItem(COSName.TYPE, COSName.getPDFName("Annot")); + annotation.setItem(COSName.SUBTYPE, COSName.getPDFName("Link")); + annotation.setItem(COSName.getPDFName("A"), action); + + COSArray annotations = new COSArray(); + annotations.add(annotation); + page.getCOSObject().setItem(COSName.getPDFName("Annots"), annotations); + + document.save(output); + return output.toByteArray(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionValueCoverageTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionValueCoverageTest.java new file mode 100644 index 00000000..6902eb29 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeConversionValueCoverageTest.java @@ -0,0 +1,181 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** Covers validation and compatibility overloads on Office conversion value objects. */ +class OfficeConversionValueCoverageTest { + + private static final UUID JOB_ID = UUID.fromString("2af086d7-5739-4b74-9791-5ed4a899f5e8"); + private static final byte[] SOURCE = "office-source".getBytes(StandardCharsets.UTF_8); + private static final String DIGEST = new OfficeConversionRequest( + "tenant", JOB_ID, 1L, "docx", "adapter", "2", "policy", "trace", SOURCE) + .sourceSha256(); + + @Test + void requestCompatibilityOverloadsPreserveQualifiedAuthority() { + OfficeConversionRequest defaultLimits = new OfficeConversionRequest( + " tenant ", JOB_ID, 1L, " DOCX ", " adapter ", " 2 ", " policy ", " trace ", SOURCE); + OfficeConversionRequest explicitBytes = new OfficeConversionRequest( + "tenant", JOB_ID, 1L, "docx", "adapter", "2", "policy", "trace", SOURCE, 4096L); + OfficeConversionRequest fixtureLimits = new OfficeConversionRequest( + "tenant", JOB_ID, 1L, "docx", "policy", "trace", SOURCE, 2048L, 7); + OfficeConversionRequest fixtureBytes = new OfficeConversionRequest( + "tenant", JOB_ID, 1L, "docx", "policy", "trace", SOURCE, 1024L); + OfficeConversionRequest fixtureDefaults = new OfficeConversionRequest( + "tenant", JOB_ID, 1L, "docx", "policy", "trace", SOURCE); + + assertEquals("tenant", defaultLimits.tenantId()); + assertEquals("docx", defaultLimits.sourceFormat()); + assertEquals("adapter", defaultLimits.expectedAdapterId()); + assertEquals("2", defaultLimits.expectedAdapterVersion()); + assertEquals(OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES, defaultLimits.maxOutputBytes()); + assertEquals(OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES, defaultLimits.maxPdfPages()); + assertEquals(4096L, explicitBytes.maxOutputBytes()); + assertEquals(OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES, explicitBytes.maxPdfPages()); + assertEquals("deterministic-fixture", fixtureLimits.expectedAdapterId()); + assertEquals("1", fixtureLimits.expectedAdapterVersion()); + assertEquals(2048L, fixtureLimits.maxOutputBytes()); + assertEquals(7, fixtureLimits.maxPdfPages()); + assertEquals(1024L, fixtureBytes.maxOutputBytes()); + assertEquals(OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES, fixtureBytes.maxPdfPages()); + assertEquals(OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES, fixtureDefaults.maxOutputBytes()); + assertEquals(OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES, fixtureDefaults.maxPdfPages()); + + byte[] returned = defaultLimits.sourceBytes(); + returned[0] = 'X'; + assertArrayEquals(SOURCE, defaultLimits.sourceBytes()); + assertEquals(defaultLimits.binding().sourceSha256(), defaultLimits.sourceSha256()); + } + + @Test + void requestRejectsEveryInvalidAuthorityAndLimitBranch() { + assertRequestInvalid(null, JOB_ID, 0L, "docx", "adapter", "1", "policy", "trace", SOURCE, 1L, 1); + assertRequestInvalid(" ", JOB_ID, 0L, "docx", "adapter", "1", "policy", "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", null, 0L, "docx", "adapter", "1", "policy", "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, -1L, "docx", "adapter", "1", "policy", "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, null, "adapter", "1", "policy", "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, " ", "adapter", "1", "policy", "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", null, "1", "policy", "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", " ", "1", "policy", "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", "adapter", null, "policy", "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", "adapter", " ", "policy", "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", null, "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", " ", "trace", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", "policy", null, SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", "policy", " ", SOURCE, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", "policy", "trace", null, 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", "policy", "trace", new byte[0], 1L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", "policy", "trace", SOURCE, 0L, 1); + assertRequestInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", "policy", "trace", SOURCE, 1L, 0); + } + + @Test + void bindingCompatibilityOverloadsAndValidationAreFullyCovered() { + OfficeConversionRequestBinding qualified = new OfficeConversionRequestBinding( + " tenant ", JOB_ID, 2L, " XLSX ", " adapter ", " 2 ", " policy ", " trace ", DIGEST); + OfficeConversionRequestBinding fixtureBytes = new OfficeConversionRequestBinding( + "tenant", JOB_ID, 2L, "xlsx", "policy", "trace", DIGEST, 512L); + OfficeConversionRequestBinding fixtureDefaults = new OfficeConversionRequestBinding( + "tenant", JOB_ID, 2L, "xlsx", "policy", "trace", DIGEST); + + assertEquals("tenant", qualified.tenantId()); + assertEquals("xlsx", qualified.sourceFormat()); + assertEquals("adapter", qualified.expectedAdapterId()); + assertEquals("2", qualified.expectedAdapterVersion()); + assertEquals(OfficeConversionRequest.DEFAULT_MAX_OUTPUT_BYTES, qualified.maxOutputBytes()); + assertEquals(OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES, qualified.maxPdfPages()); + assertEquals("deterministic-fixture", fixtureBytes.expectedAdapterId()); + assertEquals("1", fixtureBytes.expectedAdapterVersion()); + assertEquals(512L, fixtureBytes.maxOutputBytes()); + assertEquals(OfficeConversionRequest.DEFAULT_MAX_PDF_PAGES, fixtureDefaults.maxPdfPages()); + + assertBindingInvalid("tenant", JOB_ID, 0L, "docx", null, "1", "policy", "trace", DIGEST, 1L, 1); + assertBindingInvalid("tenant", JOB_ID, 0L, "docx", " ", "1", "policy", "trace", DIGEST, 1L, 1); + assertBindingInvalid("tenant", JOB_ID, 0L, "docx", "adapter", null, "policy", "trace", DIGEST, 1L, 1); + assertBindingInvalid("tenant", JOB_ID, 0L, "docx", "adapter", " ", "policy", "trace", DIGEST, 1L, 1); + assertBindingInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", null, "trace", DIGEST, 1L, 1); + assertBindingInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", " ", "trace", DIGEST, 1L, 1); + assertBindingInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", "policy", null, DIGEST, 1L, 1); + assertBindingInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", "policy", " ", DIGEST, 1L, 1); + assertBindingInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", "policy", "trace", DIGEST, 0L, 1); + assertBindingInvalid("tenant", JOB_ID, 0L, "docx", "adapter", "1", "policy", "trace", DIGEST, 1L, 0); + } + + @Test + void resultAndTypedFailureValidationBranchesRemainFailClosed() { + OfficeConversionRequestBinding binding = new OfficeConversionRequestBinding( + "tenant", JOB_ID, 1L, "docx", "adapter", "1", "policy", "trace", DIGEST); + byte[] pdf = OfficeConversionTestPdf.onePage(); + OfficeConversionResult result = new OfficeConversionResult(" adapter ", " 1 ", DIGEST, binding, pdf); + byte[] returned = result.pdfBytes(); + returned[0] = 'X'; + assertArrayEquals(pdf, result.pdfBytes()); + assertEquals("adapter", result.adapterId()); + assertEquals("1", result.adapterVersion()); + + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionResult(null, "1", DIGEST, pdf)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionResult(" ", "1", DIGEST, pdf)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionResult("adapter", null, DIGEST, pdf)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionResult("adapter", " ", DIGEST, pdf)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionResult("adapter", "1", null, pdf)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionResult("adapter", "1", "bad", pdf)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionResult( + "adapter", "1", DIGEST, new OfficeConversionRequestBinding( + "tenant", JOB_ID, 1L, "docx", "adapter", "1", "policy", "trace", "0".repeat(64)), pdf)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionResult("adapter", "1", DIGEST, null)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionResult( + "adapter", "1", DIGEST, "not-pdf".getBytes(StandardCharsets.US_ASCII))); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionResult( + "adapter", "1", DIGEST, new byte[0])); + + OfficeConversionException trimmed = new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, " diagnostic "); + assertEquals("diagnostic", trimmed.getMessage()); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionException(null, "message")); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, null)); + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionException( + OfficeConversionFailureCode.INVALID_OUTPUT, " ")); + } + + private static void assertRequestInvalid( + String tenantId, + UUID jobId, + long generation, + String sourceFormat, + String adapterId, + String adapterVersion, + String policyVersion, + String correlationId, + byte[] sourceBytes, + long maxOutputBytes, + int maxPdfPages) { + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequest( + tenantId, jobId, generation, sourceFormat, adapterId, adapterVersion, + policyVersion, correlationId, sourceBytes, maxOutputBytes, maxPdfPages)); + } + + private static void assertBindingInvalid( + String tenantId, + UUID jobId, + long generation, + String sourceFormat, + String adapterId, + String adapterVersion, + String policyVersion, + String correlationId, + String digest, + long maxOutputBytes, + int maxPdfPages) { + assertThrows(IllegalArgumentException.class, () -> new OfficeConversionRequestBinding( + tenantId, jobId, generation, sourceFormat, adapterId, adapterVersion, + policyVersion, correlationId, digest, maxOutputBytes, maxPdfPages)); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java new file mode 100644 index 00000000..c5711bcc --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestInventoryPolicyTest.java @@ -0,0 +1,111 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +/** + * Verifies that the ODF manifest enumerates each ordinary package file exactly once. + */ +class OfficeOdfManifestInventoryPolicyTest { + + private static final String MANIFEST_NAMESPACE = + "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"; + + @Test + void adapterRejectsOrdinaryPackageFileMissingFromManifest() throws IOException { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = odfPackage(false); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF manifest does not match package file inventory", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterAcceptsOrdinaryPackageFileEnumeratedExactlyOnce() throws IOException { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = odfPackage(true); + + countingAdapter(providerCalls).convert(request(source)); + + assertEquals(1, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("41bcd923-780f-4f7a-b142-e00fed5ce05e"), + 13L, + "odt", + "policy-v1", + "trace-odf-manifest-inventory", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] odfPackage(boolean listContentXml) throws IOException { + String fileEntry = listContentXml + ? "" + : ""; + byte[] manifest = ("" + + "" + + fileEntry + + "").getBytes(StandardCharsets.UTF_8); + byte[] content = "".getBytes(StandardCharsets.UTF_8); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + writeStored(zip, "META-INF/manifest.xml", manifest); + writeStored(zip, "content.xml", content); + } + return output.toByteArray(); + } + + private static void writeStored(ZipOutputStream zip, String name, byte[] payload) throws IOException { + CRC32 crc32 = new CRC32(); + crc32.update(payload); + ZipEntry entry = new ZipEntry(name); + entry.setMethod(ZipEntry.STORED); + entry.setSize(payload.length); + entry.setCompressedSize(payload.length); + entry.setCrc(crc32.getValue()); + zip.putNextEntry(entry); + zip.write(payload); + zip.closeEntry(); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestMediaTypePolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestMediaTypePolicyTest.java new file mode 100644 index 00000000..c240b73d --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestMediaTypePolicyTest.java @@ -0,0 +1,177 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * Verifies the OpenDocument manifest root media type against the package mimetype entry. + */ +class OfficeOdfManifestMediaTypePolicyTest { + + private static final byte[] MANIFEST_NAME = + "META-INF/manifest.xml".getBytes(StandardCharsets.UTF_8); + private static final byte[] MIMETYPE_NAME = "mimetype".getBytes(StandardCharsets.UTF_8); + private static final byte[] ODT_MIMETYPE = + "application/vnd.oasis.opendocument.text".getBytes(StandardCharsets.US_ASCII); + private static final String ODS_MIMETYPE = "application/vnd.oasis.opendocument.spreadsheet"; + private static final int LOCAL_HEADER_LENGTH = 30; + private static final int CENTRAL_HEADER_LENGTH = 46; + + @Test + void adapterRejectsManifestRootMediaTypeThatDisagreesWithMimetype() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = odfZipWithRootMediaType(ODS_MIMETYPE); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF manifest root media type does not match mimetype", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterAcceptsManifestRootMediaTypeThatMatchesMimetype() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = odfZipWithRootMediaType(new String(ODT_MIMETYPE, StandardCharsets.US_ASCII)); + + countingAdapter(providerCalls).convert(request(source)); + + assertEquals(1, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("218688bd-713c-4a37-87fc-54b25f73db07"), + 10L, + "odt", + "policy-v1", + "trace-odf-manifest-media-type", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] odfZipWithRootMediaType(String rootMediaType) { + byte[] manifestPayload = ("" + + "" + + "" + + "").getBytes(StandardCharsets.UTF_8); + + int mimetypeLocalOffset = 0; + int mimetypeDataOffset = mimetypeLocalOffset + LOCAL_HEADER_LENGTH + MIMETYPE_NAME.length; + int manifestLocalOffset = mimetypeDataOffset + ODT_MIMETYPE.length; + int manifestDataOffset = manifestLocalOffset + LOCAL_HEADER_LENGTH + MANIFEST_NAME.length; + int centralOffset = manifestDataOffset + manifestPayload.length; + int mimetypeCentralLength = CENTRAL_HEADER_LENGTH + MIMETYPE_NAME.length; + int manifestCentralOffset = centralOffset + mimetypeCentralLength; + int manifestCentralLength = CENTRAL_HEADER_LENGTH + MANIFEST_NAME.length; + int centralLength = mimetypeCentralLength + manifestCentralLength; + int eocdOffset = centralOffset + centralLength; + byte[] bytes = new byte[eocdOffset + 22]; + + writeLocalHeader(bytes, mimetypeLocalOffset, MIMETYPE_NAME, ODT_MIMETYPE.length); + System.arraycopy(ODT_MIMETYPE, 0, bytes, mimetypeDataOffset, ODT_MIMETYPE.length); + writeLocalHeader(bytes, manifestLocalOffset, MANIFEST_NAME, manifestPayload.length); + System.arraycopy(manifestPayload, 0, bytes, manifestDataOffset, manifestPayload.length); + + writeCentralHeader( + bytes, + centralOffset, + MIMETYPE_NAME, + mimetypeLocalOffset, + ODT_MIMETYPE.length + ); + writeCentralHeader( + bytes, + manifestCentralOffset, + MANIFEST_NAME, + manifestLocalOffset, + manifestPayload.length + ); + writeEocd(bytes, eocdOffset, 2, centralLength, centralOffset); + return bytes; + } + + private static void writeLocalHeader(byte[] bytes, int offset, byte[] entryName, int size) { + putUnsignedInt(bytes, offset, 0x04034b50L); + putUnsignedShort(bytes, offset + 4, 20); + putUnsignedShort(bytes, offset + 8, 0); + putUnsignedInt(bytes, offset + 14, 0L); + putUnsignedInt(bytes, offset + 18, size); + putUnsignedInt(bytes, offset + 22, size); + putUnsignedShort(bytes, offset + 26, entryName.length); + putUnsignedShort(bytes, offset + 28, 0); + System.arraycopy(entryName, 0, bytes, offset + LOCAL_HEADER_LENGTH, entryName.length); + } + + private static void writeCentralHeader( + byte[] bytes, + int offset, + byte[] entryName, + int localHeaderOffset, + int size + ) { + putUnsignedInt(bytes, offset, 0x02014b50L); + putUnsignedShort(bytes, offset + 10, 0); + putUnsignedInt(bytes, offset + 16, 0L); + putUnsignedInt(bytes, offset + 20, size); + putUnsignedInt(bytes, offset + 24, size); + putUnsignedShort(bytes, offset + 28, entryName.length); + putUnsignedInt(bytes, offset + 42, localHeaderOffset); + System.arraycopy(entryName, 0, bytes, offset + CENTRAL_HEADER_LENGTH, entryName.length); + } + + private static void writeEocd( + byte[] bytes, + int eocdOffset, + int entryCount, + int centralLength, + int centralOffset + ) { + putUnsignedInt(bytes, eocdOffset, 0x06054b50L); + putUnsignedShort(bytes, eocdOffset + 8, entryCount); + putUnsignedShort(bytes, eocdOffset + 10, entryCount); + putUnsignedInt(bytes, eocdOffset + 12, centralLength); + putUnsignedInt(bytes, eocdOffset + 16, centralOffset); + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflightCoverageTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflightCoverageTest.java new file mode 100644 index 00000000..bbb08094 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestPreflightCoverageTest.java @@ -0,0 +1,375 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.UUID; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +/** Exercises fail-closed ODF manifest framing, extraction, and XML-policy boundaries. */ +class OfficeOdfManifestPreflightCoverageTest { + + private static final String NS = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"; + private static final String MANIFEST_NAME = "META-INF/manifest.xml"; + private static final byte[] ODT_MIMETYPE = + "application/vnd.oasis.opendocument.text".getBytes(StandardCharsets.US_ASCII); + private static final int MAX_MANIFEST_BYTES = 1_048_576; + + @Test + void nonOdfFormatDoesNotEnterManifestParser() { + OfficeConversionRequest request = request("docx", new byte[] {0x01}); + assertDoesNotThrow(() -> OfficeOdfManifestPreflight.requireQualifiedManifest(request)); + } + + @Test + void malformedZipFramingFailsClosedBeforeXmlParsing() { + assertInvalid(new byte[] {0x01}, "source ODF manifest is invalid"); + assertInvalid(new byte[22], "source ODF manifest is invalid"); + + byte[] badCommentLength = eocdOnly(1, 0L); + putUnsignedShort(badCommentLength, 20, 1); + assertInvalid(badCommentLength, "source ODF manifest is invalid"); + + byte[] hugeCentralOffset = eocdOnly(1, 0x8000_0000L); + assertInvalid(hugeCentralOffset, "source ODF manifest is invalid"); + + byte[] missingCentralHeader = eocdOnly(1, 0L); + assertInvalid(missingCentralHeader, "source ODF manifest is invalid"); + + assertInvalid(centralHeaderTooNearEnd(), "source ODF manifest is invalid"); + } + + @Test + void malformedCentralEntryBoundsFailClosed() throws IOException { + byte[] base = storedManifestPackage(validManifest("")); + int central = findSignature(base, 0x02014b50L); + + byte[] oversizedName = base.clone(); + putUnsignedShort(oversizedName, central + 28, 0xffff); + assertInvalid(oversizedName, "source ODF manifest is invalid"); + + byte[] hugeLocalOffset = base.clone(); + putUnsignedInt(hugeLocalOffset, central + 42, 0x8000_0000L); + assertInvalid(hugeLocalOffset, "source ODF manifest is invalid"); + + byte[] outOfBoundsLocalOffset = base.clone(); + putUnsignedInt(outOfBoundsLocalOffset, central + 42, base.length); + assertInvalid(outOfBoundsLocalOffset, "source ODF manifest is invalid"); + + byte[] localMetadataPastSource = base.clone(); + int local = findSignature(localMetadataPastSource, 0x04034b50L); + putUnsignedShort(localMetadataPastSource, local + 28, 0xffff); + assertInvalid(localMetadataPastSource, "source ODF manifest is invalid"); + + byte[] sameLengthManifestNameMismatch = base.clone(); + int nameOffset = central + 46; + sameLengthManifestNameMismatch[nameOffset + MANIFEST_NAME.length() - 1] = (byte) 'L'; + assertInvalid(sameLengthManifestNameMismatch, "source ODF manifest is invalid"); + } + + @Test + void manifestExtractionEnforcesDeclaredBoundsAndCompression() throws IOException { + byte[] base = storedManifestPackage(validManifest("")); + int central = findSignature(base, 0x02014b50L); + long payloadLength = unsignedInt(base, central + 24); + + byte[] tooLarge = base.clone(); + putUnsignedInt(tooLarge, central + 24, MAX_MANIFEST_BYTES + 1L); + assertInvalid(tooLarge, "source ODF manifest exceeds maximum bytes", OfficeConversionFailureCode.POLICY_DENIED); + + byte[] compressedTooLarge = base.clone(); + putUnsignedInt(compressedTooLarge, central + 20, 0x8000_0000L); + assertInvalid(compressedTooLarge, "source ODF manifest is invalid"); + + byte[] dataPastEnd = base.clone(); + putUnsignedInt(dataPastEnd, central + 20, base.length); + putUnsignedInt(dataPastEnd, central + 24, base.length); + assertInvalid(dataPastEnd, "source ODF manifest is invalid"); + + byte[] storedSizeMismatch = base.clone(); + putUnsignedInt(storedSizeMismatch, central + 24, payloadLength + 1L); + assertInvalid(storedSizeMismatch, "source ODF manifest is invalid"); + + byte[] unsupportedMethod = base.clone(); + putUnsignedShort(unsupportedMethod, central + 10, 99); + assertInvalid(unsupportedMethod, "source ODF manifest is invalid"); + } + + @Test + void deflatedManifestRoundTripsAndCorruptionFailsClosed() throws IOException { + byte[] valid = deflatedManifestPackage(validManifest( + "")); + assertDoesNotThrow(() -> OfficeOdfManifestPreflight.requireQualifiedManifest(request("odt", valid))); + + byte[] corrupt = valid.clone(); + int local = findSignature(corrupt, 0x04034b50L); + int central = findSignature(corrupt, 0x02014b50L); + int nameLength = unsignedShort(corrupt, local + 26); + int extraLength = unsignedShort(corrupt, local + 28); + int dataOffset = local + 30 + nameLength + extraLength; + int compressedSize = (int) unsignedInt(corrupt, central + 20); + Arrays.fill(corrupt, dataOffset, dataOffset + compressedSize, (byte) 0x7f); + assertInvalid(corrupt, "source ODF manifest is invalid"); + + byte[] truncated = valid.clone(); + long validCompressedSize = unsignedInt(truncated, central + 20); + putUnsignedInt(truncated, central + 20, validCompressedSize - 1L); + assertInvalid(truncated, "source ODF manifest is invalid"); + + byte[] oversizedExpandedDeclaration = valid.clone(); + long validUncompressedSize = unsignedInt(oversizedExpandedDeclaration, central + 24); + putUnsignedInt(oversizedExpandedDeclaration, central + 24, validUncompressedSize + 1L); + assertInvalid(oversizedExpandedDeclaration, "source ODF manifest is invalid"); + + byte[] trailingCompressedByte = valid.clone(); + putUnsignedInt(trailingCompressedByte, central + 20, validCompressedSize + 1L); + assertInvalid(trailingCompressedByte, "source ODF manifest is invalid"); + + assertInvalid(deflatedManifestPackage(new byte[0]), "source ODF manifest is invalid"); + } + + @Test + void manifestXmlRequiresQualifiedRootAndFilePath() throws IOException { + assertInvalid(storedManifestPackage(("") + .getBytes(StandardCharsets.UTF_8)), "source ODF manifest is invalid"); + assertInvalid(storedManifestPackage(("").getBytes(StandardCharsets.UTF_8)), + "source ODF manifest is invalid"); + assertInvalid(storedManifestPackage(validManifest( + "")), + "source ODF manifest is invalid"); + assertInvalid(storedManifestPackage(validManifest( + "")), + "source ODF manifest is invalid"); + } + + @Test + void nonManifestNamespaceAndMetadataEntriesDoNotBecomeOrdinaryInventory() throws IOException { + String body = "" + + "" + + ""; + byte[] source = packageWithMetadataAndDirectory(validManifest(body)); + + assertDoesNotThrow(() -> OfficeOdfManifestPreflight.requireQualifiedManifest(request("odt", source))); + } + + @Test + void manifestRejectsDuplicateRootAndReservedSelfReferences() throws IOException { + assertInvalid(storedManifestPackage(validManifest( + "" + + "")), + "source ODF manifest is invalid"); + assertInvalid(storedManifestPackage(validManifest( + "")), + "source ODF manifest does not match package file inventory"); + assertInvalid(storedManifestPackage(validManifest( + "")), + "source ODF manifest does not match package file inventory"); + } + + @Test + void mimetypeAndManifestRootMustAppearTogether() throws IOException { + byte[] mimetypeWithoutRoot = packageWithEntries( + validManifest(""), + true, + null, + false + ); + assertInvalid(mimetypeWithoutRoot, "source ODF manifest root entry is missing"); + + byte[] rootWithoutMimetype = storedManifestPackage(validManifest( + "")); + assertInvalid(rootWithoutMimetype, "source ODF mimetype entry is missing for manifest root"); + } + + @Test + void duplicateOrdinaryManifestEntryFailsInventoryCardinality() throws IOException { + String duplicate = "" + + ""; + byte[] source = packageWithEntries(validManifest(duplicate), false, "content.xml", false); + + assertInvalid(source, "source ODF manifest does not match package file inventory"); + } + + @Test + void malformedXmlAndDtdAreRejectedWithoutExternalResolution() throws IOException { + assertInvalid(storedManifestPackage("]>" + + new String(validManifest( + ""), + StandardCharsets.UTF_8); + assertInvalid(storedManifestPackage(dtd.getBytes(StandardCharsets.UTF_8)), + "source ODF manifest is invalid"); + } + + @Test + void packageWithoutManifestEntryFailsClosed() throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + writeStored(zip, "content.xml", "content".getBytes(StandardCharsets.UTF_8)); + } + assertInvalid(output.toByteArray(), "source ODF manifest is invalid"); + } + + private static void assertInvalid(byte[] source, String message) { + assertInvalid(source, message, OfficeConversionFailureCode.MALFORMED_INPUT); + } + + private static void assertInvalid( + byte[] source, + String message, + OfficeConversionFailureCode failureCode + ) { + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> OfficeOdfManifestPreflight.requireQualifiedManifest(request("odt", source)) + ); + assertEquals(failureCode, failure.failureCode()); + assertEquals(message, failure.getMessage()); + } + + private static OfficeConversionRequest request(String format, byte[] source) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("7da8a7e7-3e3b-4d89-9ef5-36fd05955589"), + 17L, + format, + "policy-v1", + "trace-odf-preflight-coverage", + source, + 2_000_000L, + 20 + ); + } + + private static byte[] validManifest(String body) { + return ("" + + "" + + body + + "").getBytes(StandardCharsets.UTF_8); + } + + private static byte[] storedManifestPackage(byte[] manifest) throws IOException { + return packageWithEntries(manifest, false, null, false); + } + + private static byte[] deflatedManifestPackage(byte[] manifest) throws IOException { + return packageWithEntries(manifest, false, null, true); + } + + private static byte[] packageWithEntries( + byte[] manifest, + boolean includeMimetype, + String ordinaryFile, + boolean deflateManifest + ) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + if (includeMimetype) { + writeStored(zip, "mimetype", ODT_MIMETYPE); + } + if (deflateManifest) { + ZipEntry entry = new ZipEntry(MANIFEST_NAME); + entry.setMethod(ZipEntry.DEFLATED); + zip.putNextEntry(entry); + zip.write(manifest); + zip.closeEntry(); + } else { + writeStored(zip, MANIFEST_NAME, manifest); + } + if (ordinaryFile != null) { + writeStored(zip, ordinaryFile, "content".getBytes(StandardCharsets.UTF_8)); + } + } + return output.toByteArray(); + } + + private static byte[] packageWithMetadataAndDirectory(byte[] manifest) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + writeStored(zip, MANIFEST_NAME, manifest); + writeStored(zip, "META-INF/documentsignatures.xml", "signature".getBytes(StandardCharsets.UTF_8)); + writeStored(zip, "Pictures/", new byte[0]); + } + return output.toByteArray(); + } + + private static void writeStored(ZipOutputStream zip, String name, byte[] payload) throws IOException { + CRC32 crc32 = new CRC32(); + crc32.update(payload); + ZipEntry entry = new ZipEntry(name); + entry.setMethod(ZipEntry.STORED); + entry.setSize(payload.length); + entry.setCompressedSize(payload.length); + entry.setCrc(crc32.getValue()); + zip.putNextEntry(entry); + zip.write(payload); + zip.closeEntry(); + } + + private static byte[] eocdOnly(int entryCount, long centralOffset) { + byte[] bytes = new byte[22]; + putUnsignedInt(bytes, 0, 0x06054b50L); + putUnsignedShort(bytes, 8, entryCount); + putUnsignedShort(bytes, 10, entryCount); + putUnsignedInt(bytes, 16, centralOffset); + return bytes; + } + + private static byte[] centralHeaderTooNearEnd() { + byte[] bytes = new byte[80]; + int centralOffset = 50; + int eocdOffset = bytes.length - 22; + putUnsignedInt(bytes, centralOffset, 0x02014b50L); + putUnsignedInt(bytes, eocdOffset, 0x06054b50L); + putUnsignedShort(bytes, eocdOffset + 8, 1); + putUnsignedShort(bytes, eocdOffset + 10, 1); + putUnsignedInt(bytes, eocdOffset + 16, centralOffset); + return bytes; + } + + private static int findSignature(byte[] bytes, long signature) { + for (int offset = 0; offset <= bytes.length - 4; offset++) { + if (unsignedInt(bytes, offset) == signature) { + return offset; + } + } + throw new IllegalStateException("ZIP signature not found"); + } + + private static int unsignedShort(byte[] bytes, int offset) { + return Byte.toUnsignedInt(bytes[offset]) + | (Byte.toUnsignedInt(bytes[offset + 1]) << 8); + } + + private static long unsignedInt(byte[] bytes, int offset) { + return Integer.toUnsignedLong( + Byte.toUnsignedInt(bytes[offset]) + | (Byte.toUnsignedInt(bytes[offset + 1]) << 8) + | (Byte.toUnsignedInt(bytes[offset + 2]) << 16) + | (Byte.toUnsignedInt(bytes[offset + 3]) << 24) + ); + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java new file mode 100644 index 00000000..2051d889 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfManifestVersionPolicyTest.java @@ -0,0 +1,123 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +/** + * Verifies the supported OpenDocument manifest version and minimum manifest content. + */ +class OfficeOdfManifestVersionPolicyTest { + + private static final String MANIFEST_NAMESPACE = + "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"; + + @Test + void adapterRejectsManifestVersionOutsideSupportedOdfVersion() throws IOException { + AtomicInteger providerCalls = new AtomicInteger(); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(odfPackage("1.3"))) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF manifest version is not allowed", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsManifestWithoutAnyFileEntry() throws IOException { + AtomicInteger providerCalls = new AtomicInteger(); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(odfPackageWithoutFileEntry())) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF manifest has no file entries", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterAcceptsManifestVersionFourteen() throws IOException { + AtomicInteger providerCalls = new AtomicInteger(); + + countingAdapter(providerCalls).convert(request(odfPackage("1.4"))); + + assertEquals(1, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("931b7157-7840-4435-b65b-0d01fae5b141"), + 14L, + "odt", + "policy-v1", + "trace-odf-manifest-version", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] odfPackage(String version) throws IOException { + return packageWithManifest(("" + + "" + + "" + + "").getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] odfPackageWithoutFileEntry() throws IOException { + return packageWithManifest(("" + + "").getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] packageWithManifest(byte[] manifest) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + CRC32 crc32 = new CRC32(); + crc32.update(manifest); + ZipEntry entry = new ZipEntry("META-INF/manifest.xml"); + entry.setMethod(ZipEntry.STORED); + entry.setSize(manifest.length); + entry.setCompressedSize(manifest.length); + entry.setCrc(crc32.getValue()); + zip.putNextEntry(entry); + zip.write(manifest); + zip.closeEntry(); + } + return output.toByteArray(); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java new file mode 100644 index 00000000..9ff32563 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMetaInfPolicyTest.java @@ -0,0 +1,119 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +/** + * Enforces the OpenDocument META-INF package namespace before provider invocation. + */ +class OfficeOdfMetaInfPolicyTest { + + private static final byte[] MANIFEST_XML = ( + "" + + "" + + "" + + "" + ).getBytes(StandardCharsets.UTF_8); + private static final byte[] SIGNATURE_XML = ( + "" + + "" + ).getBytes(StandardCharsets.UTF_8); + + @Test + void adapterRejectsUnexpectedMetaInfEntryBeforeProviderInvocation() throws IOException { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = odfPackage("META-INF/manifest.xml", "META-INF/evil.xml"); + + OfficeConversionException failure = org.junit.jupiter.api.Assertions.assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF META-INF entry is not allowed", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterAllowsSignatureNamedMetaInfEntry() throws IOException { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = odfPackage( + "META-INF/manifest.xml", + "META-INF/documentsignatures.xml" + ); + + countingAdapter(providerCalls).convert(request(source)); + + assertEquals(1, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("679a23eb-e2b2-4760-8d0f-df50e60c7158"), + 12L, + "odt", + "policy-v1", + "trace-odf-meta-inf-policy", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] odfPackage(String... entryNames) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + for (String entryName : entryNames) { + byte[] payload = payloadFor(entryName); + CRC32 crc32 = new CRC32(); + crc32.update(payload); + ZipEntry entry = new ZipEntry(entryName); + entry.setMethod(ZipEntry.STORED); + entry.setSize(payload.length); + entry.setCompressedSize(payload.length); + entry.setCrc(crc32.getValue()); + zip.putNextEntry(entry); + zip.write(payload); + zip.closeEntry(); + } + } + return output.toByteArray(); + } + + private static byte[] payloadFor(String entryName) { + if ("META-INF/manifest.xml".equals(entryName)) { + return MANIFEST_XML; + } + if (entryName.contains("signatures")) { + return SIGNATURE_XML; + } + return new byte[0]; + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java new file mode 100644 index 00000000..bc5be1e8 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfMimetypePolicyTest.java @@ -0,0 +1,236 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * Enforces deterministic placement, storage, and media-type rules for an optional OpenDocument mimetype entry. + */ +class OfficeOdfMimetypePolicyTest { + + private static final byte[] MANIFEST_NAME = + "META-INF/manifest.xml".getBytes(StandardCharsets.UTF_8); + private static final byte[] MIMETYPE_NAME = "mimetype".getBytes(StandardCharsets.UTF_8); + private static final byte[] ODT_MIMETYPE = + "application/vnd.oasis.opendocument.text".getBytes(StandardCharsets.US_ASCII); + private static final byte[] ODS_MIMETYPE = + "application/vnd.oasis.opendocument.spreadsheet".getBytes(StandardCharsets.US_ASCII); + private static final int LOCAL_HEADER_LENGTH = 30; + private static final int CENTRAL_HEADER_LENGTH = 46; + + @Test + void adapterRejectsOdfMimetypeEntryWhenItIsNotFirst() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = odfZipWithMimetypeSecond(); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF mimetype entry must be first", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsOdfMimetypeEntryWhenCompressed() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = odfZipWithFirstMimetype(8, 0, ODT_MIMETYPE); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF mimetype entry must be stored without compression", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsOdfMimetypeEntryWithLocalExtraField() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = odfZipWithFirstMimetype(0, 1, ODT_MIMETYPE); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF mimetype entry must not use a local extra field", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsOdfMimetypeEntryThatDoesNotMatchDeclaredFormat() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = odfZipWithFirstMimetype(0, 0, ODS_MIMETYPE); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF mimetype does not match declared format", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("218688bd-713c-4a37-87fc-54b25f73db07"), + 10L, + "odt", + "policy-v1", + "trace-odf-mimetype-policy", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] odfZipWithMimetypeSecond() { + int manifestLocalOffset = 0; + int mimetypeLocalOffset = LOCAL_HEADER_LENGTH + MANIFEST_NAME.length; + int centralOffset = mimetypeLocalOffset + LOCAL_HEADER_LENGTH + MIMETYPE_NAME.length; + int firstCentralLength = CENTRAL_HEADER_LENGTH + MANIFEST_NAME.length; + int secondCentralOffset = centralOffset + firstCentralLength; + int secondCentralLength = CENTRAL_HEADER_LENGTH + MIMETYPE_NAME.length; + int centralLength = firstCentralLength + secondCentralLength; + int eocdOffset = centralOffset + centralLength; + byte[] bytes = new byte[eocdOffset + 22]; + + writeLocalHeader(bytes, manifestLocalOffset, MANIFEST_NAME, 0, 0, 0); + writeLocalHeader(bytes, mimetypeLocalOffset, MIMETYPE_NAME, 0, 0, 0); + writeCentralHeader(bytes, centralOffset, MANIFEST_NAME, manifestLocalOffset, 0, 0, 0); + writeCentralHeader(bytes, secondCentralOffset, MIMETYPE_NAME, mimetypeLocalOffset, 0, 0, 0); + + writeEocd(bytes, eocdOffset, 2, centralLength, centralOffset); + return bytes; + } + + private static byte[] odfZipWithFirstMimetype( + int compressionMethod, + int localExtraFieldLength, + byte[] mimetypePayload + ) { + int mimetypeLocalOffset = 0; + int mimetypeDataOffset = LOCAL_HEADER_LENGTH + MIMETYPE_NAME.length + localExtraFieldLength; + int manifestLocalOffset = mimetypeDataOffset + mimetypePayload.length; + int centralOffset = manifestLocalOffset + LOCAL_HEADER_LENGTH + MANIFEST_NAME.length; + int mimetypeCentralLength = CENTRAL_HEADER_LENGTH + MIMETYPE_NAME.length; + int manifestCentralOffset = centralOffset + mimetypeCentralLength; + int manifestCentralLength = CENTRAL_HEADER_LENGTH + MANIFEST_NAME.length; + int centralLength = mimetypeCentralLength + manifestCentralLength; + int eocdOffset = centralOffset + centralLength; + byte[] bytes = new byte[eocdOffset + 22]; + + writeLocalHeader( + bytes, + mimetypeLocalOffset, + MIMETYPE_NAME, + compressionMethod, + mimetypePayload.length, + localExtraFieldLength + ); + System.arraycopy(mimetypePayload, 0, bytes, mimetypeDataOffset, mimetypePayload.length); + writeLocalHeader(bytes, manifestLocalOffset, MANIFEST_NAME, 0, 0, 0); + writeCentralHeader( + bytes, + centralOffset, + MIMETYPE_NAME, + mimetypeLocalOffset, + compressionMethod, + mimetypePayload.length, + mimetypePayload.length + ); + writeCentralHeader(bytes, manifestCentralOffset, MANIFEST_NAME, manifestLocalOffset, 0, 0, 0); + + writeEocd(bytes, eocdOffset, 2, centralLength, centralOffset); + return bytes; + } + + private static void writeLocalHeader( + byte[] bytes, + int offset, + byte[] entryName, + int compressionMethod, + int size, + int extraFieldLength + ) { + putUnsignedInt(bytes, offset, 0x04034b50L); + putUnsignedShort(bytes, offset + 4, 20); + putUnsignedShort(bytes, offset + 8, compressionMethod); + putUnsignedInt(bytes, offset + 18, size); + putUnsignedInt(bytes, offset + 22, size); + putUnsignedShort(bytes, offset + 26, entryName.length); + putUnsignedShort(bytes, offset + 28, extraFieldLength); + System.arraycopy(entryName, 0, bytes, offset + LOCAL_HEADER_LENGTH, entryName.length); + } + + private static void writeCentralHeader( + byte[] bytes, + int offset, + byte[] entryName, + int localHeaderOffset, + int compressionMethod, + int compressedSize, + int uncompressedSize + ) { + putUnsignedInt(bytes, offset, 0x02014b50L); + putUnsignedShort(bytes, offset + 10, compressionMethod); + putUnsignedInt(bytes, offset + 20, compressedSize); + putUnsignedInt(bytes, offset + 24, uncompressedSize); + putUnsignedShort(bytes, offset + 28, entryName.length); + putUnsignedInt(bytes, offset + 42, localHeaderOffset); + System.arraycopy(entryName, 0, bytes, offset + CENTRAL_HEADER_LENGTH, entryName.length); + } + + private static void writeEocd( + byte[] bytes, + int eocdOffset, + int entryCount, + int centralLength, + int centralOffset + ) { + putUnsignedInt(bytes, eocdOffset, 0x06054b50L); + putUnsignedShort(bytes, eocdOffset + 8, entryCount); + putUnsignedShort(bytes, eocdOffset + 10, entryCount); + putUnsignedInt(bytes, eocdOffset + 12, centralLength); + putUnsignedInt(bytes, eocdOffset + 16, centralOffset); + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfPackagePolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfPackagePolicyTest.java new file mode 100644 index 00000000..24d0b6ae --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOdfPackagePolicyTest.java @@ -0,0 +1,97 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * Enforces OpenDocument package structure before invoking an Office provider. + */ +class OfficeOdfPackagePolicyTest { + + private static final byte[] ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8); + private static final int LOCAL_HEADER_LENGTH = 30; + private static final int CENTRAL_HEADER_LENGTH = 46; + private static final int CENTRAL_OFFSET = LOCAL_HEADER_LENGTH + ENTRY_NAME.length; + private static final int CENTRAL_RECORD_LENGTH = CENTRAL_HEADER_LENGTH + ENTRY_NAME.length; + private static final int EOCD_OFFSET = CENTRAL_OFFSET + CENTRAL_RECORD_LENGTH; + + @Test + void adapterRejectsOdfPackageWithoutManifestBeforeProviderInvocation() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryZipWithoutOdfManifest(); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF package manifest is missing", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("218688bd-713c-4a37-87fc-54b25f73db07"), + 10L, + "odt", + "policy-v1", + "trace-odf-package-policy", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] oneEntryZipWithoutOdfManifest() { + byte[] bytes = new byte[EOCD_OFFSET + 22]; + putUnsignedInt(bytes, 0, 0x04034b50L); + putUnsignedShort(bytes, 4, 20); + putUnsignedShort(bytes, 26, ENTRY_NAME.length); + System.arraycopy(ENTRY_NAME, 0, bytes, LOCAL_HEADER_LENGTH, ENTRY_NAME.length); + + putUnsignedInt(bytes, CENTRAL_OFFSET, 0x02014b50L); + putUnsignedShort(bytes, CENTRAL_OFFSET + 28, ENTRY_NAME.length); + putUnsignedInt(bytes, CENTRAL_OFFSET + 42, 0L); + System.arraycopy(ENTRY_NAME, 0, bytes, CENTRAL_OFFSET + CENTRAL_HEADER_LENGTH, ENTRY_NAME.length); + + putUnsignedInt(bytes, EOCD_OFFSET, 0x06054b50L); + putUnsignedShort(bytes, EOCD_OFFSET + 8, 1); + putUnsignedShort(bytes, EOCD_OFFSET + 10, 1); + putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_RECORD_LENGTH); + putUnsignedInt(bytes, EOCD_OFFSET + 16, CENTRAL_OFFSET); + return bytes; + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOoxmlContentTypePolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOoxmlContentTypePolicyTest.java new file mode 100644 index 00000000..d8732f7f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOoxmlContentTypePolicyTest.java @@ -0,0 +1,123 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +/** + * Active-content regressions for OOXML package content-type declarations. + */ +class OfficeOoxmlContentTypePolicyTest { + + private static final String CONTENT_TYPE_NAMESPACE = + "http://schemas.openxmlformats.org/package/2006/content-types"; + private static final String VBA_PROJECT_CONTENT_TYPE = + "application/vnd.ms-office.vbaProject"; + + @Test + void adapterRejectsVbaContentTypeEvenWhenPartIsRenamed() throws Exception { + AtomicInteger providerCalls = new AtomicInteger(); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(docxWithContentTypes( + "" + ))) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source Office package contains prohibited active content", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterAllowsBenignContentTypeDeclarations() throws Exception { + AtomicInteger providerCalls = new AtomicInteger(); + + assertDoesNotThrow(() -> countingAdapter(providerCalls).convert(request(docxWithContentTypes( + "" + )))); + + assertEquals(1, providerCalls.get()); + } + + @Test + void adapterRejectsMalformedContentTypesPartBeforeProvider() throws Exception { + AtomicInteger providerCalls = new AtomicInteger(); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(docxWithRawContentTypes( + (" { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("aa0ee237-7dbb-4765-9dfb-fad4ad5759a0"), + 19L, + "docx", + "policy-v1", + "trace-ooxml-content-type-policy", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] docxWithContentTypes(String declarations) throws IOException { + String contentTypes = "" + + "" + + declarations + + ""; + return docxWithRawContentTypes(contentTypes.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] docxWithRawContentTypes(byte[] contentTypes) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry("[Content_Types].xml")); + zip.write(contentTypes); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("word/customPayload.bin")); + zip.write(new byte[] {1, 2, 3}); + zip.closeEntry(); + } + return output.toByteArray(); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOoxmlExternalRelationshipPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOoxmlExternalRelationshipPolicyTest.java new file mode 100644 index 00000000..46811a35 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOoxmlExternalRelationshipPolicyTest.java @@ -0,0 +1,207 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +/** + * External-relationship regressions for OOXML source packages. + */ +class OfficeOoxmlExternalRelationshipPolicyTest { + + private static final String RELATIONSHIP_NAMESPACE = + "http://schemas.openxmlformats.org/package/2006/relationships"; + + @Test + void adapterRejectsExternalRelationshipBeforeProvider() throws Exception { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = docxWithRelationship( + "" + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("source Office package contains an external relationship", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsRootExternalRelationshipBeforeProvider() throws Exception { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = docxWithRelationshipAtPath( + "_rels/.rels", + "" + ); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("source Office package contains an external relationship", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterAllowsInternalRelationshipToReachProvider() throws Exception { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = docxWithRelationship( + "" + ); + + countingAdapter(providerCalls).convert(request(source)); + + assertEquals(1, providerCalls.get()); + } + + @Test + void adapterAllowsStoredInternalRelationshipToReachProvider() throws Exception { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = docxWithStoredRelationship( + "" + ); + + countingAdapter(providerCalls).convert(request(source)); + + assertEquals(1, providerCalls.get()); + } + + @Test + void adapterRejectsMalformedRelationshipPartBeforeProvider() throws Exception { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = docxWithRelationshipXml( + " countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source OOXML relationship part is invalid", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsOversizedRelationshipPartBeforeProvider() throws Exception { + AtomicInteger providerCalls = new AtomicInteger(); + String oversizedXml = "" + + " ".repeat(1_048_577) + + ""; + byte[] source = docxWithRelationshipXml(oversizedXml); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("source OOXML relationship part exceeds maximum bytes", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("8d0fc0f7-971f-4fde-9188-b71a8bfbbdb4"), + 17L, + "docx", + "policy-v1", + "trace-external-relationship-policy", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] docxWithRelationship(String relationshipElement) throws IOException { + return docxWithRelationshipAtPath("word/_rels/document.xml.rels", relationshipElement); + } + + private static byte[] docxWithRelationshipAtPath( + String path, + String relationshipElement + ) throws IOException { + return docxWithRelationshipXmlAtPath( + path, + "" + + "" + + relationshipElement + + "" + ); + } + + private static byte[] docxWithRelationshipXml(String xml) throws IOException { + return docxWithRelationshipXmlAtPath("word/_rels/document.xml.rels", xml); + } + + private static byte[] docxWithRelationshipXmlAtPath(String path, String xml) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry(path)); + zip.write(xml.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return output.toByteArray(); + } + + private static byte[] docxWithStoredRelationship(String relationshipElement) throws IOException { + String xml = "" + + "" + + relationshipElement + + ""; + byte[] bytes = xml.getBytes(StandardCharsets.UTF_8); + CRC32 crc32 = new CRC32(); + crc32.update(bytes); + ZipEntry entry = new ZipEntry("word/_rels/document.xml.rels"); + entry.setMethod(ZipEntry.STORED); + entry.setSize(bytes.length); + entry.setCompressedSize(bytes.length); + entry.setCrc(crc32.getValue()); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(entry); + zip.write(bytes); + zip.closeEntry(); + } + return output.toByteArray(); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeOoxmlRelationshipPreflightCoverageTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeOoxmlRelationshipPreflightCoverageTest.java new file mode 100644 index 00000000..2e62fe07 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeOoxmlRelationshipPreflightCoverageTest.java @@ -0,0 +1,146 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +/** + * Boundary coverage for malformed OOXML relationship container metadata. + */ +class OfficeOoxmlRelationshipPreflightCoverageTest { + + private static final String RELATIONSHIP_NAMESPACE = + "http://schemas.openxmlformats.org/package/2006/relationships"; + + @Test + void rejectsDeclaredRelationshipExpansionMismatch() throws Exception { + byte[] source = relationshipZip(); + int centralDirectoryOffset = centralDirectoryOffset(source); + long declaredSize = unsignedInt(source, centralDirectoryOffset + 24); + putUnsignedInt(source, centralDirectoryOffset + 24, declaredSize + 1); + + assertMalformed(source); + } + + @Test + void rejectsCorruptedRelationshipDeflateStream() throws Exception { + byte[] source = relationshipZip(); + int centralDirectoryOffset = centralDirectoryOffset(source); + int localHeaderOffset = (int) unsignedInt(source, centralDirectoryOffset + 42); + int localNameLength = unsignedShort(source, localHeaderOffset + 26); + int localExtraLength = unsignedShort(source, localHeaderOffset + 28); + int dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength; + + source[dataOffset] = (byte) ((source[dataOffset] & 0xF9) | 0x06); + + assertMalformed(source); + } + + @Test + void rejectsMissingEndOfCentralDirectory() { + assertMalformed(new byte[22]); + } + + @Test + void rejectsInconsistentEndOfCentralDirectoryCommentLength() { + byte[] source = new byte[22]; + source[0] = 0x50; + source[1] = 0x4b; + source[2] = 0x05; + source[3] = 0x06; + source[20] = 0x01; + + assertMalformed(source); + } + + private static void assertMalformed(byte[] source) { + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> OfficeOoxmlRelationshipPreflight.requireNoExternalRelationships(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source OOXML relationship part is invalid", failure.getMessage()); + } + + private static OfficeConversionRequest request(byte[] source) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("8d0fc0f7-971f-4fde-9188-b71a8bfbbdb4"), + 17L, + "docx", + "policy-v1", + "trace-ooxml-relationship-coverage", + source, + 1_000_000L, + 10 + ); + } + + private static byte[] relationshipZip() throws IOException { + String xml = "" + + "" + + "" + + ""; + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry("word/_rels/document.xml.rels")); + zip.write(xml.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return output.toByteArray(); + } + + private static int centralDirectoryOffset(byte[] source) { + int eocdOffset = findSignature(source, new byte[] {0x50, 0x4b, 0x05, 0x06}); + return (int) unsignedInt(source, eocdOffset + 16); + } + + private static int findSignature(byte[] source, byte[] signature) { + for (int offset = source.length - signature.length; offset >= 0; offset--) { + boolean matches = true; + for (int index = 0; index < signature.length; index++) { + if (source[offset + index] != signature[index]) { + matches = false; + break; + } + } + if (matches) { + return offset; + } + } + throw new IllegalStateException("ZIP signature not found in fixture"); + } + + private static int unsignedShort(byte[] source, int offset) { + return Byte.toUnsignedInt(source[offset]) + | (Byte.toUnsignedInt(source[offset + 1]) << 8); + } + + private static long unsignedInt(byte[] source, int offset) { + return Integer.toUnsignedLong( + Byte.toUnsignedInt(source[offset]) + | (Byte.toUnsignedInt(source[offset + 1]) << 8) + | (Byte.toUnsignedInt(source[offset + 2]) << 16) + | (Byte.toUnsignedInt(source[offset + 3]) << 24) + ); + } + + private static void putUnsignedInt(byte[] source, int offset, long value) { + source[offset] = (byte) value; + source[offset + 1] = (byte) (value >>> 8); + source[offset + 2] = (byte) (value >>> 16); + source[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java new file mode 100644 index 00000000..b472dc3d --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceCentralDirectoryPolicyTest.java @@ -0,0 +1,226 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * Local/central-directory metadata regressions for ZIP-family Office candidates. + * + *

The pre-provider boundary must not trust the EOCD entry count or only one of + * the duplicated local/central metadata authorities. These tests do not decompress + * entry data; they require matching local/central metadata with a safe relative + * entry name and fail closed when either record advertises ZIP encryption, the + * duplicated compression-method metadata disagrees, the agreed compression + * method falls outside the current Stored/Deflate qualification boundary, a Stored + * entry reports different compressed/uncompressed sizes, or a central-directory + * entry claims compressed bytes outside the local data region.

+ */ +class OfficeSourceCentralDirectoryPolicyTest { + + private static final byte[] SAFE_ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8); + private static final int LOCAL_HEADER_OFFSET = 0; + private static final int LOCAL_HEADER_FIXED_LENGTH = 30; + private static final int CENTRAL_DIRECTORY_OFFSET = LOCAL_HEADER_FIXED_LENGTH + SAFE_ENTRY_NAME.length; + private static final int CENTRAL_DIRECTORY_FIXED_LENGTH = 46; + private static final int CENTRAL_DIRECTORY_RECORD_LENGTH = + CENTRAL_DIRECTORY_FIXED_LENGTH + SAFE_ENTRY_NAME.length; + private static final int EOCD_OFFSET = CENTRAL_DIRECTORY_OFFSET + CENTRAL_DIRECTORY_RECORD_LENGTH; + private static final int EOCD_LENGTH = 22; + + @Test + void adapterRejectsEncryptedCentralDirectoryEntryBeforeProviderInvocation() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryZip(1, 1); + putUnsignedShort(source, CENTRAL_DIRECTORY_OFFSET + 8, 0x0001); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.PASSWORD_PROTECTED, failure.failureCode()); + assertEquals("source ZIP entry is encrypted", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsEncryptedLocalHeaderWhenCentralDirectoryLooksUnencrypted() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryZip(1, 1); + putUnsignedShort(source, LOCAL_HEADER_OFFSET + 6, 0x0001); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.PASSWORD_PROTECTED, failure.failureCode()); + assertEquals("source ZIP entry is encrypted", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsCompressionMethodMismatchBetweenLocalAndCentralRecords() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryZip(1, 1); + putUnsignedShort(source, LOCAL_HEADER_OFFSET + 8, 8); + putUnsignedShort(source, CENTRAL_DIRECTORY_OFFSET + 10, 0); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP local header does not match central directory", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsUnsupportedCompressionMethodBeforeProviderInvocation() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryZip(1, 1); + putUnsignedShort(source, LOCAL_HEADER_OFFSET + 8, 12); + putUnsignedShort(source, CENTRAL_DIRECTORY_OFFSET + 10, 12); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("source ZIP compression method is not allowed", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsStoredEntryWithDifferentCompressedAndUncompressedSizes() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryZip(1, 1); + putUnsignedInt(source, CENTRAL_DIRECTORY_OFFSET + 20, 0L); + putUnsignedInt(source, CENTRAL_DIRECTORY_OFFSET + 24, 1L); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP stored entry sizes are inconsistent", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsCompressedSizeThatExtendsBeyondLocalDataRegion() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryZip(1, 1); + putUnsignedInt(source, LOCAL_HEADER_OFFSET + 18, 1L); + putUnsignedInt(source, LOCAL_HEADER_OFFSET + 22, 1L); + putUnsignedInt(source, CENTRAL_DIRECTORY_OFFSET + 20, 1L); + putUnsignedInt(source, CENTRAL_DIRECTORY_OFFSET + 24, 1L); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP entry data exceeds local data region", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsEocdCountThatExceedsPresentCentralRecords() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryZip(2, 2); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP central directory is invalid", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("218688bd-713c-4a37-87fc-54b25f73db07"), + 10L, + "docx", + "policy-v1", + "trace-central-directory-policy", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] oneEntryZip(int entriesOnDisk, int totalEntries) { + byte[] bytes = new byte[EOCD_OFFSET + EOCD_LENGTH]; + putSignature(bytes, LOCAL_HEADER_OFFSET, 0x04034b50L); + putUnsignedShort(bytes, LOCAL_HEADER_OFFSET + 4, 20); + putUnsignedShort(bytes, LOCAL_HEADER_OFFSET + 26, SAFE_ENTRY_NAME.length); + System.arraycopy( + SAFE_ENTRY_NAME, + 0, + bytes, + LOCAL_HEADER_OFFSET + LOCAL_HEADER_FIXED_LENGTH, + SAFE_ENTRY_NAME.length + ); + + putSignature(bytes, CENTRAL_DIRECTORY_OFFSET, 0x02014b50L); + putUnsignedShort(bytes, CENTRAL_DIRECTORY_OFFSET + 28, SAFE_ENTRY_NAME.length); + putUnsignedInt(bytes, CENTRAL_DIRECTORY_OFFSET + 42, LOCAL_HEADER_OFFSET); + System.arraycopy( + SAFE_ENTRY_NAME, + 0, + bytes, + CENTRAL_DIRECTORY_OFFSET + CENTRAL_DIRECTORY_FIXED_LENGTH, + SAFE_ENTRY_NAME.length + ); + putSignature(bytes, EOCD_OFFSET, 0x06054b50L); + putUnsignedShort(bytes, EOCD_OFFSET + 8, entriesOnDisk); + putUnsignedShort(bytes, EOCD_OFFSET + 10, totalEntries); + putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_DIRECTORY_RECORD_LENGTH); + putUnsignedInt(bytes, EOCD_OFFSET + 16, CENTRAL_DIRECTORY_OFFSET); + return bytes; + } + + private static void putSignature(byte[] bytes, int offset, long value) { + putUnsignedInt(bytes, offset, value); + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerBoundaryCoverageTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerBoundaryCoverageTest.java new file mode 100644 index 00000000..0f63c1f2 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerBoundaryCoverageTest.java @@ -0,0 +1,254 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** Exercises duplicated ZIP metadata and path-policy branches at the Office source boundary. */ +class OfficeSourceContainerBoundaryCoverageTest { + + private static final int LOCAL_FIXED = 30; + private static final int CENTRAL_FIXED = 46; + private static final int EOCD_LENGTH = 22; + + @Test + void centralDirectoryRejectsEveryUnsupportedOffsetAndZip64Authority() { + byte[] compressedSentinel = oneEntryZip("content.xml"); + putUnsignedInt(compressedSentinel, centralOffset(compressedSentinel) + 20, 0xffff_ffffL); + assertMalformed(compressedSentinel); + + byte[] uncompressedSentinel = oneEntryZip("content.xml"); + putUnsignedInt(uncompressedSentinel, centralOffset(uncompressedSentinel) + 24, 0xffff_ffffL); + assertMalformed(uncompressedSentinel); + + byte[] diskSentinel = oneEntryZip("content.xml"); + putUnsignedShort(diskSentinel, centralOffset(diskSentinel) + 34, 0xffff); + assertMalformed(diskSentinel); + + byte[] otherDisk = oneEntryZip("content.xml"); + putUnsignedShort(otherDisk, centralOffset(otherDisk) + 34, 1); + assertMalformed(otherDisk); + + byte[] localOffsetSentinel = oneEntryZip("content.xml"); + putUnsignedInt(localOffsetSentinel, centralOffset(localOffsetSentinel) + 42, 0xffff_ffffL); + assertMalformed(localOffsetSentinel); + + byte[] localInsideCentralDirectory = oneEntryZip("content.xml"); + putUnsignedInt( + localInsideCentralDirectory, + centralOffset(localInsideCentralDirectory) + 42, + centralOffset(localInsideCentralDirectory) + ); + assertMalformed(localInsideCentralDirectory); + + byte[] wrongReferencedSignature = oneEntryZip("content.xml"); + putUnsignedInt(wrongReferencedSignature, centralOffset(wrongReferencedSignature) + 42, 1L); + assertMalformed(wrongReferencedSignature); + } + + @Test + void validEmptyDeflatedEntryExercisesNonStoredContainerPath() { + byte[] source = oneEntryZip("content.xml"); + putUnsignedShort(source, 8, 8); + putUnsignedShort(source, centralOffset(source) + 10, 8); + + assertDoesNotThrow(() -> OfficeSourceContainerPreflight.requireQualifiedContainer(request("docx", source))); + } + + @Test + void centralRecordCannotAdvertiseBytesBeyondItsDeclaredDirectory() { + byte[] source = oneEntryZip("content.xml"); + putUnsignedShort(source, centralOffset(source) + 28, "content.xml".length() + 1); + + assertMalformed(source); + } + + @Test + void referencedLocalHeaderMustFitBeforeCentralDirectory() { + byte[] source = oneEntryZip("content.xml"); + int offset = centralOffset(source) - 20; + putUnsignedInt(source, offset, 0x04034b50L); + putUnsignedInt(source, centralOffset(source) + 42, offset); + + assertMalformed(source); + } + + @Test + void localHeaderMetadataMustMatchNameLengthAndRemainBounded() { + byte[] nameMismatch = oneEntryZip("content.xml"); + putUnsignedShort(nameMismatch, 26, "content.xml".length() - 1); + assertMalformed(nameMismatch); + + byte[] metadataPastCentral = oneEntryZip("content.xml"); + putUnsignedShort(metadataPastCentral, 28, 1); + assertMalformed(metadataPastCentral); + } + + @Test + void localAndCentralDescriptorFlagsMustAgree() { + byte[] source = oneEntryZip("content.xml"); + putUnsignedShort(source, centralOffset(source) + 8, 0x0008); + + assertMalformed(source); + } + + @Test + void nonDescriptorCrcAndSizesRemainDuplicatedAuthorities() { + byte[] crcMismatch = oneEntryZip("content.xml"); + putUnsignedInt(crcMismatch, centralOffset(crcMismatch) + 16, 1L); + assertMalformed(crcMismatch); + + byte[] compressedMismatch = oneEntryZip("content.xml"); + putUnsignedShort(compressedMismatch, 8, 8); + putUnsignedShort(compressedMismatch, centralOffset(compressedMismatch) + 10, 8); + putUnsignedInt(compressedMismatch, centralOffset(compressedMismatch) + 20, 1L); + assertMalformed(compressedMismatch); + + byte[] uncompressedMismatch = oneEntryZip("content.xml"); + putUnsignedShort(uncompressedMismatch, 8, 8); + putUnsignedShort(uncompressedMismatch, centralOffset(uncompressedMismatch) + 10, 8); + putUnsignedInt(uncompressedMismatch, centralOffset(uncompressedMismatch) + 24, 1L); + assertMalformed(uncompressedMismatch); + } + + @Test + void localAndCentralEntryNamesMustBeByteEqual() { + byte[] source = oneEntryZip("content.xml"); + source[LOCAL_FIXED] = (byte) 'x'; + + assertMalformed(source); + } + + @Test + void emptyRawEntryNameIsNotAQualifiedRelativePath() { + byte[] source = oneEntryZip("content.xml"); + putUnsignedShort(source, 26, 0); + putUnsignedShort(source, centralOffset(source) + 28, 0); + + assertPolicyDenied(source); + } + + @Test + void pathPolicyRejectsSlashBackslashDriveNulAndParentVariants() { + assertPolicyDenied(oneEntryZip("/absolute")); + assertPolicyDenied(oneEntryZip("\\absolute")); + assertPolicyDenied(oneEntryZip("C:drive")); + assertPolicyDenied(oneEntryZip("z:drive")); + assertPolicyDenied(oneEntryZip("a\\b")); + assertPolicyDenied(oneEntryZip("a\u0000b")); + assertPolicyDenied(oneEntryZip("a/../b")); + assertPolicyDenied(oneEntryZip("a/..")); + } + + @Test + void odfUnknownMetaInfEntryExercisesShortFragmentAndNameComparisons() { + byte[] source = oneEntryZip("META-INF/x"); + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> OfficeSourceContainerPreflight.requireQualifiedContainer(request("odt", source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ODF META-INF entry is not allowed", failure.getMessage()); + } + + @Test + void unsupportedFormatAndCompoundSignaturePathsRemainExplicit() { + byte[] compound = new byte[] { + (byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0, + (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1 + }; + assertDoesNotThrow(() -> OfficeSourceContainerPreflight.requireQualifiedContainer(request("doc", compound))); + + OfficeConversionException unsupported = assertThrows( + OfficeConversionException.class, + () -> OfficeSourceContainerPreflight.requireQualifiedContainer(request("rtf", compound)) + ); + assertEquals(OfficeConversionFailureCode.UNSUPPORTED_FORMAT, unsupported.failureCode()); + } + + private static void assertMalformed(byte[] source) { + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> OfficeSourceContainerPreflight.requireQualifiedContainer(request("docx", source)) + ); + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + } + + private static void assertPolicyDenied(byte[] source) { + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> OfficeSourceContainerPreflight.requireQualifiedContainer(request("docx", source)) + ); + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + } + + private static OfficeConversionRequest request(String format, byte[] source) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("17980b56-305c-494c-94fe-0bbc7826ba65"), + 19L, + format, + "policy-v1", + "trace-container-coverage", + source, + 1_000_000L, + 10 + ); + } + + private static byte[] oneEntryZip(String entryName) { + byte[] name = entryName.getBytes(StandardCharsets.ISO_8859_1); + int centralOffset = LOCAL_FIXED + name.length; + int centralRecordLength = CENTRAL_FIXED + name.length; + int eocdOffset = centralOffset + centralRecordLength; + byte[] bytes = new byte[eocdOffset + EOCD_LENGTH]; + + putUnsignedInt(bytes, 0, 0x04034b50L); + putUnsignedShort(bytes, 4, 20); + putUnsignedShort(bytes, 26, name.length); + System.arraycopy(name, 0, bytes, LOCAL_FIXED, name.length); + + putUnsignedInt(bytes, centralOffset, 0x02014b50L); + putUnsignedShort(bytes, centralOffset + 28, name.length); + putUnsignedInt(bytes, centralOffset + 42, 0L); + System.arraycopy(name, 0, bytes, centralOffset + CENTRAL_FIXED, name.length); + + putUnsignedInt(bytes, eocdOffset, 0x06054b50L); + putUnsignedShort(bytes, eocdOffset + 8, 1); + putUnsignedShort(bytes, eocdOffset + 10, 1); + putUnsignedInt(bytes, eocdOffset + 12, centralRecordLength); + putUnsignedInt(bytes, eocdOffset + 16, centralOffset); + return bytes; + } + + private static int centralOffset(byte[] source) { + return (int) unsignedInt(source, source.length - EOCD_LENGTH + 16); + } + + private static long unsignedInt(byte[] bytes, int offset) { + return Integer.toUnsignedLong( + Byte.toUnsignedInt(bytes[offset]) + | (Byte.toUnsignedInt(bytes[offset + 1]) << 8) + | (Byte.toUnsignedInt(bytes[offset + 2]) << 16) + | (Byte.toUnsignedInt(bytes[offset + 3]) << 24) + ); + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java new file mode 100644 index 00000000..d3abaf86 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceContainerPreflightTest.java @@ -0,0 +1,298 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * Source-container regressions for the Office adapter trust boundary. + * + *

These tests deliberately cover only the common pre-conversion authority: + * candidate format qualification, declared-format/container-family agreement, + * and bounded ZIP local/central-directory framing. They do not treat passing + * this preflight as complete safety, Office-package, archive-expansion, macro, + * malware, or fidelity qualification.

+ */ +class OfficeSourceContainerPreflightTest { + + private static final byte[] ZIP_SIGNATURE_PREFIX = new byte[] { + 0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00 + }; + private static final byte[] COMPOUND_FILE_HEADER = new byte[] { + (byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0, + (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1 + }; + private static final byte[] SAFE_ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8); + private static final int LOCAL_FIXED_LENGTH = 30; + private static final int CENTRAL_OFFSET = LOCAL_FIXED_LENGTH + SAFE_ENTRY_NAME.length; + private static final int CENTRAL_FIXED_LENGTH = 46; + private static final int CENTRAL_RECORD_LENGTH = CENTRAL_FIXED_LENGTH + SAFE_ENTRY_NAME.length; + private static final int EOCD_OFFSET = CENTRAL_OFFSET + CENTRAL_RECORD_LENGTH; + private static final int EOCD_LENGTH = 22; + + @Test + void adapterRejectsUnknownFormatBeforeProviderInvocation() { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request("pdf", "%PDF-1.7".getBytes(StandardCharsets.US_ASCII))) + ); + assertEquals(OfficeConversionFailureCode.UNSUPPORTED_FORMAT, failure.failureCode()); + assertEquals("source format is not an Office conversion candidate", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsZipFamilyWithCompoundFileSignatureBeforeProviderInvocation() { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request("docx", COMPOUND_FILE_HEADER)) + ); + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source container signature does not match declared format", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsLegacyFamilyWithZipSignatureBeforeProviderInvocation() { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request("xls", framedZip())) + ); + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source container signature does not match declared format", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsTruncatedZipSignatureBeforeProviderInvocation() { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request("docx", new byte[] {0x50, 0x4b, 0x03})) + ); + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source container signature does not match declared format", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsZipPrefixWithoutCentralDirectoryFramingBeforeProviderInvocation() { + assertMalformedZipBeforeProvider(ZIP_SIGNATURE_PREFIX); + } + + @Test + void adapterRejectsLongZipCandidateWithoutEocdBeforeProviderInvocation() { + byte[] bytes = new byte[40]; + System.arraycopy(ZIP_SIGNATURE_PREFIX, 0, bytes, 0, ZIP_SIGNATURE_PREFIX.length); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsEocdWithCommentLengthBeyondBuffer() { + byte[] bytes = framedZip(); + putUnsignedShort(bytes, EOCD_OFFSET + 20, 1); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsMultiDiskZipFraming() { + byte[] bytes = framedZip(); + putUnsignedShort(bytes, EOCD_OFFSET + 4, 1); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsCentralDirectoryOnDifferentDisk() { + byte[] bytes = framedZip(); + putUnsignedShort(bytes, EOCD_OFFSET + 6, 1); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsZeroEntryZipFraming() { + byte[] bytes = framedZip(); + putUnsignedShort(bytes, EOCD_OFFSET + 8, 0); + putUnsignedShort(bytes, EOCD_OFFSET + 10, 0); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsMismatchedEntryCounts() { + byte[] bytes = framedZip(); + putUnsignedShort(bytes, EOCD_OFFSET + 10, 2); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsZip64EntrySentinelWithoutSeparateQualification() { + byte[] bytes = framedZip(); + putUnsignedShort(bytes, EOCD_OFFSET + 8, 0xffff); + putUnsignedShort(bytes, EOCD_OFFSET + 10, 0xffff); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsZip64CentralDirectorySizeSentinel() { + byte[] bytes = framedZip(); + putUnsignedInt(bytes, EOCD_OFFSET + 12, 0xffff_ffffL); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsZip64CentralDirectoryOffsetSentinel() { + byte[] bytes = framedZip(); + putUnsignedInt(bytes, EOCD_OFFSET + 16, 0xffff_ffffL); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsEmptyCentralDirectorySize() { + byte[] bytes = framedZip(); + putUnsignedInt(bytes, EOCD_OFFSET + 12, 0L); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsCentralDirectoryOffsetOutsideAddressableInput() { + byte[] bytes = framedZip(); + putUnsignedInt(bytes, EOCD_OFFSET + 16, 0x8000_0000L); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsCentralDirectoryThatOverlapsEocd() { + byte[] bytes = framedZip(); + putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_RECORD_LENGTH + 1L); + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterRejectsMissingCentralDirectorySignature() { + byte[] bytes = framedZip(); + bytes[CENTRAL_OFFSET] = 0x00; + assertMalformedZipBeforeProvider(bytes); + } + + @Test + void adapterInvokesProviderForBoundedZipFamilyFraming() { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + adapter.convert(request("pptx", framedZip())); + assertEquals(1, providerCalls.get()); + } + + @Test + void adapterInvokesProviderForBoundedZipFramingWithComment() { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + byte[] base = framedZip(); + byte[] withComment = Arrays.copyOf(base, base.length + 2); + putUnsignedShort(withComment, EOCD_OFFSET + 20, 2); + withComment[withComment.length - 2] = 'o'; + withComment[withComment.length - 1] = 'k'; + adapter.convert(request("docx", withComment)); + assertEquals(1, providerCalls.get()); + } + + @Test + void adapterInvokesProviderForQualifiedLegacyCompoundFileSignature() { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + adapter.convert(request("doc", COMPOUND_FILE_HEADER)); + assertEquals(1, providerCalls.get()); + } + + private static void assertMalformedZipBeforeProvider(byte[] bytes) { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request("docx", bytes)) + ); + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP container framing is invalid", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(String sourceFormat, byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("945bf4f3-48b6-475b-a253-c316969818e6"), + 9L, + sourceFormat, + "policy-v1", + "trace-source-preflight", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] framedZip() { + byte[] bytes = new byte[EOCD_OFFSET + EOCD_LENGTH]; + bytes[0] = 0x50; + bytes[1] = 0x4b; + bytes[2] = 0x03; + bytes[3] = 0x04; + putUnsignedShort(bytes, 4, 20); + putUnsignedShort(bytes, 26, SAFE_ENTRY_NAME.length); + System.arraycopy(SAFE_ENTRY_NAME, 0, bytes, LOCAL_FIXED_LENGTH, SAFE_ENTRY_NAME.length); + + bytes[CENTRAL_OFFSET] = 0x50; + bytes[CENTRAL_OFFSET + 1] = 0x4b; + bytes[CENTRAL_OFFSET + 2] = 0x01; + bytes[CENTRAL_OFFSET + 3] = 0x02; + putUnsignedShort(bytes, CENTRAL_OFFSET + 28, SAFE_ENTRY_NAME.length); + putUnsignedInt(bytes, CENTRAL_OFFSET + 42, 0L); + System.arraycopy(SAFE_ENTRY_NAME, 0, bytes, CENTRAL_OFFSET + CENTRAL_FIXED_LENGTH, SAFE_ENTRY_NAME.length); + + bytes[EOCD_OFFSET] = 0x50; + bytes[EOCD_OFFSET + 1] = 0x4b; + bytes[EOCD_OFFSET + 2] = 0x05; + bytes[EOCD_OFFSET + 3] = 0x06; + putUnsignedShort(bytes, EOCD_OFFSET + 8, 1); + putUnsignedShort(bytes, EOCD_OFFSET + 10, 1); + putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_RECORD_LENGTH); + putUnsignedInt(bytes, EOCD_OFFSET + 16, CENTRAL_OFFSET); + putUnsignedShort(bytes, EOCD_OFFSET + 20, 0); + return bytes; + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceDuplicateEntryPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceDuplicateEntryPolicyTest.java new file mode 100644 index 00000000..fc9b7d25 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceDuplicateEntryPolicyTest.java @@ -0,0 +1,131 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +/** + * Prevents duplicate logical ZIP entry names from reaching an Office provider. + */ +class OfficeSourceDuplicateEntryPolicyTest { + + private static final byte[] FIRST_NAME = "content.xml".getBytes(StandardCharsets.US_ASCII); + private static final byte[] SECOND_NAME = "stylesx.xml".getBytes(StandardCharsets.US_ASCII); + + @Test + void adapterRejectsDuplicateZipEntryNameBeforeProviderInvocation() throws IOException { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = duplicateNamePackage(); + + OfficeConversionException failure = org.junit.jupiter.api.Assertions.assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP contains duplicate entry name", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsAsciiCaseEquivalentOpcPartNamesBeforeProviderInvocation() throws IOException { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = caseVariantDuplicateNamePackage(); + + OfficeConversionException failure = org.junit.jupiter.api.Assertions.assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP contains duplicate entry name", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("516f661f-06a7-49dc-9c14-4f2e4161758c"), + 13L, + "docx", + "policy-v1", + "trace-duplicate-entry-policy", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] duplicateNamePackage() throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + addStoredEmptyEntry(zip, new String(FIRST_NAME, StandardCharsets.US_ASCII)); + addStoredEmptyEntry(zip, new String(SECOND_NAME, StandardCharsets.US_ASCII)); + } + byte[] bytes = output.toByteArray(); + replaceAll(bytes, SECOND_NAME, FIRST_NAME); + return bytes; + } + + private static byte[] caseVariantDuplicateNamePackage() throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + addStoredEmptyEntry(zip, "word/document.xml"); + addStoredEmptyEntry(zip, "WORD/DOCUMENT.XML"); + } + return output.toByteArray(); + } + + private static void addStoredEmptyEntry(ZipOutputStream zip, String entryName) throws IOException { + ZipEntry entry = new ZipEntry(entryName); + entry.setMethod(ZipEntry.STORED); + entry.setSize(0L); + entry.setCompressedSize(0L); + entry.setCrc(0L); + zip.putNextEntry(entry); + zip.closeEntry(); + } + + private static void replaceAll(byte[] bytes, byte[] source, byte[] replacement) { + if (source.length != replacement.length) { + throw new IllegalArgumentException("replacement must preserve ZIP filename length"); + } + for (int offset = 0; offset <= bytes.length - source.length; offset++) { + if (!matchesAt(bytes, offset, source)) { + continue; + } + System.arraycopy(replacement, 0, bytes, offset, replacement.length); + offset += source.length - 1; + } + } + + private static boolean matchesAt(byte[] bytes, int offset, byte[] expected) { + for (int index = 0; index < expected.length; index++) { + if (bytes[offset + index] != expected[index]) { + return false; + } + } + return true; + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java new file mode 100644 index 00000000..a1754273 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceEntryPathPolicyTest.java @@ -0,0 +1,175 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * ZIP-entry path policy regressions for Office source candidates. + * + *

The converter may eventually use a filesystem-backed sandbox internally, so + * path traversal and platform-absolute entry names must fail before provider + * invocation. Local-header and central-directory entry names must also agree so + * different ZIP consumers cannot be given conflicting path metadata. These tests + * inspect archive metadata and do not extract archive contents.

+ */ +class OfficeSourceEntryPathPolicyTest { + + @Test + void adapterRejectsParentTraversalEntryBeforeProviderInvocation() { + assertUnsafeEntry("../outside.bin"); + } + + @Test + void adapterRejectsNestedParentTraversalEntryBeforeProviderInvocation() { + assertUnsafeEntry("word/../../outside.bin"); + } + + @Test + void adapterRejectsLeadingSlashEntryBeforeProviderInvocation() { + assertUnsafeEntry("/absolute.bin"); + } + + @Test + void adapterRejectsBackslashEntryBeforeProviderInvocation() { + assertUnsafeEntry("word\\..\\outside.bin"); + } + + @Test + void adapterRejectsNulEntryNameBeforeProviderInvocation() { + assertUnsafeEntry("word/document.xml\u0000.exe"); + } + + @Test + void adapterRejectsEmptyPathSegmentBeforeProviderInvocation() { + assertUnsafeEntry("word//document.xml"); + } + + @Test + void adapterRejectsDirectoryStyleTrailingSlashBeforeProviderInvocation() { + assertUnsafeEntry("word/"); + } + + @Test + void adapterRejectsIntermediateSegmentEndingWithDotBeforeProviderInvocation() { + assertUnsafeEntry("word./document.xml"); + } + + @Test + void adapterRejectsFinalSegmentEndingWithDotBeforeProviderInvocation() { + assertUnsafeEntry("word/document.xml."); + } + + @Test + void adapterRejectsLocalHeaderNameThatDiffersFromCentralDirectoryBeforeProviderInvocation() { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request(zipWithEntryNames("../outside.bin", "word/document.xml"))) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP local header does not match central directory", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterAllowsRelativeOfficeStyleEntryName() { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + + adapter.convert(request(zipWithEntry("word/document.xml"))); + + assertEquals(1, providerCalls.get()); + } + + private static void assertUnsafeEntry(String entryName) { + AtomicInteger providerCalls = new AtomicInteger(); + OfficeConversionAdapter adapter = countingAdapter(providerCalls); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> adapter.convert(request(zipWithEntry(entryName))) + ); + + assertEquals(OfficeConversionFailureCode.POLICY_DENIED, failure.failureCode()); + assertEquals("source ZIP entry path is unsafe", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("b715d31f-e26f-451d-86fb-d95fb11e8e63"), + 11L, + "docx", + "policy-v1", + "trace-entry-path-policy", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] zipWithEntry(String entryName) { + return zipWithEntryNames(entryName, entryName); + } + + private static byte[] zipWithEntryNames(String localEntryName, String centralEntryName) { + byte[] localNameBytes = localEntryName.getBytes(StandardCharsets.UTF_8); + byte[] centralNameBytes = centralEntryName.getBytes(StandardCharsets.UTF_8); + int localHeaderLength = 30 + localNameBytes.length; + int centralOffset = localHeaderLength; + int centralLength = 46 + centralNameBytes.length; + int eocdOffset = centralOffset + centralLength; + byte[] bytes = new byte[eocdOffset + 22]; + + putUnsignedInt(bytes, 0, 0x04034b50L); + putUnsignedShort(bytes, 26, localNameBytes.length); + System.arraycopy(localNameBytes, 0, bytes, 30, localNameBytes.length); + + putUnsignedInt(bytes, centralOffset, 0x02014b50L); + putUnsignedShort(bytes, centralOffset + 28, centralNameBytes.length); + putUnsignedInt(bytes, centralOffset + 42, 0L); + System.arraycopy(centralNameBytes, 0, bytes, centralOffset + 46, centralNameBytes.length); + + putUnsignedInt(bytes, eocdOffset, 0x06054b50L); + putUnsignedShort(bytes, eocdOffset + 8, 1); + putUnsignedShort(bytes, eocdOffset + 10, 1); + putUnsignedInt(bytes, eocdOffset + 12, centralLength); + putUnsignedInt(bytes, eocdOffset + 16, centralOffset); + return bytes; + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceExpansionConsistencyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceExpansionConsistencyTest.java new file mode 100644 index 00000000..9df245e0 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceExpansionConsistencyTest.java @@ -0,0 +1,120 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * Expansion-consistency regressions for ZIP-family Office candidates. + * + *

A Deflate entry that claims non-empty expanded content cannot have an empty + * compressed payload. Rejecting that impossible metadata combination before a + * converter sees the package is a narrow prerequisite for the broader + * decompression-ratio and archive-expansion policy required by issue #5.

+ */ +class OfficeSourceExpansionConsistencyTest { + + private static final byte[] ENTRY_NAME = "word/document.xml".getBytes(StandardCharsets.UTF_8); + private static final int LOCAL_FIXED_LENGTH = 30; + private static final int CENTRAL_OFFSET = LOCAL_FIXED_LENGTH + ENTRY_NAME.length; + private static final int CENTRAL_FIXED_LENGTH = 46; + private static final int CENTRAL_RECORD_LENGTH = CENTRAL_FIXED_LENGTH + ENTRY_NAME.length; + private static final int EOCD_OFFSET = CENTRAL_OFFSET + CENTRAL_RECORD_LENGTH; + private static final int EOCD_LENGTH = 22; + + @Test + void adapterRejectsNonEmptyDeflateClaimWithEmptyCompressedPayload() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = impossibleEmptyDeflateZip(); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP deflated entry sizes are inconsistent", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("d846829d-b07e-4c28-a795-b8ca5c65b379"), + 12L, + "docx", + "policy-v1", + "trace-expansion-consistency", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] impossibleEmptyDeflateZip() { + byte[] bytes = new byte[EOCD_OFFSET + EOCD_LENGTH]; + + putSignature(bytes, 0, 0x04034b50L); + putUnsignedShort(bytes, 4, 20); + putUnsignedShort(bytes, 8, 8); + putUnsignedInt(bytes, 18, 0L); + putUnsignedInt(bytes, 22, 1L); + putUnsignedShort(bytes, 26, ENTRY_NAME.length); + System.arraycopy(ENTRY_NAME, 0, bytes, LOCAL_FIXED_LENGTH, ENTRY_NAME.length); + + putSignature(bytes, CENTRAL_OFFSET, 0x02014b50L); + putUnsignedShort(bytes, CENTRAL_OFFSET + 10, 8); + putUnsignedInt(bytes, CENTRAL_OFFSET + 20, 0L); + putUnsignedInt(bytes, CENTRAL_OFFSET + 24, 1L); + putUnsignedShort(bytes, CENTRAL_OFFSET + 28, ENTRY_NAME.length); + putUnsignedInt(bytes, CENTRAL_OFFSET + 42, 0L); + System.arraycopy( + ENTRY_NAME, + 0, + bytes, + CENTRAL_OFFSET + CENTRAL_FIXED_LENGTH, + ENTRY_NAME.length + ); + + putSignature(bytes, EOCD_OFFSET, 0x06054b50L); + putUnsignedShort(bytes, EOCD_OFFSET + 8, 1); + putUnsignedShort(bytes, EOCD_OFFSET + 10, 1); + putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_RECORD_LENGTH); + putUnsignedInt(bytes, EOCD_OFFSET + 16, CENTRAL_OFFSET); + return bytes; + } + + private static void putSignature(byte[] bytes, int offset, long value) { + putUnsignedInt(bytes, offset, value); + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java new file mode 100644 index 00000000..73c77246 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceLocalHeaderMetadataPolicyTest.java @@ -0,0 +1,146 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +/** + * Verifies duplicated ZIP local-header metadata before an Office provider is invoked. + */ +class OfficeSourceLocalHeaderMetadataPolicyTest { + + private static final byte[] ENTRY_NAME = "content.xml".getBytes(StandardCharsets.UTF_8); + private static final int LOCAL_HEADER_LENGTH = 30; + private static final int CENTRAL_HEADER_LENGTH = 46; + private static final int CENTRAL_OFFSET = LOCAL_HEADER_LENGTH + ENTRY_NAME.length; + private static final int CENTRAL_RECORD_LENGTH = CENTRAL_HEADER_LENGTH + ENTRY_NAME.length; + private static final int EOCD_OFFSET = CENTRAL_OFFSET + CENTRAL_RECORD_LENGTH; + + @Test + void adapterRejectsLocalCompressedSizeMismatchWithoutDataDescriptor() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryStoredZip(); + putUnsignedInt(source, 18, 1L); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP local header does not match central directory", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsLocalUncompressedSizeMismatchWithoutDataDescriptor() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryStoredZip(); + putUnsignedInt(source, 22, 1L); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP local header does not match central directory", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsDataDescriptorFlagMismatchBetweenLocalAndCentralRecords() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryStoredZip(); + putUnsignedShort(source, CENTRAL_OFFSET + 8, 0x0008); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP local header does not match central directory", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + @Test + void adapterRejectsLocalCrcMismatchWithoutDataDescriptor() { + AtomicInteger providerCalls = new AtomicInteger(); + byte[] source = oneEntryStoredZip(); + putUnsignedInt(source, 14, 1L); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(source)) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source ZIP local header does not match central directory", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("218688bd-713c-4a37-87fc-54b25f73db07"), + 10L, + "docx", + "policy-v1", + "trace-local-header-metadata", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] oneEntryStoredZip() { + byte[] bytes = new byte[EOCD_OFFSET + 22]; + putUnsignedInt(bytes, 0, 0x04034b50L); + putUnsignedShort(bytes, 4, 20); + putUnsignedShort(bytes, 26, ENTRY_NAME.length); + System.arraycopy(ENTRY_NAME, 0, bytes, LOCAL_HEADER_LENGTH, ENTRY_NAME.length); + + putUnsignedInt(bytes, CENTRAL_OFFSET, 0x02014b50L); + putUnsignedShort(bytes, CENTRAL_OFFSET + 28, ENTRY_NAME.length); + putUnsignedInt(bytes, CENTRAL_OFFSET + 42, 0L); + System.arraycopy(ENTRY_NAME, 0, bytes, CENTRAL_OFFSET + CENTRAL_HEADER_LENGTH, ENTRY_NAME.length); + + putUnsignedInt(bytes, EOCD_OFFSET, 0x06054b50L); + putUnsignedShort(bytes, EOCD_OFFSET + 8, 1); + putUnsignedShort(bytes, EOCD_OFFSET + 10, 1); + putUnsignedInt(bytes, EOCD_OFFSET + 12, CENTRAL_RECORD_LENGTH); + putUnsignedInt(bytes, EOCD_OFFSET + 16, CENTRAL_OFFSET); + return bytes; + } + + private static void putUnsignedShort(byte[] bytes, int offset, int value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + } + + private static void putUnsignedInt(byte[] bytes, int offset, long value) { + bytes[offset] = (byte) value; + bytes[offset + 1] = (byte) (value >>> 8); + bytes[offset + 2] = (byte) (value >>> 16); + bytes[offset + 3] = (byte) (value >>> 24); + } +} diff --git a/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceMacroPolicyTest.java b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceMacroPolicyTest.java new file mode 100644 index 00000000..83b4d6bc --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/conversion/OfficeSourceMacroPolicyTest.java @@ -0,0 +1,89 @@ +package com.clearfolio.viewer.conversion; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; + +/** + * Active-content regressions for ZIP-family Office conversion candidates. + */ +class OfficeSourceMacroPolicyTest { + + @Test + void adapterRejectsVbaProjectBeforeProvider() throws Exception { + assertProhibitedActiveContent("word/vbaProject.bin"); + } + + @Test + void adapterRejectsEmbeddedBinaryPartBeforeProvider() throws Exception { + assertProhibitedActiveContent("word/embeddings/oleObject1.bin"); + } + + @Test + void adapterRejectsExternalLinkPartBeforeProvider() throws Exception { + assertProhibitedActiveContent("xl/externalLinks/externalLink1.xml"); + } + + @Test + void adapterRejectsActiveXPartBeforeProvider() throws Exception { + assertProhibitedActiveContent("xl/activeX/activeX1.xml"); + } + + private static void assertProhibitedActiveContent(String entryName) throws Exception { + AtomicInteger providerCalls = new AtomicInteger(); + + OfficeConversionException failure = assertThrows( + OfficeConversionException.class, + () -> countingAdapter(providerCalls).convert(request(docxWithEntry(entryName))) + ); + + assertEquals(OfficeConversionFailureCode.MALFORMED_INPUT, failure.failureCode()); + assertEquals("source Office package contains prohibited active content", failure.getMessage()); + assertEquals(0, providerCalls.get()); + } + + private static OfficeConversionAdapter countingAdapter(AtomicInteger providerCalls) { + return input -> { + providerCalls.incrementAndGet(); + return new OfficeConversionResult( + "deterministic-fixture", + "1", + input.sourceSha256(), + input.binding(), + OfficeConversionTestPdf.onePage() + ); + }; + } + + private static OfficeConversionRequest request(byte[] sourceBytes) { + return new OfficeConversionRequest( + "tenant-a", + UUID.fromString("9ac37475-7937-429c-81d0-3859f1fa0491"), + 13L, + "docx", + "policy-v1", + "trace-macro-policy", + sourceBytes, + 1_000_000L, + 10 + ); + } + + private static byte[] docxWithEntry(String entryName) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.putNextEntry(new ZipEntry(entryName)); + zip.write(new byte[] {1, 2, 3}); + zip.closeEntry(); + } + return output.toByteArray(); + } +} diff --git a/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java b/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java index 7598632a..79be372d 100644 --- a/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java +++ b/src/test/java/com/clearfolio/viewer/fuzz/DocumentValidationFuzzTest.java @@ -21,11 +21,17 @@ */ final class DocumentValidationFuzzTest { + private static final String POLICY_OVERRIDE_KEY = + "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_PSEUDONYM_KEY = + "fedcba9876543210fedcba9876543210"; + private final DefaultDocumentValidationService validator = createValidator(); private static DefaultDocumentValidationService createValidator() { ConversionProperties properties = new ConversionProperties(); - properties.setPolicyOverrideSecret("fuzz-test-secret"); + properties.setPolicyOverrideSecret(POLICY_OVERRIDE_KEY); + properties.setAuditPseudonymSecret(AUDIT_PSEUDONYM_KEY); return new DefaultDocumentValidationService(properties); } diff --git a/src/test/java/com/clearfolio/viewer/fuzz/OfficeConversionBoundaryFuzzTest.java b/src/test/java/com/clearfolio/viewer/fuzz/OfficeConversionBoundaryFuzzTest.java new file mode 100644 index 00000000..14b70518 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/fuzz/OfficeConversionBoundaryFuzzTest.java @@ -0,0 +1,61 @@ +package com.clearfolio.viewer.fuzz; + +import java.util.UUID; + +import com.code_intelligence.jazzer.api.FuzzedDataProvider; +import com.code_intelligence.jazzer.junit.FuzzTest; + +import com.clearfolio.viewer.conversion.OfficeConversionAdapter; +import com.clearfolio.viewer.conversion.OfficeConversionException; +import com.clearfolio.viewer.conversion.OfficeConversionFailureCode; +import com.clearfolio.viewer.conversion.OfficeConversionRequest; + +/** + * Fuzzes the provider-neutral Office source boundary with hostile container bytes. + * + *

The converter implementation is deliberately replaced with a sentinel that + * throws a typed policy rejection. Therefore arbitrary source bytes may either be + * rejected by Clearfolio's ZIP/ODF/compound-file preflight or reach the sentinel, + * but no parser implementation exception or other unexpected throwable may escape. + * This target never starts an Office process and never dereferences external data.

+ */ +final class OfficeConversionBoundaryFuzzTest { + + private static final String[] FORMATS = { + "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp" + }; + + private static final OfficeConversionAdapter SENTINEL_PROVIDER = request -> { + throw new OfficeConversionException( + OfficeConversionFailureCode.POLICY_DENIED, + "fuzz sentinel provider reached" + ); + }; + + @FuzzTest(maxDuration = "60s") + void hostileOfficeBytesOnlyFailThroughTypedConversionBoundary(FuzzedDataProvider data) { + String format = FORMATS[data.consumeInt(0, FORMATS.length - 1)]; + byte[] sourceBytes = data.consumeRemainingAsBytes(); + if (sourceBytes.length == 0) { + sourceBytes = new byte[] {0}; + } + + OfficeConversionRequest request = new OfficeConversionRequest( + "fuzz-tenant", + UUID.fromString("cd348e0e-bd83-433a-a7d0-818d297b98af"), + 1L, + format, + "fuzz-sentinel", + "1", + "fuzz-policy", + "fuzz-trace", + sourceBytes + ); + + try { + SENTINEL_PROVIDER.convert(request); + } catch (OfficeConversionException expected) { + // Typed fail-closed rejection is the complete public failure contract here. + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryCoverageTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryCoverageTest.java new file mode 100644 index 00000000..74e97c0f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryCoverageTest.java @@ -0,0 +1,46 @@ +package com.clearfolio.viewer.repository; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Verifies deletion behavior for missing jobs and jobs without an indexable + * content hash. + */ +class InMemoryConversionJobRepositoryCoverageTest { + + @Test + void deleteIsIdempotentForMissingJobsAndRemovesJobsWithNullOrBlankHashes() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + + repository.deleteById(UUID.randomUUID()); + + ConversionJob nullHashJob = job(null); + repository.save(nullHashJob); + repository.deleteById(nullHashJob.getJobId()); + assertTrue(repository.findById(nullHashJob.getJobId()).isEmpty()); + + ConversionJob blankHashJob = job(" "); + repository.save(blankHashJob); + repository.deleteById(blankHashJob.getJobId()); + assertTrue(repository.findById(blankHashJob.getJobId()).isEmpty()); + } + + private static ConversionJob job(String contentHash) { + return new ConversionJob( + UUID.randomUUID(), + "tenant-a", + "subject-a", + "document.pdf", + "application/pdf", + contentHash, + 1L, + 3 + ); + } +} diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java new file mode 100644 index 00000000..9b896b75 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -0,0 +1,94 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.config.ConversionProperties; + +class AuditKeySeparationGuardTest { + + private static final String POLICY_KEY = "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_KEY = "fedcba9876543210fedcba9876543210"; + + @Test + void rejectsIdenticalConfiguredKeysDuringStartup() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret(POLICY_KEY); + properties.setAuditPseudonymSecret(POLICY_KEY); + + assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + } + + @Test + void rejectsEnabledPolicySigningWithoutAnAuditPseudonymKey() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret(POLICY_KEY); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + + assertTrue(exception.getMessage().contains("audit pseudonym key is required")); + } + + @Test + void rejectsConfiguredPolicyKeyShorterThanThirtyTwoUtf8Bytes() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("short-policy-key"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + + assertTrue(exception.getMessage().contains("at least 32 UTF-8 bytes")); + } + + @Test + void acceptsDistinctConfiguredKeys() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret(POLICY_KEY); + properties.setAuditPseudonymSecret(AUDIT_KEY); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + + @Test + void acceptsConfiguredPolicyKeyMeasuredAsThirtyTwoOrMoreUtf8Bytes() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("가나다라마바사아자차카"); + properties.setAuditPseudonymSecret(AUDIT_KEY); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + + @Test + void permitsMissingPolicyKeyWhenPolicySigningIsDisabled() { + ConversionProperties properties = new ConversionProperties(); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + + @Test + void permitsAnAuditKeyWhenPolicySigningIsDisabled() { + ConversionProperties properties = new ConversionProperties(); + properties.setAuditPseudonymSecret(AUDIT_KEY); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + + @Test + void permitsDisabledSecurityPurposesWithoutComparingMissingValues() { + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(null, "audit-key")); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct("policy-key", null)); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(" ", "audit-key")); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct("policy-key", " ")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java new file mode 100644 index 00000000..5200269a --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java @@ -0,0 +1,41 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Verifies the minimum configured key-strength contract for audit pseudonyms. + */ +class AuditPseudonymizerKeyStrengthTest { + + @Test + void rejectsConfiguredSecretShorterThanThirtyTwoUtf8Bytes() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer("0123456789abcdef0123456789abcde", "v1") + ); + + assertTrue(exception.getMessage().contains("at least 32 UTF-8 bytes")); + } + + @Test + void acceptsConfiguredSecretWithThirtyTwoUtf8Bytes() { + AuditPseudonymizer pseudonymizer = assertDoesNotThrow( + () -> new AuditPseudonymizer("0123456789abcdef0123456789abcdef", "v1") + ); + + assertTrue(pseudonymizer.fingerprint("approver").startsWith("v1:")); + } + + @Test + void measuresConfiguredSecretLengthAsUtf8Bytes() { + AuditPseudonymizer pseudonymizer = assertDoesNotThrow( + () -> new AuditPseudonymizer("가나다라마바사아자차카타파하가나", "v1") + ); + + assertTrue(pseudonymizer.fingerprint("approver").startsWith("v1:")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java new file mode 100644 index 00000000..ce9b01b7 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java @@ -0,0 +1,182 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.Provider; +import java.security.Security; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class AuditPseudonymizerTest { + + private static final Object SECURITY_PROVIDERS_LOCK = new Object(); + private static final String AUDIT_KEY_ONE = "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_KEY_TWO = "fedcba9876543210fedcba9876543210"; + + @Test + void producesDeterministicVersionedFingerprintForExactIdentifierBytes() { + AuditPseudonymizer pseudonymizer = new AuditPseudonymizer(AUDIT_KEY_ONE, "2026-08"); + + String first = pseudonymizer.fingerprint("Employee-007@example.com"); + String second = pseudonymizer.fingerprint("Employee-007@example.com"); + + assertEquals(first, second); + assertTrue(first.matches("2026-08:[0-9a-f]{32}")); + assertFalse(first.contains("Employee-007")); + } + + @Test + void separatesKeysVersionsAndDomains() { + String identifier = "approver-123"; + String baseline = new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v1", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier); + + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_TWO, + "v1", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier) + ); + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v2", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier) + ); + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v1", + "clearfolio:audit-subject:v1" + ).fingerprint(identifier) + ); + } + + @Test + void distinguishesAbsentEmptyAndUnavailableValues() { + AuditPseudonymizer configured = new AuditPseudonymizer(AUDIT_KEY_ONE, "v1"); + AuditPseudonymizer unavailableWhitespace = new AuditPseudonymizer(" ", "v1"); + AuditPseudonymizer unavailableNull = new AuditPseudonymizer(null, "v1"); + + assertEquals("absent:v1", configured.fingerprint(null)); + assertTrue(configured.fingerprint("").matches("v1:[0-9a-f]{32}")); + assertNotEquals(configured.fingerprint(null), configured.fingerprint("")); + assertEquals("unavailable:v1", unavailableWhitespace.fingerprint("approver")); + assertEquals("unavailable:v1", unavailableNull.fingerprint("approver")); + assertEquals("absent:v1", unavailableWhitespace.fingerprint(null)); + } + + @Test + void preservesUnicodeAndControlCharactersOnlyInsideTheHmacInput() { + String identifier = "승인자\n\u202E@example.com"; + String fingerprint = new AuditPseudonymizer(AUDIT_KEY_ONE, "unicode-v1") + .fingerprint(identifier); + + assertTrue(fingerprint.matches("unicode-v1:[0-9a-f]{32}")); + assertFalse(fingerprint.contains("승인자")); + assertFalse(fingerprint.contains("example.com")); + assertFalse(fingerprint.contains("\n")); + assertFalse(fingerprint.contains("\u202E")); + } + + @Test + void defaultsOnlyMissingKeyVersionAndRejectsInvalidExplicitValues() { + assertTrue(new AuditPseudonymizer(AUDIT_KEY_ONE, null).fingerprint("id").startsWith("v1:")); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, " ") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, " v1") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1 ") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "bad/version") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "x".repeat(33)) + ); + assertTrue( + new AuditPseudonymizer(AUDIT_KEY_ONE, "valid._-9") + .fingerprint("id") + .startsWith("valid._-9:") + ); + } + + @Test + void rejectsMissingDomain() { + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1", null) + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1", " ") + ); + } + + @Test + void wrapsMissingHmacProviderAsStableInternalFailure() { + synchronized (SECURITY_PROVIDERS_LOCK) { + List removedProviders = hmacProviderPositions(); + for (ProviderPosition providerPosition : removedProviders) { + Security.removeProvider(providerPosition.provider().getName()); + } + try { + AuditPseudonymizer pseudonymizer = new AuditPseudonymizer(AUDIT_KEY_ONE, "v1"); + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> pseudonymizer.fingerprint("approver") + ); + assertEquals("audit pseudonym HMAC unavailable", exception.getMessage()); + } finally { + removedProviders.stream() + .sorted(Comparator.comparingInt(ProviderPosition::position)) + .forEach(providerPosition -> Security.insertProviderAt( + providerPosition.provider(), + providerPosition.position() + )); + } + } + } + + private static List hmacProviderPositions() { + Provider[] providers = Security.getProviders(); + List positions = new ArrayList<>(); + for (int index = 0; index < providers.length; index++) { + Provider provider = providers[index]; + if (provider.getService("Mac", "HmacSHA256") != null) { + positions.add(new ProviderPosition(provider, index + 1)); + } + } + return positions; + } + + private record ProviderPosition(Provider provider, int position) { + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerFailurePrivacyTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerFailurePrivacyTest.java new file mode 100644 index 00000000..176b8cbf --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerFailurePrivacyTest.java @@ -0,0 +1,62 @@ +package com.clearfolio.viewer.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.artifact.PdfBoxArtifactGenerator; +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.model.ConversionJobStatus; +import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; + +/** + * Regression tests for the public conversion-failure status privacy boundary. + */ +class DefaultConversionWorkerFailurePrivacyTest { + + /** + * Prevents exception-controlled paths, document values, or other sensitive data from becoming + * the client-visible conversion status message. + */ + @Test + void workerDoesNotExposeExceptionMessageInJobStatus() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + ConversionProperties conversionProperties = new ConversionProperties(); + conversionProperties.setMaxRetryAttempts(1); + + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob( + jobId, + "confidential-report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "source-digest", + 64L, + 1 + ); + repository.save(job); + + String sensitiveProviderMessage = + "converter failed at /srv/tenants/acme/private/confidential-report.docx for customer@example.test"; + DefaultConversionWorker worker = new DefaultConversionWorker( + repository, + Runnable::run, + new InMemoryArtifactStore(), + new PdfBoxArtifactGenerator(), + conversionProperties, + id -> { + throw new IllegalStateException(sensitiveProviderMessage); + } + ); + + worker.enqueue(jobId); + + assertThat(job.getStatus()).isEqualTo(ConversionJobStatus.FAILED); + assertThat(job.isDeadLettered()).isTrue(); + assertThat(job.getStatusMessage()).isEqualTo("conversion failed: IllegalStateException"); + assertThat(job.getStatusMessage()).doesNotContain("/srv/tenants", "customer@example.test", "confidential-report.docx"); + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java index b52798a4..88503b48 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java @@ -295,7 +295,7 @@ void workerRetriesFailedJobUntilDeadLettering() throws Exception { assertEquals(2, job.getMaxAttempts()); assertEquals(ConversionJobStatus.FAILED, job.getStatus()); assertTrue(job.isDeadLettered()); - assertEquals("conversion failed: boom", job.getStatusMessage()); + assertEquals("conversion failed: IllegalStateException", job.getStatusMessage()); } finally { executor.shutdownNow(); executor.awaitTermination(1, TimeUnit.SECONDS); @@ -839,7 +839,7 @@ void workerMarksJobFailedWhenConversionThrowsNonRuntimeError() { assertEquals(ConversionJobStatus.FAILED, job.getStatus()); assertTrue(job.isDeadLettered()); - assertEquals("conversion failed: boom-error", job.getStatusMessage()); + assertEquals("conversion failed: AssertionError", job.getStatusMessage()); } @Test @@ -951,7 +951,7 @@ class TestVirtualMachineError extends VirtualMachineError { assertThrows(TestVirtualMachineError.class, () -> invokeProcess(worker, jobId)); assertEquals(ConversionJobStatus.FAILED, job.getStatus()); assertTrue(job.isDeadLettered()); - assertEquals("conversion failed: vm-boom", job.getStatusMessage()); + assertEquals("conversion failed: TestVirtualMachineError", job.getStatusMessage()); } @Test diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceCoverageTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceCoverageTest.java new file mode 100644 index 00000000..24e2d240 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceCoverageTest.java @@ -0,0 +1,52 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import com.clearfolio.viewer.artifact.InMemoryArtifactStore; +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; + +/** + * Verifies that the service persistence boundary rejects unsafe filenames even + * when a replaceable validation adapter does not enforce the same rule. + */ +class DefaultDocumentConversionServiceCoverageTest { + + @Test + void rejectsNullByteFilenameAtTheServiceBoundaryWithAPluggableValidator() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + DocumentValidationService permissiveValidator = file -> { + // Deliberately permissive to prove the service has its own boundary. + }; + ConversionWorker worker = jobId -> { + throw new AssertionError("unsafe upload must not be enqueued"); + }; + DefaultDocumentConversionService service = new DefaultDocumentConversionService( + repository, + permissiveValidator, + worker, + new InMemoryArtifactStore(), + new ConversionProperties() + ); + MockMultipartFile file = new MockMultipartFile( + "file", + "unsafe\u0000.pdf", + "application/pdf", + "%PDF-1.7".getBytes(StandardCharsets.US_ASCII) + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> service.submit(file) + ); + + assertEquals("File name contains null byte.", exception.getMessage()); + assertEquals(0, repository.findAll().size()); + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceFailurePrivacyTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceFailurePrivacyTest.java new file mode 100644 index 00000000..b052e1c9 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceFailurePrivacyTest.java @@ -0,0 +1,100 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.artifact.ArtifactStore; +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.repository.ConversionJobRepository; + +/** + * Verifies that artifact-cleanup failures do not expose provider-controlled + * diagnostics or raw job identifiers through warning logs. + */ +class DefaultDocumentConversionServiceFailurePrivacyTest { + + @Test + void artifactDeletionFailureLogExcludesRawProviderDataAndJobIdentifier() { + ConversionJobRepository repository = mock(ConversionJobRepository.class); + ConversionWorker worker = mock(ConversionWorker.class); + DocumentValidationService validationService = mock(DocumentValidationService.class); + ArtifactStore artifactStore = mock(ArtifactStore.class); + DefaultDocumentConversionService service = new DefaultDocumentConversionService( + repository, + validationService, + worker, + artifactStore, + new ConversionProperties() + ); + UUID jobId = UUID.fromString("4ce471f3-2f79-4b82-879a-a36ce43e48fc"); + String providerMessage = "customer@example.com /tenant/private/report.pdf"; + org.mockito.Mockito.doThrow(new IllegalStateException(providerMessage)) + .when(artifactStore) + .deletePdf(jobId); + CapturingAppender appender = attachAppender(); + + try { + service.deleteJob(jobId); + } finally { + appender.closeAndDetach(); + } + + String renderedLog = appender.renderedLog(); + assertFalse(renderedLog.contains(providerMessage)); + assertFalse(renderedLog.contains("customer@example.com")); + assertFalse(renderedLog.contains("/tenant/private/report.pdf")); + assertFalse(renderedLog.contains(jobId.toString())); + } + + private static CapturingAppender attachAppender() { + Logger logger = (Logger) LogManager.getLogger(DefaultDocumentConversionService.class); + CapturingAppender appender = new CapturingAppender(logger); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.WARN); + return appender; + } + + private static final class CapturingAppender extends AbstractAppender { + + private final Logger logger; + private final List renderedEvents = new ArrayList<>(); + + private CapturingAppender(Logger logger) { + super( + "conversion-service-failure-privacy-test", + null, + PatternLayout.newBuilder().withPattern("%m%throwable").build(), + false, + null + ); + this.logger = logger; + } + + @Override + public void append(LogEvent event) { + renderedEvents.add(getLayout().toSerializable(event).toString()); + } + + private String renderedLog() { + return String.join("\n", renderedEvents); + } + + private void closeAndDetach() { + logger.removeAppender(this); + stop(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationCoverageTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationCoverageTest.java new file mode 100644 index 00000000..0eebda27 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationCoverageTest.java @@ -0,0 +1,118 @@ +package com.clearfolio.viewer.service; + +import static com.clearfolio.viewer.testsupport.SecurityProviderTestSupport.SECURITY_PROVIDERS_LOCK; +import static com.clearfolio.viewer.testsupport.SecurityProviderTestSupport.sha256ProviderPositions; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.security.Security; +import java.util.Comparator; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; + +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.testsupport.SecurityProviderTestSupport.ProviderPosition; + +/** + * Exercises security and filename-validation edge cases that production callers + * can reach through supported configuration or hostile upload metadata. + */ +@ResourceLock("java.security.Security.providers") +class DefaultDocumentValidationCoverageTest { + + @Test + void normalizesNullPolicySecretToTheSupportedDisabledDefault() { + ConversionProperties properties = new ConversionProperties(); + + properties.setPolicyOverrideSecret(null); + + assertEquals("", properties.getPolicyOverrideSecret()); + } + + @Test + void rejectsBlankRootNullByteAndAlternateDataStreamFilenameShapes() throws Exception { + DefaultDocumentValidationService service = serviceWithDefaults(); + + assertEquals("", invokeString(service, "extensionOf", " ")); + assertEquals("", invokeString(service, "extensionOf", "/")); + + String nullByteFilename = "report" + (char) 0 + ".pdf"; + InvocationTargetException nullByteException = assertThrows( + InvocationTargetException.class, + () -> invokeString(service, "extensionOf", nullByteFilename) + ); + assertTrue(nullByteException.getCause() instanceof IllegalArgumentException); + assertEquals("File name contains null byte.", nullByteException.getCause().getMessage()); + + InvocationTargetException alternateStreamException = assertThrows( + InvocationTargetException.class, + () -> invokeString(service, "extensionOf", "report.pdf:stream") + ); + assertTrue(alternateStreamException.getCause() instanceof IllegalArgumentException); + assertEquals("File extension is invalid.", alternateStreamException.getCause().getMessage()); + } + + @Test + void sanitizesEveryLogSeparatorWithoutChangingTheAdjacentBoundaryCharacter() throws Exception { + DefaultDocumentValidationService service = serviceWithDefaults(); + String hostile = "\u0000\t\r\n\u2028\u2029\u202A\u202B\u202C\u202D\u202E\u202Fx"; + + String sanitized = invokeString(service, "sanitizeForLog", hostile); + + assertEquals("_".repeat(11) + "\u202Fx", sanitized); + } + + @Test + void auditFingerprintFailsClosedWhenSha256IsUnavailable() throws Exception { + DefaultDocumentValidationService service = serviceWithDefaults(); + Method method = DefaultDocumentValidationService.class.getDeclaredMethod( + "tokenFingerprint", + String.class + ); + method.setAccessible(true); + + synchronized (SECURITY_PROVIDERS_LOCK) { + List providers = sha256ProviderPositions(); + assertFalse(providers.isEmpty()); + providers.forEach(position -> Security.removeProvider(position.provider().getName())); + try { + InvocationTargetException exception = assertThrows( + InvocationTargetException.class, + () -> method.invoke(service, "approval-token") + ); + assertTrue(exception.getCause() instanceof IllegalStateException); + assertEquals("SHA-256 digest unavailable", exception.getCause().getMessage()); + } finally { + providers.stream() + .sorted(Comparator.comparingInt(ProviderPosition::position)) + .forEach(position -> Security.insertProviderAt( + position.provider(), + position.position() + )); + } + } + } + + private static DefaultDocumentValidationService serviceWithDefaults() { + return new DefaultDocumentValidationService(new ConversionProperties()); + } + + private static String invokeString( + DefaultDocumentValidationService service, + String methodName, + String value + ) throws Exception { + Method method = DefaultDocumentValidationService.class.getDeclaredMethod( + methodName, + String.class + ); + method.setAccessible(true); + return (String) method.invoke(service, value); + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java new file mode 100644 index 00000000..6ed06539 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java @@ -0,0 +1,163 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import com.clearfolio.viewer.config.ConversionProperties; + +class DefaultDocumentValidationServiceAuditTest { + + private static final String POLICY_OVERRIDE_SECRET = + "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_PSEUDONYM_SECRET = + "fedcba9876543210fedcba9876543210"; + + @Test + void acceptedOverrideLogsOnlyPrivacySafeFingerprints() { + String approverId = "employee-007@example.com"; + String approvalToken = generateSignature( + approverId, + "hwp", + POLICY_OVERRIDE_SECRET + ); + ConversionProperties properties = configuredProperties( + POLICY_OVERRIDE_SECRET, + AUDIT_PSEUDONYM_SECRET, + "rotation-7" + ); + DefaultDocumentValidationService service = new DefaultDocumentValidationService(properties); + CapturingAppender appender = attachAppender(); + + try { + service.validateOrThrow( + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), + PolicyOverrideRequest.of("true", approvalToken, approverId) + ); + } finally { + appender.closeAndDetach(); + } + + String auditLine = appender.singleMessage(); + assertTrue(auditLine.contains("approverFingerprint=rotation-7:")); + assertTrue(auditLine.contains("tokenFingerprint=")); + assertFalse(auditLine.contains(approverId)); + assertFalse(auditLine.contains(approvalToken)); + assertFalse(auditLine.contains("approverId=")); + } + + @Test + void enabledPolicySigningRejectsMissingDedicatedAuditKeyBeforeLogging() { + ConversionProperties properties = configuredProperties( + POLICY_OVERRIDE_SECRET, + "", + "v9" + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new DefaultDocumentValidationService(properties) + ); + + assertTrue(exception.getMessage().contains("audit pseudonym key is required")); + } + + @Test + void auditConfigurationNullsUseDocumentedSafeDefaults() { + ConversionProperties properties = new ConversionProperties(); + + properties.setAuditPseudonymSecret(null); + properties.setAuditPseudonymKeyVersion(null); + + assertEquals("", properties.getAuditPseudonymSecret()); + assertEquals("v1", properties.getAuditPseudonymKeyVersion()); + } + + private static ConversionProperties configuredProperties( + String policySecret, + String auditSecret, + String keyVersion) { + ConversionProperties properties = new ConversionProperties(); + properties.setBlockedExtensions(Set.of("hwp", "hwpx")); + properties.setPolicyOverrideSecret(policySecret); + properties.setAuditPseudonymSecret(auditSecret); + properties.setAuditPseudonymKeyVersion(keyVersion); + return properties; + } + + private static String generateSignature(String approverId, String extension, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String payload = approverId.length() + ":" + approverId + extension; + return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + throw new IllegalStateException("test signature generation failed", ex); + } + } + + private static CapturingAppender attachAppender() { + Logger logger = (Logger) LogManager.getLogger(DefaultDocumentValidationService.class); + CapturingAppender appender = new CapturingAppender(logger); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.INFO); + return appender; + } + + private static final class CapturingAppender extends AbstractAppender { + + private final Logger logger; + private final List messages = new ArrayList<>(); + + private CapturingAppender(Logger logger) { + super( + "audit-test-appender", + null, + PatternLayout.newBuilder().withPattern("%m").build(), + false, + null + ); + this.logger = logger; + } + + @Override + public void append(LogEvent event) { + messages.add(event.getMessage().getFormattedMessage()); + } + + private String singleMessage() { + assertEquals(1, messages.size()); + return messages.getFirst(); + } + + private void closeAndDetach() { + logger.removeAppender(this); + stop(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java new file mode 100644 index 00000000..91ffc42a --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceConfigurationTest.java @@ -0,0 +1,24 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.config.ConversionProperties; + +class DefaultDocumentValidationServiceConfigurationTest { + + @Test + void rejectsEnabledPolicyOverrideWithoutDedicatedAuditKey() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("0123456789abcdef0123456789abcdef"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new DefaultDocumentValidationService(properties) + ); + + assertTrue(exception.getMessage().contains("audit pseudonym key is required")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java index 4f27bdcc..77cd74bd 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java @@ -2,8 +2,8 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -26,28 +26,32 @@ class DefaultDocumentValidationServiceTest { + private static final String POLICY_OVERRIDE_KEY = + "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_PSEUDONYM_KEY = + "fedcba9876543210fedcba9876543210"; + private static final Object SECURITY_PROVIDERS_LOCK = new Object(); + @Test void sanitizeFilenameReturnsNullWhenFilenameIsNull() throws Exception { ConversionProperties conversionProperties = new ConversionProperties(); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); + Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); method.setAccessible(true); String sanitized = (String) method.invoke(validationService, new Object[] {null}); assertNull(sanitized); } - @Test void sanitizeFilenameReturnsCleanPathWhenNoSlashIsPresent() throws Exception { ConversionProperties conversionProperties = new ConversionProperties(); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); + Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); method.setAccessible(true); String sanitized = (String) method.invoke(validationService, "simple-file.txt"); assertEquals("simple-file.txt", sanitized); } - @Test void stripsDirectoryTraversalFromFilename() { ConversionProperties conversionProperties = new ConversionProperties(); @@ -57,16 +61,18 @@ void stripsDirectoryTraversalFromFilename() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "../../../etc/passwd.hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "../../../etc/passwd.hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); assertEquals("hwp", ex.getExtension()); } - - private static final Object SECURITY_PROVIDERS_LOCK = new Object(); - @Test void rejectsHwpAndHwpxByDefault() { ConversionProperties conversionProperties = new ConversionProperties(); @@ -76,7 +82,12 @@ void rejectsHwpAndHwpxByDefault() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -99,12 +110,17 @@ private String generateSignature(String approverId, String extension, String sec void allowsBlockedExtensionWhenOverrideHeadersAreValid() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - String validSignature = generateSignature("approver-1", "hwp", "test-secret"); + String validSignature = generateSignature("approver-1", "hwp", POLICY_OVERRIDE_KEY); assertDoesNotThrow(() -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", validSignature, "approver-1") )); } @@ -113,13 +129,18 @@ void allowsBlockedExtensionWhenOverrideHeadersAreValid() { void rejectsBlockedExtensionWhenOverrideSignatureIsInvalid() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", "invalid-token", "approver-1") ) ); @@ -131,7 +152,7 @@ void rejectsBlockedExtensionWhenOverrideSignatureIsInvalid() { void rejectsBlockedExtensionWhenSignatureIsWellFormedButDoesNotMatch() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); // Valid hex of the correct length, but computed with the wrong secret, so the @@ -141,7 +162,12 @@ void rejectsBlockedExtensionWhenSignatureIsWellFormedButDoesNotMatch() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", wrongSignature, "approver-1") ) ); @@ -159,7 +185,12 @@ void rejectsBlockedExtensionWhenSecretIsNotConfigured() { IllegalStateException ex = assertThrows( IllegalStateException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", "any-token", "approver-1") ) ); @@ -176,7 +207,12 @@ void rejectsBlockedExtensionWhenOverrideFlagIsInvalid() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("not-boolean", "token-123", "approver-1") ) ); @@ -193,12 +229,20 @@ void rejectsBlockedExtensionWhenOverrideTokenIsMissing() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", " ", "approver-1") ) ); - assertEquals("X-Clearfolio-Approval-Token is required when policy override is true.", ex.getMessage()); + assertEquals( + "X-Clearfolio-Approval-Token is required when policy override is true.", + ex.getMessage() + ); } @Test @@ -210,12 +254,20 @@ void rejectsBlockedExtensionWhenOverrideTokenIsNull() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", null, "approver-1") ) ); - assertEquals("X-Clearfolio-Approval-Token is required when policy override is true.", ex.getMessage()); + assertEquals( + "X-Clearfolio-Approval-Token is required when policy override is true.", + ex.getMessage() + ); } @Test @@ -227,12 +279,20 @@ void rejectsBlockedExtensionWhenOverrideApproverIsMissing() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", "token-123", " ") ) ); - assertEquals("X-Clearfolio-Approver-Id is required when policy override is true.", ex.getMessage()); + assertEquals( + "X-Clearfolio-Approver-Id is required when policy override is true.", + ex.getMessage() + ); } @Test @@ -244,7 +304,12 @@ void rejectsBlockedExtensionWhenOverrideFlagIsFalse() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("false", "token-123", "approver-1") ) ); @@ -261,7 +326,12 @@ void rejectsBlockedExtensionWhenOverrideFlagIsBlank() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of(" ", "token-123", "approver-1") ) ); @@ -276,7 +346,12 @@ void ignoresInvalidOverrideFlagForSupportedExtension() { DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); assertDoesNotThrow(() -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.docx", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("invalid", null, null) )); } @@ -288,7 +363,12 @@ void allowsSupportedExtensions() { DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); assertDoesNotThrow(() -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[] {1}) + new MockMultipartFile( + "file", + "contract.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + new byte[] {1} + ) )); } @@ -301,7 +381,12 @@ void rejectsMissingExtension() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -352,7 +437,12 @@ void rejectsNullFilename() { assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", (String) null, "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + (String) null, + "application/octet-stream", + new byte[] {1} + ) ) ); } @@ -367,7 +457,12 @@ void rejectsOversizedPayload() { assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[] {1, 2, 3}) + new MockMultipartFile( + "file", + "contract.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + new byte[] {1, 2, 3} + ) ) ); } @@ -395,7 +490,12 @@ void rejectsEmptyFile() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.docx", "application/octet-stream", new byte[0]) + new MockMultipartFile( + "file", + "contract.docx", + "application/octet-stream", + new byte[0] + ) ) ); @@ -430,7 +530,12 @@ void rejectsFilenameEndingWithDot() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract.", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -446,7 +551,12 @@ void rejectsLeadingDotFilenameAsMissingExtension() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", ".hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + ".hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -462,7 +572,12 @@ void trimsFilenameBeforeBlockedExtensionCheck() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", " contract.hwp ", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + " contract.hwp ", + "application/octet-stream", + new byte[] {1} + ) ) ); @@ -478,7 +593,12 @@ void rejectsNullByteInFilename() { IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract\u0000.hwp", "application/octet-stream", new byte[] {1}) + new MockMultipartFile( + "file", + "contract\u0000.hwp", + "application/octet-stream", + new byte[] {1} + ) ) ); assertEquals("File name contains null byte.", ex.getMessage()); @@ -493,7 +613,12 @@ void handlesNullOverrideRequestByFallingBackToDefaultPolicy() { UnsupportedDocumentFormatException ex = assertThrows( UnsupportedDocumentFormatException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), null ) ); @@ -529,11 +654,11 @@ void sanitizeForLogReplacesTabCharacter() throws Exception { void throwsWhenSha256DigestIsUnavailableForOverrideAuditFingerprint() { ConversionProperties conversionProperties = new ConversionProperties(); conversionProperties.setBlockedExtensions(Set.of("hwp", "hwpx")); - conversionProperties.setPolicyOverrideSecret("test-secret"); + configureOverrideKeys(conversionProperties); DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); - // Generate the signature BEFORE removing security providers - String validSignature = generateSignature("approver-1", "hwp", "test-secret"); + // Generate the signature BEFORE removing security providers. + String validSignature = generateSignature("approver-1", "hwp", POLICY_OVERRIDE_KEY); synchronized (SECURITY_PROVIDERS_LOCK) { Provider[] providers = Security.getProviders(); @@ -545,7 +670,12 @@ void throwsWhenSha256DigestIsUnavailableForOverrideAuditFingerprint() { IllegalStateException ex = assertThrows( IllegalStateException.class, () -> validationService.validateOrThrow( - new MockMultipartFile("file", "contract.hwp", "application/octet-stream", new byte[] {1}), + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), PolicyOverrideRequest.of("true", validSignature, "approver-1") ) ); @@ -558,4 +688,9 @@ void throwsWhenSha256DigestIsUnavailableForOverrideAuditFingerprint() { } } } + + private static void configureOverrideKeys(ConversionProperties properties) { + properties.setPolicyOverrideSecret(POLICY_OVERRIDE_KEY); + properties.setAuditPseudonymSecret(AUDIT_PSEUDONYM_KEY); + } } diff --git a/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java b/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java new file mode 100644 index 00000000..0784ca94 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java @@ -0,0 +1,66 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Verifies that the compatibility delete contract fails closed before mutation. + */ +class DocumentConversionServiceCoverageTest { + + @Test + void tenantScopedDeleteRejectsMissingContextAndMissingJobBeforeMutation() { + UUID jobId = UUID.randomUUID(); + AtomicInteger lookupCount = new AtomicInteger(); + AtomicInteger deleteCount = new AtomicInteger(); + DocumentConversionService service = new DocumentConversionService() { + @Override + public UUID submit(MultipartFile file) { + return UUID.randomUUID(); + } + + @Override + public Optional getJob(UUID requestedJobId) { + lookupCount.incrementAndGet(); + return Optional.empty(); + } + + @Override + public RetryDeadLetterResult retryDeadLettered(UUID requestedJobId, String operatorId) { + return RetryDeadLetterResult.NOT_FOUND; + } + + @Override + public void deleteJob(UUID requestedJobId) { + deleteCount.incrementAndGet(); + } + + @Override + public Iterable getAllJobs() { + return java.util.List.of(); + } + }; + + assertFalse(service.deleteJob(jobId, null)); + assertEquals(0, lookupCount.get()); + assertEquals(0, deleteCount.get()); + + assertFalse(service.deleteJob( + jobId, + new TenantContext("tenant-a", "subject-a", Set.of()) + )); + assertEquals(1, lookupCount.get()); + assertEquals(0, deleteCount.get()); + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java b/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java index 00a34a26..3e369374 100644 --- a/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java +++ b/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java @@ -4,8 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -53,30 +53,41 @@ void ofCreatesDistinctInstanceWhenOnlyApproverHeaderIsPresent() { } @Test - void toStringRedactsApprovalToken() { - PolicyOverrideRequest request = PolicyOverrideRequest.of("true", "secret-token", "approver-1"); + void toStringRedactsApprovalTokenAndApproverIdentifier() { + PolicyOverrideRequest request = PolicyOverrideRequest.of( + "true", + "secret-token", + "private-approver@example.com" + ); String rendered = request.toString(); assertTrue(rendered.contains("approvalToken='[redacted]'")); + assertTrue(rendered.contains("approverId='[redacted]'")); assertFalse(rendered.contains("secret-token")); + assertFalse(rendered.contains("private-approver@example.com")); } @Test - void toStringNormalizesControlCharactersInPrintableHeaders() { - PolicyOverrideRequest request = PolicyOverrideRequest.of("true\n", "secret-token", "approver\r\n1\t"); + void toStringNormalizesControlCharactersInPrintableOverrideFlag() { + PolicyOverrideRequest request = PolicyOverrideRequest.of( + "tr\r\nue\t", + "secret-token", + "sensitive-user\r\n1\t" + ); String rendered = request.toString(); - assertTrue(rendered.contains("policyOverride='true_'")); - assertTrue(rendered.contains("approverId='approver__1_'")); + assertTrue(rendered.contains("policyOverride='tr__ue_'")); + assertTrue(rendered.contains("approverId='[redacted]'")); + assertFalse(rendered.contains("sensitive-user")); } @Test - void toStringHandlesNullPrintableHeaders() { + void toStringHandlesNullPrintableHeaderWithoutRevealingIdentityState() { String rendered = PolicyOverrideRequest.none().toString(); assertTrue(rendered.contains("policyOverride='null'")); - assertTrue(rendered.contains("approverId='null'")); + assertTrue(rendered.contains("approverId='[redacted]'")); } } diff --git a/src/test/java/com/clearfolio/viewer/testsupport/SecurityProviderTestSupport.java b/src/test/java/com/clearfolio/viewer/testsupport/SecurityProviderTestSupport.java new file mode 100644 index 00000000..d5211192 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/testsupport/SecurityProviderTestSupport.java @@ -0,0 +1,52 @@ +package com.clearfolio.viewer.testsupport; + +import java.security.Provider; +import java.security.Security; +import java.util.ArrayList; +import java.util.List; + +/** + * Provides one shared synchronization boundary and provider snapshot for tests + * that temporarily remove JVM-wide SHA-256 security providers. + * + *

The Java security-provider registry is global to the test JVM. Tests in + * different packages must therefore use the same lock in addition to JUnit's + * shared resource lock, otherwise parallel execution can observe an incomplete + * provider list or restore providers in the wrong order.

+ */ +public final class SecurityProviderTestSupport { + + /** Shared monitor used by every test that mutates the provider registry. */ + public static final Object SECURITY_PROVIDERS_LOCK = new Object(); + + private SecurityProviderTestSupport() { + // Utility class. + } + + /** + * Returns every installed provider that implements SHA-256 together with its + * original one-based position in the JVM provider registry. + * + * @return ordered SHA-256 provider positions + */ + public static List sha256ProviderPositions() { + Provider[] installedProviders = Security.getProviders(); + List positions = new ArrayList<>(); + for (int index = 0; index < installedProviders.length; index++) { + Provider provider = installedProviders[index]; + if (provider.getService("MessageDigest", "SHA-256") != null) { + positions.add(new ProviderPosition(provider, index + 1)); + } + } + return positions; + } + + /** + * Stores one provider and its original one-based registry position. + * + * @param provider installed security provider + * @param position original one-based registry position + */ + public record ProviderPosition(Provider provider, int position) { + } +}