diff --git a/src/cmake/testing.cmake b/src/cmake/testing.cmake index 15ffa40a0d..a8da380691 100644 --- a/src/cmake/testing.cmake +++ b/src/cmake/testing.cmake @@ -545,6 +545,7 @@ macro (oiio_add_all_tests) oiio_add_tests (psd psd-colormodes ENABLEVAR ENABLE_PSD IMAGEDIR oiio-images/psd) + oiio_add_tests (psd-empty-layers ENABLEVAR ENABLE_PSD) oiio_add_tests (ptex FOUNDVAR Ptex_FOUND ENABLEVAR ENABLE_PTEX) oiio_add_tests (raw raw-thumbnail diff --git a/src/psd.imageio/psdinput.cpp b/src/psd.imageio/psdinput.cpp index 83fcd54756..82de843e70 100644 --- a/src/psd.imageio/psdinput.cpp +++ b/src/psd.imageio/psdinput.cpp @@ -147,6 +147,8 @@ class PSDInput final : public ImageInput { uint32_t width, height; uint16_t channel_count; + bool empty() const { return width == 0 || height == 0; } + std::vector channel_info; std::map channel_id_map; @@ -195,7 +197,7 @@ class PSDInput final : public ImageInput { std::string m_filename; //Current subimage int m_subimage; - //Subimage count (1 + layer count) + //Subimage count (composite plus nonempty layers) int m_subimage_count; std::vector m_specs; static const ResourceLoader resource_loaders[]; @@ -651,8 +653,6 @@ PSDInput::open(const std::string& name, ImageSpec& newspec) return false; } - // Layer count + 1 for merged composite (Image Data Section) - m_subimage_count = m_layers.size() + 1; // Setup ImageSpecs and m_channels bool ok = setup(); @@ -1632,7 +1632,9 @@ PSDInput::load_layer(Layer& layer) int64_t h = int64_t(layer.bottom) - int64_t(layer.top); layer.width = uint32_t(w < 0 ? -w : w); layer.height = uint32_t(h < 0 ? -h : h); - if (!validate_resolution("Layer Record", layer.width, layer.height)) + // Tolerate empty rectangles, but still enforce upper size limits. + if (!validate_resolution("Layer Record", std::max(layer.width, 1u), + std::max(layer.height, 1u))) return false; layer.channel_info.resize(layer.channel_count); for (uint16_t channel = 0; channel < layer.channel_count; channel++) { @@ -1756,6 +1758,22 @@ PSDInput::load_layer_channel(Layer& layer, ChannelInfo& channel_info) if (channel_info.data_length <= 2) return true; + // A channel's stored data, including the 2 byte compression tag already + // read, cannot extend past the end of the file. Compare unsigned: for PSB + // the length is 64 bits and a corrupt value would wrap negative if cast. + int64_t file_size = ioproxy() ? ioproxy()->size() : 0; + int64_t remaining = file_size - start_pos; + if (remaining < 0 || channel_info.data_length > uint64_t(remaining)) { + errorfmt("[Layer Channel] data length {} exceeds remaining file size", + channel_info.data_length); + return false; + } + + // Empty layers are not exposed as images, but their channels may still + // contain RLE row counts or ZIP data. Masks have independent dimensions. + if (layer.empty() && channel_info.channel_id != ChannelID_LayerMask) + return ioseek(start_pos + channel_info.data_length); + // Use mask_data size when channel_id is -2 uint32_t width, height; if (channel_info.channel_id == ChannelID_LayerMask) { @@ -1780,17 +1798,6 @@ PSDInput::load_layer_channel(Layer& layer, ChannelInfo& channel_info) channel_info.data_pos = iotell(); - // A channel's stored data, including the 2 byte compression tag already - // read, cannot extend past the end of the file. Compare unsigned: for PSB - // the length is 64 bits and a corrupt value would wrap negative if cast. - int64_t file_size = ioproxy() ? ioproxy()->size() : 0; - int64_t remaining = file_size - start_pos; - if (remaining < 0 || channel_info.data_length > uint64_t(remaining)) { - errorfmt("[Layer Channel] data length {} exceeds remaining file size", - channel_info.data_length); - return false; - } - channel_info.row_pos.resize(height); channel_info.row_length = (width * m_header.depth + 7) / 8; @@ -2158,13 +2165,15 @@ PSDInput::setup() spec.attribute("oiio:UnassociatedAlpha", 1); // Composite channels - m_channels.reserve(m_subimage_count); + m_channels.reserve(m_layers.size() + 1); m_channels.resize(1); m_channels[0].reserve(raw_channel_count); for (int i = 0; i < raw_channel_count; ++i) m_channels[0].push_back(&m_image_data.channel_info[i]); for (Layer& layer : m_layers) { + if (layer.empty()) + continue; spec_channel_count = m_WantRaw ? mode_channel_count[m_header.color_mode] : 3; raw_channel_count = mode_channel_count[m_header.color_mode]; @@ -2209,6 +2218,7 @@ PSDInput::setup() spec.attribute("oiio:subimagename", layer.name); } + m_subimage_count = int(m_specs.size()); return true; } diff --git a/testsuite/psd-empty-layers/ref/out.txt b/testsuite/psd-empty-layers/ref/out.txt new file mode 100644 index 0000000000..66ea4da970 --- /dev/null +++ b/testsuite/psd-empty-layers/ref/out.txt @@ -0,0 +1 @@ +Empty-layer subimages, populated pixels, raw mode and size limits verified. diff --git a/testsuite/psd-empty-layers/run.py b/testsuite/psd-empty-layers/run.py new file mode 100644 index 0000000000..88a4be4801 --- /dev/null +++ b/testsuite/psd-empty-layers/run.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +redirect = ' >> out.txt 2>&1 ' +command += run_app(f'"{pythonbin}" src/check-empty-layers.py ' + f'"{oiio_app("oiiotool").strip()}" src') diff --git a/testsuite/psd-empty-layers/src/all-empty.psd b/testsuite/psd-empty-layers/src/all-empty.psd new file mode 100644 index 0000000000..d3fcbe312c Binary files /dev/null and b/testsuite/psd-empty-layers/src/all-empty.psd differ diff --git a/testsuite/psd-empty-layers/src/check-empty-layers.py b/testsuite/psd-empty-layers/src/check-empty-layers.py new file mode 100644 index 0000000000..cd2c508d70 --- /dev/null +++ b/testsuite/psd-empty-layers/src/check-empty-layers.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +tool, root = sys.argv[1], Path(sys.argv[2]) +composite = (2, 2, "", "6585CA1F6B702CF27768484219F6F9200F4E9192") +before = (1, 1, "before", "E3FF44389E80210E4545A907BE55980CEB6EA821") +after = (1, 1, "after", "5818BEB65A1338D7C80684EEC2EA2ECB0DB183D0") +populated = (1, 1, "empty", before[3]) +cases = [] +for compression in (0, 1): + for width, height in ((0, 0), (0, 2), (2, 0)): + cases.append((f"empty-layer-{width}x{height}-{compression}.psd", + [composite, before, after])) + cases.append((f"hidden-empty-layer-{compression}.psd", [composite, before, after])) +cases += [ + ("hidden-populated-layer.psd", [composite, before, populated, after]), + ("all-empty.psd", [composite]), + ("trailing-empty.psd", [composite, before]), + ("empty-layer-with-mask.psd", [composite, before, after]), + ("empty-layer-zip.psd", [composite, before, after]), + ("empty-layer-0x300000.psb", [composite, before, after]), + ("empty-layer-300000x0.psb", [composite, before, after]), +] + +for raw in (0, 1): + for filename, expected in cases: + result = subprocess.run( + [tool, "--iconfig", "psd:RawData", str(raw), + "--info:format=xml:verbose=1", "-a", "--hash", str(root / filename)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + assert result.returncode == 0, (filename, raw, result.stderr) + specs = ET.fromstring("" + result.stdout + "") + actual = [ + (int(spec.findtext("width")), int(spec.findtext("height")), + spec.findtext("attrib[@name='oiio:subimagename']", ""), + spec.findtext("SHA1")) + for spec in specs + ] + assert actual == expected, (filename, raw, actual, expected) + assert all(int(spec.findtext("subimages", "1")) == len(expected) + for spec in specs), (filename, raw, "incorrect subimage count") + +for filename, diagnostic in ( + ("empty-layer-invalid-canvas.psd", "[Header]"), + ("empty-layer-oversized.psd", "[Layer Record]"), + ("empty-layer-oversized-width.psd", "[Layer Record]"), + ("oversized-layer-0x300001.psb", "[Layer Record]"), + ("oversized-layer-300001x0.psb", "[Layer Record]"), + ("empty-layer-truncated-channel.psd", "[Layer Channel]"), +): + result = subprocess.run( + [tool, "--info", str(root / filename)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + assert result.returncode != 0 and diagnostic in result.stderr, ( + filename, result.returncode, result.stderr) + +print("Empty-layer subimages, populated pixels, raw mode and size limits verified.") diff --git a/testsuite/psd-empty-layers/src/empty-layer-0x0-0.psd b/testsuite/psd-empty-layers/src/empty-layer-0x0-0.psd new file mode 100644 index 0000000000..8df0442ee3 Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-0x0-0.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-0x0-1.psd b/testsuite/psd-empty-layers/src/empty-layer-0x0-1.psd new file mode 100644 index 0000000000..e8cd0100c5 Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-0x0-1.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-0x2-0.psd b/testsuite/psd-empty-layers/src/empty-layer-0x2-0.psd new file mode 100644 index 0000000000..8ca25d134e Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-0x2-0.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-0x2-1.psd b/testsuite/psd-empty-layers/src/empty-layer-0x2-1.psd new file mode 100644 index 0000000000..6ca4826f34 Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-0x2-1.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-0x300000.psb b/testsuite/psd-empty-layers/src/empty-layer-0x300000.psb new file mode 100644 index 0000000000..cf1f15ecc2 Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-0x300000.psb differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-2x0-0.psd b/testsuite/psd-empty-layers/src/empty-layer-2x0-0.psd new file mode 100644 index 0000000000..bd24476843 Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-2x0-0.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-2x0-1.psd b/testsuite/psd-empty-layers/src/empty-layer-2x0-1.psd new file mode 100644 index 0000000000..4f9bcaeb7a Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-2x0-1.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-300000x0.psb b/testsuite/psd-empty-layers/src/empty-layer-300000x0.psb new file mode 100644 index 0000000000..ce120e7265 Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-300000x0.psb differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-invalid-canvas.psd b/testsuite/psd-empty-layers/src/empty-layer-invalid-canvas.psd new file mode 100644 index 0000000000..4d80a4189e Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-invalid-canvas.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-oversized-width.psd b/testsuite/psd-empty-layers/src/empty-layer-oversized-width.psd new file mode 100644 index 0000000000..992efef878 Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-oversized-width.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-oversized.psd b/testsuite/psd-empty-layers/src/empty-layer-oversized.psd new file mode 100644 index 0000000000..0d072ee983 Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-oversized.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-truncated-channel.psd b/testsuite/psd-empty-layers/src/empty-layer-truncated-channel.psd new file mode 100644 index 0000000000..64aad57d12 Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-truncated-channel.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-with-mask.psd b/testsuite/psd-empty-layers/src/empty-layer-with-mask.psd new file mode 100644 index 0000000000..5e154ce654 Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-with-mask.psd differ diff --git a/testsuite/psd-empty-layers/src/empty-layer-zip.psd b/testsuite/psd-empty-layers/src/empty-layer-zip.psd new file mode 100644 index 0000000000..6b292df7dd Binary files /dev/null and b/testsuite/psd-empty-layers/src/empty-layer-zip.psd differ diff --git a/testsuite/psd-empty-layers/src/hidden-empty-layer-0.psd b/testsuite/psd-empty-layers/src/hidden-empty-layer-0.psd new file mode 100644 index 0000000000..2542b5eaf5 Binary files /dev/null and b/testsuite/psd-empty-layers/src/hidden-empty-layer-0.psd differ diff --git a/testsuite/psd-empty-layers/src/hidden-empty-layer-1.psd b/testsuite/psd-empty-layers/src/hidden-empty-layer-1.psd new file mode 100644 index 0000000000..554b2efb13 Binary files /dev/null and b/testsuite/psd-empty-layers/src/hidden-empty-layer-1.psd differ diff --git a/testsuite/psd-empty-layers/src/hidden-populated-layer.psd b/testsuite/psd-empty-layers/src/hidden-populated-layer.psd new file mode 100644 index 0000000000..8d21d10acd Binary files /dev/null and b/testsuite/psd-empty-layers/src/hidden-populated-layer.psd differ diff --git a/testsuite/psd-empty-layers/src/make-empty-layer-psds.py b/testsuite/psd-empty-layers/src/make-empty-layer-psds.py new file mode 100644 index 0000000000..374562b1c9 --- /dev/null +++ b/testsuite/psd-empty-layers/src/make-empty-layer-psds.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +import struct +import zlib +from pathlib import Path + + +def layer(width, height, compression, name, values=(32, 96, 160), hidden=False, + mask=False, version=1, data_length_extra=0): + encoded_name = name.encode("ascii") + pascal_name = bytes([len(encoded_name)]) + encoded_name + pascal_name += bytes(-len(pascal_name) % 4) + mask_data = struct.pack(">iiiiBB2x", 0, 0, 2, 2, 255, 0) if mask else b"" + extra = struct.pack(">I", len(mask_data)) + mask_data + extra += struct.pack(">I", 0) + pascal_name + channels = [] + for value in values: + if not width or not height: + data = struct.pack(">H", compression) + if compression == 1: + data += bytes(height * (2 if version == 1 else 4)) + elif compression == 2: + data += zlib.compress(b"") + else: + data = struct.pack(">H", 0) + bytes([value]) * width * height + channels.append((len(channels), data)) + if mask: + channels.append((-2, struct.pack(">H", 0) + bytes([255]) * 4)) + record = struct.pack(">iiiiH", 0, 0, height, width, len(channels)) + record += b"".join( + struct.pack(">hI" if version == 1 else ">hQ", channel, + len(data) + data_length_extra) + for channel, data in channels + ) + record += b"8BIMnorm" + bytes([255, 0, 2 if hidden else 0, 0]) + record += struct.pack(">I", len(extra)) + extra + return record, b"".join(data for channel, data in channels) + + +def write_psd(filename, width, height, compression, canvas_width=2, + hidden=False, records=None, version=1): + header = b"8BPS" + struct.pack(">H6sHIIHH", version, bytes(6), 3, + 2, canvas_width, 8, 3) + if records is None: + records = [layer(1, 1, 0, "before", version=version), + layer(width, height, compression, "empty", hidden=hidden, + version=version), + layer(1, 1, 0, "after", values=(160, 96, 32), version=version)] + layer_info = struct.pack(">h", len(records)) + layer_info += b"".join(record for record, data in records) + layer_info += b"".join(data for record, data in records) + layer_info += bytes(len(layer_info) % 2) + length_format = ">I" if version == 1 else ">Q" + layer_mask = struct.pack(length_format, len(layer_info)) + layer_info + layer_mask += struct.pack(">I", 0) + composite = struct.pack(">H", 0) + composite += b"".join(bytes([v]) * 4 for v in (32, 96, 160)) + Path(filename).write_bytes( + header + struct.pack(">II", 0, 0) + + struct.pack(length_format, len(layer_mask)) + layer_mask + composite + ) + + +for compression in (0, 1): + for width, height in ((0, 0), (0, 2), (2, 0)): + write_psd(f"empty-layer-{width}x{height}-{compression}.psd", + width, height, compression) + +for compression in (0, 1): + write_psd(f"hidden-empty-layer-{compression}.psd", 0, 0, compression, + hidden=True) +write_psd("hidden-populated-layer.psd", 1, 1, 0, hidden=True) + +# An empty layer must not bypass the existing document and layer size limits. +write_psd("empty-layer-invalid-canvas.psd", 0, 0, 0, canvas_width=0) +write_psd("empty-layer-oversized.psd", 0, 30001, 0) +write_psd("empty-layer-oversized-width.psd", 30001, 0, 0) +write_psd("all-empty.psd", 0, 0, 0, + records=[layer(0, 0, 0, "empty"), layer(0, 2, 1, "empty2")]) +write_psd("trailing-empty.psd", 0, 0, 0, + records=[layer(1, 1, 0, "before"), layer(0, 0, 0, "empty")]) +write_psd("empty-layer-with-mask.psd", 0, 0, 0, + records=[layer(1, 1, 0, "before"), layer(0, 0, 0, "empty", mask=True), + layer(1, 1, 0, "after", values=(160, 96, 32))]) +write_psd("empty-layer-zip.psd", 0, 2, 2) +write_psd("empty-layer-truncated-channel.psd", 0, 2, 1, + records=[layer(0, 2, 1, "empty", data_length_extra=1000000)]) +for width, height in ((0, 300000), (300000, 0)): + write_psd(f"empty-layer-{width}x{height}.psb", width, height, 0, version=2) +for width, height in ((0, 300001), (300001, 0)): + write_psd(f"oversized-layer-{width}x{height}.psb", width, height, 0, version=2) diff --git a/testsuite/psd-empty-layers/src/oversized-layer-0x300001.psb b/testsuite/psd-empty-layers/src/oversized-layer-0x300001.psb new file mode 100644 index 0000000000..9365475e51 Binary files /dev/null and b/testsuite/psd-empty-layers/src/oversized-layer-0x300001.psb differ diff --git a/testsuite/psd-empty-layers/src/oversized-layer-300001x0.psb b/testsuite/psd-empty-layers/src/oversized-layer-300001x0.psb new file mode 100644 index 0000000000..e5278fa7c9 Binary files /dev/null and b/testsuite/psd-empty-layers/src/oversized-layer-300001x0.psb differ diff --git a/testsuite/psd-empty-layers/src/trailing-empty.psd b/testsuite/psd-empty-layers/src/trailing-empty.psd new file mode 100644 index 0000000000..c9d903d1c0 Binary files /dev/null and b/testsuite/psd-empty-layers/src/trailing-empty.psd differ