photocraft/crates/psd
agent-346022 649b1d5d92
psd: harden the standalone binary parsers (fuzz + property tests, gradient/swatch correctness) (#3542)
* psd: harden the standalone binary parsers (gradients, swatches, patterns, filter effects, metadata, resources, tagged blocks)

Correctness and robustness work on the psd crate's auxiliary parsers, with
fuzz targets and property tests so the invariants are enforced, not just asserted.

grd (gradients):
- Split num()/finite() into a single guard with a saturating f64->f32 cast: only
  NaN has no value, everything else (including +/-inf and 1e300) saturates to the
  representable range so the mapping stays monotonic instead of snapping to a field
  default and inverting a hostile value.
- Hue now wraps with rem_euclid(360) instead of clamping (370 -> 10, not 370 -> 360,
  which would be a different colour at the boundary).
- Every colour model is bounded: RGB/CMYK/Gray to 0..=1, L* to 0..=100 and a*/b* to
  the CIELAB gamut (-128..=127), so a downstream converter never sees out-of-gamut input.

aco/ase (swatches):
- Share one io::utf16_units() helper that caps a name by UTF-16 code units without
  ever splitting a surrogate pair; keeping it in one place stops the fix from drifting
  between the two formats.

Coverage:
- New fuzz targets for grd, aco, ase, patterns, filter_effects, metadata, tagged and
  resources (wired into the fuzz CI matrix).
- Property tests mirroring each fuzz invariant (round-trip stability, no panic on
  arbitrary bytes, documented colour/number ranges) so normal CI covers them; the
  fuzz jobs run on demand.

369 psd tests pass; clippy -D warnings and rustfmt clean; layering and wasm check pass;
all psd fuzz targets build and run clean.

* psd: address Gemini/Claude review of the parser hardening

Follow-up to the previous commit, applying the substantive points from two
independent code reviews (Gemini 3.8: ship; Claude: ship after small fixes).

Production:
- grd hue: an in-range value (0..=360, including exactly 360) is now kept verbatim;
  only an out-of-range hue wraps with rem_euclid. Wrapping unconditionally altered a
  legal 360 to 0 (and f32 round-off on a tiny negative could itself land on 360),
  changing the colour at the boundary for anything that interpolates in HSB.
- ase: compile-time assert that MAX_NAME_UNITS < u16::MAX, since put_name writes the
  length as `units.len() as u16 + 1`; guards the invariant if the cap is ever raised.

Stronger test/fuzz invariants (byte stability alone would pass even if the writer
silently dropped a field; these assert semantic equality):
- metadata fuzz: assert the parsed-then-written block re-parses to the same items
  (the weakened byte-only check was the fix the first fuzz run surfaced).
- patterns fuzz: assert parse(write(parse(x))) == parse(x); channel offsets/padding
  are wire artifacts, not fields, so the round trip is lossless (confirmed, 2M execs).
- filter_effects fuzz: assert back == fx as well as byte stability; also decode every
  cached slot, not just the mask.
- resources fuzz: round-trip ResolutionInfo through to_bytes/from_bytes.

Coverage:
- tagged fuzz: select the block key from a table of every typed/strict key via the
  first byte, so the fuzzer reaches each branch instead of guessing a 4-byte key.
- new property tests: damage-a-valid-block for metadata/patterns/filter_effects (pure
  random bytes rarely pass a header), and check_structure over every structural key.
- grd_every_colour_model_is_clamped_and_finite now pins exact saturation values
  (a*/b* to +/-128, L* to 0/100, units to 0/1), so a weaker clamp is caught, not just
  an out-of-range value; grd_hue_wraps now pins the exact-360 and 0 boundaries.

Verified: 371 psd tests pass; clippy -D warnings, rustfmt, layers and wasm clean; all
13 fuzz targets build and run clean (metadata 11.5M, patterns 2M, filter_effects 5.9M,
resources 3.5M, tagged 5.4M execs); the a*/b* clamp-weakening and hue-guard-removal
mutants are both killed by the strengthened tests.

---------

Co-authored-by: photocraft-agent UNCONFIGURED <unconfigured@invalid.local>
2026-10-12 02:49:08 -04:00
..
examples PSD save: stream Lr16/Lr32 layer blocks into the output (#1158) 2026-10-09 01:13:22 -04:00
fuzz psd: harden the standalone binary parsers (fuzz + property tests, gradient/swatch correctness) (#3542) 2026-10-12 02:49:08 -04:00
proptest-regressions one-shot 2026-09-30 05:02:08 -04:00
src psd: harden the standalone binary parsers (fuzz + property tests, gradient/swatch correctness) (#3542) 2026-10-12 02:49:08 -04:00
tests psd: harden the standalone binary parsers (fuzz + property tests, gradient/swatch correctness) (#3542) 2026-10-12 02:49:08 -04:00
Cargo.toml Photoshop oracles via storytold/photocraft-corpus: pinned corpora, corpus feature, test-corpus, CI job (#190) (#190) 2026-10-05 17:47:33 -04:00
README.md PSD: cap the layer tree for psd_to_document; layered TIFF checks group depth (#2940) (#2975) 2026-10-11 11:20:32 -04:00

README.md

photocraft-psd

A standalone reader and writer for Adobe Photoshop PSD (version 1) and PSB (version 2, "large document format") files. It keeps everything it reads, so an unmodified file writes back byte for byte. It depends on no other photocraft crate.

  • Clean-room implementation from Adobe's public Photoshop File Formats Specification. Where the spec is silent, behavior follows MIT-licensed psd-tools / ag-psd documentation, and the code comments say so.
  • #![forbid(unsafe_code)]. Builds for wasm32-unknown-unknown. The core API works on byte slices; the file helpers only exist on native targets.
  • Parsing never panics on malformed input. Limits: dimensions ≤ 300 000; allocations are checked against the remaining input; a single decode produces at most MAX_DECODED_BYTES (8 GiB on 64-bit targets, 2 GiB on 32-bit targets); descriptor nesting depth ≤ 64.

Guarantees

Property How
PsdFile::from_bytes(b)?.to_bytes()? == b for unmodified files Channel data keeps its original encoding. Unknown blocks and resources are stored raw. Exact padding is recorded wherever it can vary: tagged blocks, layer info, section trailers.
from_bytes(to_bytes(f)) == f for any model f Canonical defaults: padding: None means "zero-pad to even", and the parser yields None whenever the file used that default.
Lazy decoding The parser only copies encoded bytes. Channels are decoded when asked (ChannelData::decode, ImageData::decode).
No compositing PsdBuilder takes the merged image from the caller. Without one, it writes a white placeholder and sets has_real_merged_data = false in resource 1057.

Public API (overview)

// Top level
PsdFile::from_bytes(&[u8]) -> Result<PsdFile>
PsdFile::to_bytes(&self) -> Result<Vec<u8>>
PsdFile::open(path) / save(path)            // native only
PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement,
          global_layer_mask, global_blocks, layer_mask_trailing, image_data }
PsdFile::layers() -> &[LayerRecord]         // file order = bottom-most first
PsdFile::layers_mut() -> &mut Vec<LayerRecord>
PsdFile::layer(i) -> Option<Layer<'_>>;  iter_layers()
PsdFile::layer_tree() -> Vec<LayerNode>     // nested groups, children bottom-to-top
PsdFile::layer_tree_max_depth(n) -> Vec<LayerNode> // same, groups nested n deep keep no children
PsdFile::decode_merged() -> Result<Vec<u8>> // planar big-endian samples
PsdFile::composite_rgba8() -> Result<RgbaImage>
PsdFile::resource(id), global_block(key), icc_profile(), resolution(),
        has_real_merged_data(), merged_has_alpha(), validate()

// Header
Header { version: Version::{Psd,Psb}, channels, width, height, depth, color_mode, reserved }
ColorMode::{Bitmap, Grayscale, Indexed, Rgb, Cmyk, Multichannel, Duotone, Lab, Unknown(u16)}

// Layers
LayerInfo { merged_alpha, layers: Vec<LayerRecord>, padding }
LayerRecord { rect, channels: Vec<ChannelData>, blend_mode, opacity, clipping, flags,
              filler, mask: MaskData, blending_ranges, name, blocks, extra_trailing }
LayerRecord::name()            // prefers `luni`
LayerRecord::section_divider(), section_type(), layer_id(), fill_opacity(), is_visible(),
             block(key), block_mut(key), channel(id), channel_rect(id), decode_channel(id, depth, version)
ChannelData { id, compression: Option<Compression>, data }   // encoded bytes kept verbatim
ChannelData::encode(id, compression, decoded, w, h, depth, version)
ChannelData::decode(w, h, depth, version); set_decoded(...)
MaskData::{None, Mask(LayerMask), Raw(Vec<u8>)}
LayerMask { rect, default_color, flags, parameters: Option<MaskParameters>, real: Option<RealMask>, trailing, real_first }
BlendMode (27 modes + PassThrough + Unknown([u8;4])); BlendMode::key()/from_key()
Layer<'_>::rgba8() -> Result<RgbaImage>; user_mask() -> Option<Result<GrayImage>>; channel_bytes(id)

// Tagged blocks
TaggedBlock { signature /* 8BIM | 8B64 */, key, data, padding: Option<Vec<u8>> }
TaggedBlock::parsed() -> Option<Result<BlockData>>
BlockData::{UnicodeName, SectionDivider, LayerId, NameSource, BlendClippedAsGroup,
            BlendInteriorElements, Knockout, Protection, SheetColor, FillOpacity, MetadataSetting}
constructors: unicode_name, section_divider, layer_id, name_source, blend_clipped_as_group,
              blend_interior_elements, knockout, protection, sheet_color, fill_opacity
TaggedBlock::check_structure() -> Result<()>   // strict inner re-parse of PlLd, SoLd, SoLE, lnk2/3/D, lfx2
tagged::uses_long_length(version, key)   // PSB 8-byte length keys (the spec's 13 + lnk3, lnkE, pths, extd, extn, FELS, cinf, artd)
LayerInfo::unpadded_len(version), LayerInfo::pad_to(version, 4)   // Photoshop pads the layer info to 4

// Image resources
ImageResource { signature, id, name, data };  ImageResource::parsed() -> Option<Result<ResourceData>>
ResourceData::{ResolutionInfo, LayerState, LayerGroupInfo, Thumbnail, GlobalAngle, IccProfile,
               GlobalAltitude, VersionInfo, Exif, Xmp}

// Compression
Compression::{Raw, Rle, Zip, ZipPrediction, Unknown(u16)}
compression::{encode_planes, decode_planes, PlaneLayout, packbits::{encode, decode}, predict, unpredict}
pixels::{samples_u16, samples_f32, u16_to_bytes, f32_to_bytes, unpack_bits, plane_to_u8}

// Descriptors
descriptor::{Descriptor, VersionedDescriptor, Value, Id, Class, ReferenceItem, UnicodeString, ObjectArray}
Descriptor::from_bytes / to_bytes;  VersionedDescriptor::from_bytes / to_bytes (version 16)

// Builder
PsdBuilder::new(w, h).color_mode(..).depth(8|16).version(..).compression(..).resolution(dpi).icc_profile(..)
builder.push_layer(LayerSpec) / begin_group(GroupSpec) / end_group()? / composite(PixelData)
builder.build() -> Result<PsdFile>;  builder.to_bytes()
PixelData::{Rgba8, Rgba16, GrayA8, GrayA16, Cmyka8, Cmyka16}   // CMYK given as ink, stored inverted

// Test generator (feature `testgen`)
testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_plane}

Feature matrix

Area Read Write Typed Notes
Header, PSD v1 / PSB v2 ✓ ✓ ✓ depth 1/8/16/32; all 8 color modes plus Unknown
Color mode data ✓ ✓ raw indexed palette is used by composite_rgba8
Image resources ✓ ✓ partial 1005, 1024, 1026, 1033/1036 (raw), 1037, 1039, 1049, 1057, 1058, 1060
Layer records ✓ ✓ ✓ flags, clipping, filler, blending ranges (raw + accessor)
Layer masks ✓ ✓ ✓ 20 / 36-byte forms, mask parameters, real mask; unparseable masks stay raw
Channel ids -1 / -2 / -3 ✓ ✓ ✓ per-channel rect via channel_rect
Raw / RLE / ZIP / ZIP+prediction ✓ ✓ – prediction at 8/16/32 bits (32: byte-plane shuffle + delta); RLE counts are u16 in PSD, u32 in PSB
Merged image data ✓ ✓ – validated at parse time (sizes, RLE counts, zlib stream incl. checksum); a zlib stream declaring more than MAX_DECODED_BYTES is kept verbatim without inflating it, and decoding it reports the limit
Tagged blocks (raw passthrough) ✓ ✓ – keeps 8BIM / 8B64 and exact padding
PSB 8-byte-length keys ✓ ✓ – LMsk Lr16 Lr32 Layr Mt16 Mt32 Mtrn Alph FMsk lnk2 FEid FXid PxSD
Lr16 / Lr32 / Layr layer info ✓ ✓ ✓ lifted into layer_info; re-inserted at its original position
luni lsct lsdk lyid lnsr clbl infx knko lspf lclr iOpa shmd ✓ ✓ ✓ TaggedBlock::parsed()
ActionDescriptor (all OSTypes) ✓ ✓ ✓ including references, ObAr (best effort), UnFl, Pth , version-16 wrapper
Layer tree (groups) ✓ ✓ ✓ open/closed folders plus bounding dividers; tolerant of malformed nesting
RGBA8 extraction ✓ – – RGB / gray / CMYK layers; merged image also supports indexed / bitmap / duotone
Builder – ✓ – RGB / gray / CMYK at 8/16 bit, masks, groups, blend modes, opacity, fill, visibility, clipping
Patterns ✓ ✓ ✓ Patt / Pat2 / Pat3, .pat, and .abr embedded patterns; non-empty tiles allow in-bounds channel subrectangles, decoded size is capped and PackBits expansion is input-bounded

Pattern channel subrectangles are padded with zeroes to the tile rectangle. All channel metadata and the total padded size are checked against MAX_DECODED_BYTES before any plane is decoded. Fully populated channels reuse their decoded buffer without a second copy; allocations for padded channels return an error on failure. There is no additional padding-specific size limit.

Preserved raw, not yet typed (parsers planned for M8)

  • Layer effects: lfx2, lrFX, lmfx. The descriptors inside can be parsed on demand with VersionedDescriptor::from_bytes(&block.data[4..]).
  • Type layers: TySh, tySh, and the EngineData inside them.
  • Smart objects: SoLd, PlLd, SoLE, lnk2, lnkD, lnk3.
  • Vector masks and shapes: vmsk, vsms, vogk, vscg.
  • Adjustment and fill layers: curv, levl, hue2, SoCo, GdFl, PtFl, …, plus Patt, Txt2, FMsk, 3D and video blocks.
  • Global layer mask info (raw, with accessors), thumbnails (raw), slices, guides, paths, and every other resource id.

Testing

cargo test -p photocraft-psd --all-features runs the unit tests, integration tests and proptests:

  • Byte stability and model round-trips for every generated case: all depths, color modes and compressions, PSD and PSB.
  • PackBits edge cases.
  • Known vectors for 8/16/32-bit prediction.
  • PSD vs PSB length handling.
  • Truncation sweeps: every prefix of several small files must return Err.
  • Random-mutation and random-byte proptests: never panic.
  • Layer tree cases.
  • Builder → rgba8 correctness.
  • Unknown blend modes and unknown blocks pass through unchanged.

tests/corpus.rs walks corpus/psd/**/*.{psd,psb} at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently.

A cargo-fuzz skeleton lives in fuzz/. It is its own workspace and is excluded from photocraft's. Run it with cargo +nightly fuzz run parse (or descriptor) from crates/psd.

Spec ambiguities and decisions

  • Tagged block padding. The spec says lengths are "rounded up to an even byte count". In practice writers either include the padding in the length or append it after the data, and pad to 2 or 4. The parser detects the padding that actually follows each block: the smallest k ≤ 3 after which the next signature or the end of the region appears. It stores that padding unless it matches the default.
  • Layer info padding (2 vs 4) is recorded the same way.
  • 16/32-bit layers are read from global Lr16 / Lr32 blocks when the main layer info is empty. The block data is a layer-info body with no inner length field.
  • Mask record layout. The order is: rect, default color, flags, parameters (when flag bit 4 is set), then the real flags, background and rect whenever at least 18 bytes remain. Anything left over (for example the 2 padding bytes of the 20-byte form) is kept in trailing.
  • Mask channel rects. Channel -2 uses the mask rect. Channel -3 uses the real mask rect.
  • Merged ZIP data is a single zlib stream over all planes. Prediction is applied per row across all planes.
  • ZIP prediction at depth 1 is treated as byte-wise delta.
  • ObAr is read as a u32 prefix followed by a descriptor-shaped body, following ag-psd's reading. The spec does not document it.
  • Descriptor bool bytes other than 0/1 are normalized to 1 on write.
  • Pascal names have their pad bytes written as zeros. A layer & mask section that contains only a zero layer-info length is normalized to an empty section. These are the only known non-byte-exact cases, and both are pathological.
  • Header limits. The parser accepts up to 300 000 × 300 000 for both versions. PsdFile::validate() enforces the spec's 30 000 limit for PSD.