Files
photocraft/crates/doc/src/import_conversion.rs
T
Terra854 453ea8c9ca [Extend XCF import into native PhotoCraft documents 1/6] Persist import conversion data and bounded native filter foundations
Imported documents need durable conversion evidence and native pixel operations before XCF conversion can produce editable PhotoCraft data. This adds bounded document/report types, lossless pattern construction and `.pcraft` persistence, plus native HDR, percentile, noise, convolution, Average, coverage and matting kernels and checked Smart-filter mixing. Runtime color models/depths are retained; source attachments remain inert. Native SVG operations retain parsing, lowering, rendering and parser-tree destruction on one bounded worker, including nested resources; WebAssembly reports a controlled error for depths requiring native isolation.

Native filter corrections cover matte black points and signed HDR, extent-wide Smart-filter coverage, flat-image identity Auto Levels, opaque FillHoles capability, exact unselected channel preservation, coverage-preserving Average selection and bounded pattern expansion. Checked Smart-filter mixing rejects nonfinite native F32 samples, preserves unchanged Normal pixels without regenerating CMYK separations, and recovers representable alpha mixtures after an overflowing difference. HDR blend overflow retries use cold F64 arithmetic while retaining ordinary finite results and integer-depth behavior. Both opaque and alpha-bearing F32 mask recovery retain signed/HDR colors.

Stored pattern declarations remain lossless and inert. Dense PSD/PAT/ASL pattern export now checks dimensions and one shared plane-allocation budget before conversion. PAT/ASL report conversion errors; PSD/PSB report named omission warnings. Matching raw PSD pattern blocks remain verbatim. CMYK offsets retain the established brightness convention.

Command registration, XCF decoding and frontend workflows follow in dependent parts. The contributor entry is the author's existing chosen entry.

<!-- native-foundations-fixes-20261107:start -->
Small noise, convolution and point-operation edits admit their clipped work area on large owners while retaining output/workspace limits and whole-owner FillHoles bounds. HDR toning retains skipped transparent native samples. Normal Smart-filter strength interpolates stored CMYK/Lab/ink planes with premultiplied coverage, preserving unchanged channels. Bitmap Custom Pattern reads compact patterns in bounded row bands and ranks the complete map once. Embedded SVG isolation failures propagate independently of strict mode; ordinary invalid images can still be omitted, and private native parses share the complete-operation worker. The import-report regression now primes the layer blob cache and requires its specific limit diagnostic.
<!-- native-foundations-fixes-20261107:end -->

<!-- adversarial-5810371597:start -->
Import-report accounting uses portable fixed costs. HDR scene means use F64. Bitmap pattern ranking preflights a 256 MiB input/order/output budget, uses fallible allocation and heap-scratch-free sorting. Average uses the supplied image domain and retains one global mean across output tiles. Non-Normal Smart-filter coverage is classified once across the complete mixing extent. A fallible filter dispatcher and engine error conversion preserve checked native diagnostics and cancellation. Smart-filter engine integration remains in part 5 (#11032).
<!-- adversarial-5810371597:end -->

## Six-part series

Each branch is hosted in Terra854/photocraft, and every PR targets storytold/photocraft:main. Merge in the order below. Later drafts currently show cumulative diffs while prerequisites are unmerged; their descriptions link to isolated comparisons. Rebase each part onto current upstream main after its prerequisites merge, including squash-merge ancestry, and rerun affected checks before requesting review.

| Part | Upstream PR | Fork branch | Scope |
| --- | --- | --- | --- |
| 1 | [#11009](https://github.com/storytold/photocraft/pull/11009) | xcf-support-v2-01-import-data | Document/native-format data and pixel foundations |
| 2 | [#11029](https://github.com/storytold/photocraft/pull/11029) | xcf-support-v2-02-decoder | Bounded transport/records, precision, metadata, editable text and paths |
| 3 | [#11030](https://github.com/storytold/photocraft/pull/11030) | xcf-support-v2-03-effects-core | Native effect frames and primary tone/color/spatial/resource/transform adapters |
| 4 | [#11031](https://github.com/storytold/photocraft/pull/11031) | xcf-support-v2-04-effects-graphs | Remaining adapters, bounded graph lowering, coverage installation and source evidence |
| 5 | [#11032](https://github.com/storytold/photocraft/pull/11032) | xcf-support-v2-05-engine | Native commands, import preparation, historical choices, editing and persistence workflows |
| 6 | [#11033](https://github.com/storytold/photocraft/pull/11033) | xcf-support-v2-06-frontends | Desktop/web UI, CLI/MCP, translations, GPU diagnostics and qualification/docs |

The full integration remains preserved on `xcf-support-v2`; original #7098 remains available for reference.

## Validation

<!-- main-catchup-0d08cb7dd:start -->
Updated to upstream `main` at `0d08cb7dd` and restacked all six branches. Part 1 is `640a01f43`; full integration is `24cd17301`. The final six-part tree exactly matches the full integration tree.

The merge preserves both upstream's new tests/crop translations and the XCF historical-open tests/report labels. The automation adapter now preserves import-source protection across multiple granted read roots: relative source names cover every matching candidate so newly shadowing files cannot expose the original to overwrite, while absolute source names remain precise. Existing ordered reads and implicit-save provenance are retained. Read-only import source identity is kept independently of whether its library root grants an implicit Save target. Focused regressions cover source aliases, removed files, denied candidates, changing root precedence, and read-only library imports; the Windows escape fixture also uses a real junction when symlink privileges are unavailable. The new upstream UI import fixture also uses the native import outcome type.

Local validation passed affected host all-target checks, core and native frontend/automation unit and integration suites, forced-Vulkan GPU unit and integration suites, pinned IO corpora, strict all-target Clippy, formatting, layering, panel command dispatch, WASM, adversarial command tests, and the quick CPU performance harness. Offscreen historical-XCF prompt and conversion-report captures were reviewed. The first two PR boundaries also passed independent all-target engine checks. Tests used the pinned craft-fonts input, four Rayon workers, serial tests for large fixtures and opt-level=0 for engine/UI test glue; pixel/format crates retained their repository settings. Quick mode uses small documents and does not enforce full-size budgets. The part 1 push triggered fresh [CI](https://github.com/storytold/photocraft/actions/runs/42885527754) and [SonarCloud](https://github.com/storytold/photocraft/actions/runs/42885527740) runs. After upstream approval, CI passed the Linux gate, Windows unit/integration tests, no-HEIF tests, pinned corpora and web build. SonarCloud's instrumented archive, coverage shards and actual scan remain skipped behind its token guard; its outer green status does not mean coverage ran. Catching up with main therefore retriggered normal tests but did not remove that SonarCloud skip. Older validation below remains evidence for its explicitly named earlier commits.
<!-- main-catchup-0d08cb7dd:end -->

<!-- average-no-extent-4517477902:start -->
Average no-extent follow-up at part 1 `f8ddb626f` (full integration `882def329`), carried through all six branches. The checked Average dispatcher now uses the requested `area` when no owner extent is supplied; an explicit extent retains its existing clipping behavior. Other filters, the Average pixel kernel and resource admission are unchanged.

The new regression fails against the preceding revision and passes with the fix. It covers `apply`, one-pixel explicit tiling, and the cancellable tiled API at 8/16/32-bit, signed/HDR colors, hidden RGB, native alpha preservation, unchanged source data and pixels outside the requested area. Validation passed the algorithm unit/integration suite (935 tests; 27 existing ignored), strict all-target algorithm Clippy, formatting, layering, WASM (24 crates plus HEIF), and independent all-target engine checks of the first two PR boundaries. The broader suites below remain evidence for their named earlier commits and were not rerun for this dispatcher-only follow-up.
<!-- average-no-extent-4517477902:end -->

<!-- review-20261110:start -->
Review follow-up at part 1 `a3144d559` (full integration `b5fd3e211`), carried through all six branches. Average admits long thin regions within its existing pixel/workspace budgets, clamps finite selection coverage, and uses owner extents for off-canvas replay. HDR Toning also adapts over the owner extent. Pattern GPU uploads now use bounded bands; the warning-free `document_to_psd` API returns `Result` on pattern export failure and reuses prepared pattern blocks. Warning-enabled exports retain explicit omission warnings.

Five filter regressions fail against the previous revision and pass with the fixes. Local validation passed 1,597 algorithm/compositor/GPU tests with Vulkan, all 129 GPU integration tests separately on DX12 with FXC (the app uses FXC when no bundled DXC is present), a separate forced DX12 pattern upload/readback test, 1,144 IO unit and 675 pinned IO integration tests, 10 affected engine tests, strict all-target Clippy, formatting, layering, WASM and quick CPU performance checks. The first two PR boundaries passed independent all-target engine checks. This Intel driver's DX12/DXC configuration rejects the unchanged `fs_adjust` pipeline (`0x80070057`); DX12/FXC and Vulkan both pass. The full workspace suite was not rerun; earlier broad evidence below remains tied to its named commits. Quick performance mode does not enforce full-size budgets.

A separate 24 MP signed/HDR RGBA32F public Average-kernel probe retained exact mean RGB and original alpha. Three alternating runs had medians of 1,274.713 ms before and 1,259.885 ms after; this small difference is within run variation and is not a speedup claim.

The Minor/Poor-tradeoff report-deserialization finding remains open and documented. Encoded records are capped at 32 MiB and individual arrays are bounded, but compact targets can still amplify transient decoded memory before aggregate validation rejects the report. Early shared-budget deserialization is not implemented in this follow-up.
<!-- review-20261110:end -->

<!-- main-catchup-7c64653d5:start -->
Updated to upstream `main` at `7c64653d5` and restacked all six branches. Part 1 is `cbf068d5f`; full integration is `206f71012`. The full integration tree exactly matches part 6.

The merge retains main's alpha-preserving Average behavior with bounded checked processing, and uses it consistently for XCF coverage conversion and native replay. Historical XCF normalization and import preparation now run through main's resumable Place flow and Open as Smart Object; regression tests cover source byte order and native Edit Contents. XCF report/filter labels also cover the seven languages main promoted to menu-complete status.

The current tree passed affected host all-target checks, core/IO, GPU, engine and frontend suites, strict all-target Clippy, formatting, layering, panel command dispatch, WASM and the web build, adversarial command tests, pinned IO corpora and the quick CPU performance harness. Offscreen historical-XCF prompt and conversion-report captures were reviewed. The first two PR boundaries also passed independent all-target engine checks. Tests used CI-pinned craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`, four Rayon workers, serial tests for RAM-heavy suites and opt-level=0 for engine/UI test glue; pixel and format crates kept repository settings. Quick performance mode uses smaller documents and does not enforce full-size budgets. Older measurements below remain evidence for their named earlier commits.
<!-- main-catchup-7c64653d5:end -->

<!-- percentile-owner-4502952267:start -->
Percentile owner-bounds follow-up at part 1 `93946db04` (full integration `f7264af14`): nonempty owner bounds now supply edge samples without a dense whole-owner admission check. The unchanged preflight still bounds clipped output, padded halo, workspace and CPU work; dense-image adapter limits remain in place. Two new regressions failed with `InvalidGeometry` before the fix and pass afterward, covering small edits on 81 MP and wider sparse owners, clamped-edge oracles, fractional selections, source preservation, clipped requests, invalid domains, oversized output, work limits and cancellation.

This follow-up passed 903 algorithm tests (887 unit + 16 integration; 27 existing ignored), all-target strict algorithm Clippy, formatting, layering and WASM (24 crates plus the HEIF configuration). Only sampling-bound admission changed; percentile processing and resource-preflight code are byte-for-byte unchanged. The broader qualification below remains tied to its explicitly named earlier commits; the full workspace suite was not rerun for this one-file follow-up.
<!-- percentile-owner-4502952267:end -->

<!-- native-foundations-validation-20261107:start -->
The production fixes at part 1 `c16e274b8` (full integration `6f8349cd5`) passed 7,245 affected algorithm/compositor/document/engine/native-format/IO tests across 46 binaries, with 65 existing ignored tests, plus doctests, affected all-target strict Clippy, layering, WASM, adversarial command tests, formatting and the pinned IO corpus (1,107 unit + 661 integration tests, 26 existing ignored). The final diagnostics regressions also passed independently on the foundation and full integration branches. The first PR passed an independent all-target engine check. Tests used four Rayon/test workers and the existing CI font pin; engine test glue used opt-level=0, and the foundation-only diagnostic probe also used opt-level=0 for its algorithm dependency. The full integration tree exactly matches the final sixth branch.

Paired 24 MP release probes used three timed repetitions per version after warm-ups, alternating order, matching inlining controls, opaque parameters and a current public-API control, with four Rayon workers. Timings are completed operations on this host.

| Probe | Before (ms) | After (ms) | Public API (ms) |
| --- | ---: | ---: | ---: |
| HDR toning | 830.3 | 834.4 | 791.2 |
| Rgb-Normal | 1070.5 | 1069.3 | 1079.7 |
| Cmyk-Normal | 683.0 | 669.7 | 691.5 |
| Rgb-Multiply | 1103.1 | 1303.3 | 1297.5 |

The extent-wide coverage prepass adds 200.2 ms (18.2%) in the alpha-preserving Multiply case. This is the measured cost of applying one consistent coverage rule; the Normal RGB/CMYK controls remain near baseline.

The uniform HDR probe's middle-gray output changed from 0.200004 to 0.180000 (target 0.18); the public control agrees. Mixer probes check native colors and alpha at sampled positions. The quick CPU application harness also completed; its saved cross-machine comparison was skipped, and quick mode does not apply full-size budgets. No budgets or baselines changed.
<!-- native-foundations-validation-20261107:end -->

Earlier validation of full integration `c003e3f50`, before these follow-up fixes, completed the upstream `main` catch-up at `c101cbfb7` on 2026-11-07. The cumulative series passed affected host all-target checks, foundation and IO suites, GPU, engine and frontend suites, strict Clippy, layering, WASM, adversarial command tests, formatting, pinned IO corpora and the quick CPU performance harness. Offscreen historical-XCF prompt and conversion-report captures were reviewed. Local tests used CI-pinned craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`, four Rayon workers and temporary opt-level=0 overrides for engine/UI test glue; pixel and format crates retained repository settings. The full UI library passed 5,330 tests, and the final dialog-dispatch cleanup passed 945 focused dialog tests.

The decoder and engine PR boundaries also passed independent all-target checks. The full `xcf-support-v2` integration tree matches the final sixth branch exactly. SVG stack regressions retain their 512 KiB caller, isolated complete native lifecycle and 100 retained group levels.

The matched performance figures below were measured before this main integration; the HDR recovery arithmetic and bounded tile implementation are preserved.

Forward filter-mask mixing retains its existing four-buffer allocation optimization and arithmetic. Its matched 24 MP RGBA32F probes measured opaque pixels at 392.410→182.842 ms, semitransparent pixels at 383.321→179.916 ms, and signed HDR pixels at 373.696→183.903 ms (five-run medians, 50–53% faster).

Inverse filter-mask recovery now processes at most eight native tiles in parallel, with four reusable scratch buffers per slot and ordered surface publication. Small inputs, one-worker operation and WebAssembly use the same serial tile helper. All F64 and integer pixel arithmetic is unchanged. Bitwise regressions compare full/partial/missing tiles, negative coordinates, differing defaults and hidden/zero-alpha outputs across 30 format combinations; an explicit one-worker/two-worker comparison checks both execution paths.

Matched 24 MP HDR inverse-recovery public-API probes measured 166.972→90.565 ms (opaque, 45.8% faster), and 433.325→130.707 ms (semitransparent, 69.8% faster). Five-run medians use four Rayon workers on this host; all runs assert recovered HDR samples at three positions. The existing F64 precision and HDR saturation bounds are preserved.

Mask recovery retains signed/HDR colors, extreme finite values, subnormals and exact full coverage. The PSD/PSB regression uses a real blur on spatially varying HDR pixels and partial masks; raw exported channels must match the unmasked filter result, and reimported Smart caches must match independently authored masked values.

The prior quick CPU application harness completed. Its saved cross-machine baseline comparison was skipped, so the paired public-API measurements above provide the direct before/after evidence. No performance budgets or baselines changed.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
  * Added image adjustments and filters for averaging, channel noise, convolution, percentile effects, alpha and coverage editing, black matte removal, HDR toning, and automatic tone, contrast, and color correction.
  * Added Smart-filter blending controls for opacity and blend modes.
  * Import-conversion information is preserved when saving and reopening documents.

* **Improvements**
  * Improved handling of deeply nested SVGs and large patterns, with bounded pattern processing and warnings when pattern export is not possible.
  * Improved stability when blending extreme HDR values and applying filters to HDR images.
  * Improved transparency handling for channel-selective filters and added clearer feedback when image processing or export cannot be completed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:35:40 +01:00

420 lines
18 KiB
Rust

//! Historical import outcomes. None of this data drives rendering, editing or resource access.
use std::fmt;
use std::sync::Arc;
use serde::de::{SeqAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use crate::LayerId;
pub const CONVERSION_SCHEMA_VERSION: u32 = 1;
/// Separate from an input reader's existing metadata allowance.
pub const MAX_CONVERSION_BYTES: usize = 32 << 20;
pub const MAX_CONVERSION_STRING: usize = 1 << 20;
pub const MAX_CONVERSION_ENTRIES: usize = 65_536;
pub const MAX_CONVERSION_ATTACHMENTS: usize = 4_096;
const MAX_ITEM_PATH: usize = 64;
// Schema-v1 accounting units, independent of pointer width and Rust layout. Keep
// these conservative fixed costs so web and desktop accept the same reports.
const ENTRY_BYTES: usize = 256;
const TARGET_BYTES: usize = 128;
const ATTACHMENT_BYTES: usize = 160;
/// An optional future report is retained without interpreting its record or binary payloads.
#[derive(Clone, Debug, PartialEq)]
pub enum ImportConversion {
Report(ConversionReport),
Opaque { schema_version: u32, record: Arc<Vec<u8>>, attachments: Vec<Arc<Vec<u8>>> },
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversionReport {
pub source_format: String,
pub source_version: Option<u32>,
pub policy_version: u32,
#[serde(deserialize_with = "entries")]
pub entries: Vec<ConversionEntry>,
#[serde(deserialize_with = "attachments")]
pub attachments: Vec<RetainedImportData>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversionEntry {
pub feature_code: String,
pub source: SourceItemRef,
#[serde(deserialize_with = "targets")]
pub targets: Vec<NativeTargetRef>,
pub disposition: ConversionDisposition,
pub reason: ConversionReason,
pub source_description: String,
pub native_description: String,
#[serde(deserialize_with = "indices")]
pub attachments: Vec<u32>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConversionDisposition {
NativeEquivalent,
NativeApproximation,
RetainedUnapplied,
SkippedNonessential,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConversionReason {
NativePolicy,
NativeLimitation,
MissingMapping,
ResourceUnavailable,
InvalidOptionalData,
RetentionLimit,
NumericalConversion,
}
/// Ordinals/paths identify the source snapshot, including duplicate display names.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceItemRef {
pub kind: String,
pub ordinal: u32,
#[serde(deserialize_with = "item_path")]
pub item_path: Vec<u32>,
pub tattoo: Option<u32>,
pub record_offset: Option<u64>,
pub name: String,
}
/// Import-time references may become stale; they are never remapped into live instructions.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NativeTargetRef {
pub layer_id: Option<LayerId>,
#[serde(deserialize_with = "item_path")]
pub item_path: Vec<u32>,
pub channel_index: Option<u32>,
pub path_index: Option<u32>,
pub filter_index: Option<u32>,
pub label: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RetainedImportData {
pub owner: SourceItemRef,
pub kind: String,
pub encoding: String,
/// Native format stores this separately in its hashed blob store, never as JSON bytes.
#[serde(skip)]
pub bytes: Arc<Vec<u8>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConversionValidationError {
Limit(&'static str),
Invalid(&'static str),
}
impl fmt::Display for ConversionValidationError {
/// Render bounded validation failures as actionable import-report errors without looking up live document
/// targets.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Limit(message) | Self::Invalid(message) => write!(f, "import conversion: {message}"),
}
}
}
impl std::error::Error for ConversionValidationError {}
type Validation = Result<(), ConversionValidationError>;
struct Budget(usize);
impl Budget {
/// Debit the report's independent byte allowance and reject overflow before accepting more retained data.
fn charge(&mut self, n: usize) -> Validation {
self.0 = self.0.checked_sub(n).ok_or(ConversionValidationError::Limit("report exceeds the separate 32 MiB allowance"))?;
Ok(())
}
/// Enforce the per-string ceiling before charging its UTF-8 bytes to the report budget.
fn string(&mut self, s: &str) -> Validation {
if s.len() > MAX_CONVERSION_STRING {
return Err(ConversionValidationError::Limit("report string exceeds 1 MiB"));
}
self.charge(s.len())
}
/// Bound source/target path depth and charge its stored components without traversing a document.
fn path(&mut self, path: &[u32]) -> Validation {
if path.len() > MAX_ITEM_PATH {
return Err(ConversionValidationError::Limit("item path exceeds 64 components"));
}
self.charge(std::mem::size_of_val(path))
}
/// Account for an item's kind, display name and historical path as inert report metadata.
fn source(&mut self, source: &SourceItemRef) -> Validation {
self.string(&source.kind)?;
self.string(&source.name)?;
self.path(&source.item_path)
}
}
impl ImportConversion {
/// Expose the known report schema or preserve an opaque future record's declared version.
pub fn schema_version(&self) -> u32 {
match self {
Self::Report(_) => CONVERSION_SCHEMA_VERSION,
Self::Opaque { schema_version, .. } => *schema_version,
}
}
/// Validate before saving or installing data; no current document/target lookup is needed.
pub fn validate(&self) -> Validation {
match self {
Self::Report(report) => report.validate(),
Self::Opaque { schema_version, record, attachments } => {
if *schema_version <= CONVERSION_SCHEMA_VERSION {
return Err(ConversionValidationError::Invalid("opaque report must have a newer schema version"));
}
if attachments.len() > MAX_CONVERSION_ATTACHMENTS {
return Err(ConversionValidationError::Limit("too many report attachments"));
}
let mut budget = Budget(MAX_CONVERSION_BYTES);
budget.charge(record.len())?;
for bytes in attachments {
budget.charge(bytes.len())?;
}
Ok(())
}
}
}
}
impl ConversionReport {
/// Validate report identities, references, counts and combined retained bytes without granting those references
/// authority.
pub fn validate(&self) -> Validation {
if self.source_format.is_empty() {
return Err(ConversionValidationError::Invalid("source format is empty"));
}
if self.entries.len() > MAX_CONVERSION_ENTRIES || self.attachments.len() > MAX_CONVERSION_ATTACHMENTS {
return Err(ConversionValidationError::Limit("too many report entries or attachments"));
}
let mut budget = Budget(MAX_CONVERSION_BYTES);
budget.string(&self.source_format)?;
for entry in &self.entries {
if entry.feature_code.is_empty() || entry.source.kind.is_empty() {
return Err(ConversionValidationError::Invalid("entry feature code or source kind is empty"));
}
budget.charge(ENTRY_BYTES)?;
budget.string(&entry.feature_code)?;
budget.source(&entry.source)?;
budget.string(&entry.source_description)?;
budget.string(&entry.native_description)?;
if entry.targets.len() > MAX_CONVERSION_ENTRIES || entry.attachments.len() > MAX_CONVERSION_ATTACHMENTS {
return Err(ConversionValidationError::Limit("too many item targets or attachment references"));
}
for target in &entry.targets {
budget.charge(TARGET_BYTES)?;
budget.string(&target.label)?;
budget.path(&target.item_path)?;
}
for index in &entry.attachments {
if usize::try_from(*index).ok().is_none_or(|index| index >= self.attachments.len()) {
return Err(ConversionValidationError::Invalid("attachment reference is out of range"));
}
budget.charge(std::mem::size_of::<u32>())?;
}
}
for attachment in &self.attachments {
budget.charge(ATTACHMENT_BYTES)?;
budget.source(&attachment.owner)?;
budget.string(&attachment.kind)?;
budget.string(&attachment.encoding)?;
budget.charge(attachment.bytes.len())?;
}
Ok(())
}
}
// Limit element counts during deserialization, before accepting the next allocation.
/// Deserialize at most the declared number of items and reject an extra element before accepting another
/// allocation.
fn bounded_sequence<'de, T: Deserialize<'de>, D: Deserializer<'de>, const MAX: usize>(d: D) -> Result<Vec<T>, D::Error> {
struct Bounded<T, const MAX: usize>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>, const MAX: usize> Visitor<'de> for Bounded<T, MAX> {
type Value = Vec<T>;
/// Describe the bounded report-array contract in deserialization errors.
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "an import conversion array of at most {MAX} items")
}
/// Collect only the allowed sequence length and reject a trailing element instead of silently truncating
/// it.
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<T>, A::Error> {
let mut out = Vec::new();
while out.len() < MAX {
match seq.next_element()? {
Some(value) => out.push(value),
None => return Ok(out),
}
}
if seq.next_element::<serde::de::IgnoredAny>()?.is_some() {
return Err(serde::de::Error::custom("import conversion array exceeds its count limit"));
}
Ok(out)
}
}
d.deserialize_seq(Bounded::<T, MAX>(std::marker::PhantomData))
}
/// Apply the report-entry count limit during deserialization rather than after collecting unbounded input.
fn entries<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<ConversionEntry>, D::Error> {
bounded_sequence::<'de, _, _, MAX_CONVERSION_ENTRIES>(d)
}
/// Bound retained attachment records while decoding their metadata array.
fn attachments<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<RetainedImportData>, D::Error> {
bounded_sequence::<'de, _, _, MAX_CONVERSION_ATTACHMENTS>(d)
}
/// Limit native target references while preserving their historical, non-executable meaning.
fn targets<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<NativeTargetRef>, D::Error> {
bounded_sequence::<'de, _, _, MAX_CONVERSION_ENTRIES>(d)
}
/// Bound attachment-reference counts before later report validation resolves their indices.
fn indices<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
bounded_sequence::<'de, _, _, MAX_CONVERSION_ATTACHMENTS>(d)
}
/// Reject historical item paths deeper than the report format permits during decoding.
fn item_path<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
bounded_sequence::<'de, _, _, MAX_ITEM_PATH>(d)
}
#[cfg(test)]
mod tests {
use super::*;
/// Pin the schema's byte boundary to fixed units rather than the host's Rust struct layout.
#[test]
fn report_budget_is_identical_on_web_and_desktop() {
let mut report = ConversionReport {
source_format: "xcf".into(),
entries: vec![ConversionEntry {
feature_code: "f".into(),
source: SourceItemRef { kind: "layer".into(), item_path: vec![1, 2], ..Default::default() },
targets: vec![NativeTargetRef { item_path: vec![3], label: "t".into(), ..Default::default() }],
disposition: ConversionDisposition::RetainedUnapplied,
reason: ConversionReason::MissingMapping,
source_description: String::new(),
native_description: String::new(),
attachments: vec![0],
}],
attachments: vec![RetainedImportData {
owner: SourceItemRef::default(),
kind: "raw".into(),
encoding: "binary".into(),
bytes: Arc::new(Vec::new()),
}],
..Default::default()
};
// Literal schema costs plus UTF-8 bytes, three u32 path components and one index.
let overhead = 256 + 128 + 160 + 3 + 1 + 5 + 1 + 3 + 6 + 12 + 4;
report.attachments[0].bytes = Arc::new(vec![0; MAX_CONVERSION_BYTES - overhead]);
assert_eq!(report.validate(), Ok(()));
Arc::make_mut(&mut report.attachments[0].bytes).push(0);
assert!(matches!(report.validate(), Err(ConversionValidationError::Limit(_))));
}
#[test]
/// Check absent-report defaults and ensure historical source/target references do not become live document
/// state.
fn reports_default_to_absent_and_have_no_document_authority() {
assert!(crate::Metadata::default().import_conversion.is_none());
let report = ImportConversion::Report(ConversionReport { source_format: "xcf".into(), ..Default::default() });
assert!(report.validate().is_ok());
assert_eq!(report.schema_version(), 1);
}
/// Document and history-style clones share the complete report, including entry strings and opaque future records.
#[test]
fn document_clones_share_known_and_opaque_conversion_reports() {
let entry = ConversionEntry {
feature_code: "xcf.source".into(),
source: SourceItemRef { kind: "layer".into(), ..Default::default() },
targets: Vec::new(),
disposition: ConversionDisposition::RetainedUnapplied,
reason: ConversionReason::MissingMapping,
source_description: "source control ".repeat(64),
native_description: "inert historical conversion information".into(),
attachments: vec![0],
};
let known = ImportConversion::Report(ConversionReport {
source_format: "xcf".into(),
entries: vec![entry; 1024],
attachments: vec![RetainedImportData {
owner: SourceItemRef::default(),
kind: "configuration".into(),
encoding: "binary".into(),
bytes: Arc::new(vec![17; 64 * 1024]),
}],
..Default::default()
});
let opaque = ImportConversion::Opaque {
schema_version: CONVERSION_SCHEMA_VERSION + 1,
record: Arc::new(b"future report record".to_vec()),
attachments: vec![Arc::new(vec![23; 64 * 1024])],
};
for report in [known, opaque] {
report.validate().unwrap();
let mut original =
crate::Document::new("Shared report", crate::Size::new(1, 1), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
original.metadata.import_conversion = Some(Arc::new(report));
let cloned = original.clone();
let first = original.metadata.import_conversion.as_ref().unwrap();
let second = cloned.metadata.import_conversion.as_ref().unwrap();
assert!(Arc::ptr_eq(first, second), "document clones must share the entire conversion report");
if let ImportConversion::Report(report) = first.as_ref() {
let ImportConversion::Report(copy) = second.as_ref() else { panic!("same shared variant") };
assert_eq!(report.entries.as_ptr(), copy.entries.as_ptr(), "entry and string arrays are not deep-cloned");
assert!(Arc::ptr_eq(&report.attachments[0].bytes, &copy.attachments[0].bytes));
}
}
}
#[test]
/// Reject oversized strings and future opaque payloads under the independent report allowance.
fn string_and_opaque_payload_limits_are_enforced() {
let report = ConversionReport { source_format: "x".repeat(MAX_CONVERSION_STRING + 1), ..Default::default() };
assert!(matches!(report.validate(), Err(ConversionValidationError::Limit(_))));
let report = ImportConversion::Opaque { schema_version: 2, record: Arc::new(vec![0; MAX_CONVERSION_BYTES + 1]), attachments: Vec::new() };
assert!(matches!(report.validate(), Err(ConversionValidationError::Limit(_))));
}
#[test]
/// Verify oversized arrays fail in bounded deserialization instead of being accepted then truncated.
fn counts_are_limited_during_deserialization() {
let owner = SourceItemRef::default();
let mut value = serde_json::to_value(&owner).unwrap();
value["itemPath"] = serde_json::json!(vec![0; MAX_ITEM_PATH + 1]);
assert!(serde_json::from_value::<SourceItemRef>(value).is_err());
}
#[test]
/// Exercise attachment count and aggregate byte limits without borrowing the source metadata allowance.
fn attachment_counts_and_totals_are_bounded_independently_of_source_metadata() {
let empty = Arc::new(Vec::new());
let count = ImportConversion::Opaque { schema_version: 2, record: empty.clone(), attachments: vec![empty; MAX_CONVERSION_ATTACHMENTS + 1] };
assert!(count.validate().is_err());
let chunk = Arc::new(vec![0; MAX_CONVERSION_BYTES / 2 + 1]);
let total = ImportConversion::Opaque { schema_version: 2, record: chunk.clone(), attachments: vec![chunk] };
assert!(matches!(total.validate(), Err(ConversionValidationError::Limit(_))));
}
}