Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ cc_library(
":runtime_cache_interface",
":runtime_format_descriptor",
":runtime_lru_tile_cache",
":runtime_path_utils",
":runtime_reader_registry",
":runtime_tile_writer",
],
Expand Down Expand Up @@ -1414,6 +1415,18 @@ cc_library(
],
)

cc_library(
name = "runtime_path_utils",
hdrs = ["include/fastslide/runtime/io/path_utils.h"],
copts = WASM_COPTS,
includes = ["include"],
visibility = ["//visibility:public"],
deps = [
"@aifocore-cpp//:result",
"@aifocore-cpp//:utilities-core",
],
)

cc_library(
name = "runtime_file_reader",
srcs = ["src/runtime/io/file_reader.cpp"],
Expand Down Expand Up @@ -1916,6 +1929,17 @@ cc_test(
],
)

cc_test(
name = "path_utils_test",
size = "small",
srcs = ["src/runtime/io/path_utils_test.cpp"],
deps = [
":runtime_path_utils",
"@googletest//:gtest",
"@googletest//:gtest_main",
],
)

# Format tests
cc_test(
name = "aperio_metadata_parser_test",
Expand Down
16 changes: 14 additions & 2 deletions include/fastslide/core/tile_plan.h
Original file line number Diff line number Diff line change
Expand Up @@ -199,20 +199,32 @@ struct OutputSpec {
};

/// @brief Convert a DataType to the closest OutputSpec::PixelFormat
///
/// The tile sinks memcpy raw samples out of the decoded tile using the
/// canvas's sample width, so this mapping must preserve the source's bytes per
/// sample. `PixelFormat` has no signed members; the signed types therefore map
/// onto the unsigned member of the same width, which keeps the bit pattern
/// intact. Falling back to `kFloat32` instead would widen a 16-bit sample to
/// four bytes and make the sinks stride off the end of the tile.
///
/// @param dtype Source data type
/// @return Matching pixel format (kUInt8, kUInt16, kUInt32, or kFloat32)
/// @return Pixel format with the same width as @p dtype
constexpr OutputSpec::PixelFormat ToOutputPixelFormat(
fastslide::DataType dtype) {
switch (dtype) {
case fastslide::DataType::kUInt8:
return OutputSpec::PixelFormat::kUInt8;
case fastslide::DataType::kUInt16:
case fastslide::DataType::kInt16:
return OutputSpec::PixelFormat::kUInt16;
case fastslide::DataType::kUInt32:
case fastslide::DataType::kInt32:
return OutputSpec::PixelFormat::kUInt32;
default:
case fastslide::DataType::kFloat32:
case fastslide::DataType::kFloat64:
return OutputSpec::PixelFormat::kFloat32;
}
return OutputSpec::PixelFormat::kUInt8;
}

/// @brief Complete tile reading plan
Expand Down
7 changes: 7 additions & 0 deletions include/fastslide/image.h
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ constexpr DataType DataTypeFromSampleFormat(uint16_t bits_per_sample,
case 3: // IEEE floating point.
return bits_per_sample >= 64 ? DataType::kFloat64 : DataType::kFloat32;
case 2: // Two's-complement signed integer.
// `DataType` has no signed 8-bit member. Map an 8-bit signed page onto
// kUInt8 rather than widening it to kInt16: the bit pattern survives and,
// more importantly, so does the one-byte sample width that the tile sinks
// assume when they copy out of the decoded strip.
if (bits_per_sample <= 8) {
return DataType::kUInt8;
}
return bits_per_sample <= 16 ? DataType::kInt16 : DataType::kInt32;
case 1: // Unsigned integer.
default:
Expand Down
10 changes: 8 additions & 2 deletions include/fastslide/readers/isyntax/third_party/isyntax.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ typedef struct isyntax_xml_cpp_state_t isyntax_xml_cpp_state_t;

#include "fastslide/readers/isyntax/third_party/third_party/yxml.h"

// Capacities of the inline arrays in `isyntax_image_t` and `isyntax_t`. Both
// are filled from counts parsed out of the file's XML header, so every writer
// must bound the count against these before indexing.
#define ISYNTAX_MAX_LEVELS 16
#define ISYNTAX_MAX_IMAGES 16

enum isyntax_image_type_enum {
ISYNTAX_IMAGE_TYPE_NONE = 0,
ISYNTAX_IMAGE_TYPE_MACROIMAGE = 1,
Expand Down Expand Up @@ -305,7 +311,7 @@ typedef struct isyntax_image_t {
int32_t offset_y;
int32_t level_count;
int32_t max_scale;
isyntax_level_t levels[16];
isyntax_level_t levels[ISYNTAX_MAX_LEVELS];
int32_t compressor_version;
bool compression_is_lossy;
int32_t lossy_image_compression_ratio;
Expand Down Expand Up @@ -376,7 +382,7 @@ typedef struct isyntax_t {
isyntax_open_flags_t open_flags;
int64_t filesize;
file_handle_t file_handle;
isyntax_image_t images[16];
isyntax_image_t images[ISYNTAX_MAX_IMAGES];
int32_t image_count;
isyntax_block_header_template_t block_header_templates[64];
int32_t block_header_template_count;
Expand Down
35 changes: 35 additions & 0 deletions include/fastslide/readers/simpletiff_decode_utils.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <span>
#include <string_view>
#include <vector>
Expand Down Expand Up @@ -176,6 +177,40 @@ inline aifocore::Result<DecodedInterleavedView> ReadTileOrStrip(
decoded_w, decoded_h));
}

// `buffer` is sized from the actual payload, but the geometry we advertise
// below comes from the IFD. The two can disagree: a JPEG page declaring
// SamplesPerPixel=4 still decodes to three bytes per pixel, and an
// undersized LZW/Deflate payload inflates to fewer rows than declared.
// Consumers stride using the advertised geometry, so a buffer that does not
// back it becomes an out-of-bounds read. Reject the contradiction instead of
// guessing which side is right.
const size_t decoded_pixels =
static_cast<size_t>(decoded_w) * static_cast<size_t>(decoded_h);
const bool pixels_overflowed =
decoded_pixels / static_cast<size_t>(decoded_h) !=
static_cast<size_t>(decoded_w);
if (pixels_overflowed ||
decoded_pixels > std::numeric_limits<size_t>::max() / bytes_per_pixel) {
return AIFOCORE_MAKE_STATUS(
aifocore::StatusCode::kInvalidArgument,
aifocore::fmt::format("Decoded geometry {}x{} at {} bytes/pixel "
"overflows for page {}",
decoded_w, decoded_h, bytes_per_pixel,
page_index));
}
const size_t required_bytes = decoded_pixels * bytes_per_pixel;
if (buffer.size() < required_bytes) {
return AIFOCORE_MAKE_STATUS(
aifocore::StatusCode::kInvalidArgument,
aifocore::fmt::format(
"Page {} tile/strip {} decoded to {} bytes but its {}x{} geometry "
"at {} bytes/pixel (SamplesPerPixel={}, BitsPerSample={}) requires "
"{}",
page_index, tile_or_strip_index, buffer.size(), decoded_w,
decoded_h, bytes_per_pixel, page_header.samples_per_pixel,
page_header.bits_per_sample, required_bytes));
}

return DecodedInterleavedView{
.data = std::span<const uint8_t>(buffer.data(), buffer.size()),
.width = static_cast<uint32_t>(decoded_w),
Expand Down
116 changes: 116 additions & 0 deletions include/fastslide/runtime/io/path_utils.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2026 Jonas Teuwen. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#ifndef AIFO_FASTSLIDE_INCLUDE_FASTSLIDE_RUNTIME_IO_PATH_UTILS_H_
#define AIFO_FASTSLIDE_INCLUDE_FASTSLIDE_RUNTIME_IO_PATH_UTILS_H_

#include <filesystem>
#include <system_error>

#include "aifocore/status/result.h"
#include "aifocore/utilities/fmt.h"

/**
* @file path_utils.h
* @brief Containment checks for filenames taken from slide files.
*
* Several formats are directory bundles whose manifest names the sibling files
* holding the pixel data: MIRAX reads `FILE_0`.. out of `Slidedat.ini`,
* OME-Zarr reads `datasets[].path` out of `zarr.json`. Those strings are
* attacker controlled, so joining them onto the bundle directory without
* checking lets a crafted slide address any file the process can open.
*/

namespace fastslide {
namespace runtime {
namespace io {

/// @brief Join `relative` onto `root` without allowing an escape from `root`.
///
/// Rejects absolute paths and any `..` component, then resolves the result and
/// confirms it is still inside `root`. Resolution goes through
/// `std::filesystem::weakly_canonical`, so a symlink pointing out of the bundle
/// is rejected as well -- the check would otherwise pass on a path that
/// contains no `..` at all.
///
/// @param root Bundle directory the result must stay within.
/// @param relative Path read out of the slide file.
/// @return Resolved absolute path, or `kInvalidArgument` if it escapes `root`.
[[nodiscard]] inline aifocore::Result<std::filesystem::path>
ResolveContainedPath(const std::filesystem::path& root,
const std::filesystem::path& relative) {
namespace fs = std::filesystem;

if (relative.empty()) {
return AIFOCORE_MAKE_STATUS(aifocore::StatusCode::kInvalidArgument,
"Slide references an empty path");
}
if (relative.is_absolute() || relative.has_root_name() ||
relative.has_root_directory()) {
return AIFOCORE_MAKE_STATUS(
aifocore::StatusCode::kInvalidArgument,
aifocore::fmt::format("Slide references absolute path '{}'",
relative.string()));
}
for (const auto& component : relative) {
if (component == "..") {
return AIFOCORE_MAKE_STATUS(
aifocore::StatusCode::kInvalidArgument,
aifocore::fmt::format("Slide references parent directory in '{}'",
relative.string()));
}
}

std::error_code err;
const fs::path resolved_root = fs::weakly_canonical(root, err);
if (err) {
return AIFOCORE_MAKE_STATUS(
aifocore::StatusCode::kInvalidArgument,
aifocore::fmt::format("Cannot resolve slide directory '{}': {}",
root.string(), err.message()));
}
const fs::path resolved = fs::weakly_canonical(root / relative, err);
if (err) {
return AIFOCORE_MAKE_STATUS(
aifocore::StatusCode::kInvalidArgument,
aifocore::fmt::format("Cannot resolve slide path '{}': {}",
relative.string(), err.message()));
}

// Compare component-wise rather than as strings so a sibling directory whose
// name merely starts with the root's name is not mistaken for a child.
auto root_it = resolved_root.begin();
auto resolved_it = resolved.begin();
for (; root_it != resolved_root.end(); ++root_it, ++resolved_it) {
if (resolved_it == resolved.end() || *resolved_it != *root_it) {
return AIFOCORE_MAKE_STATUS(
aifocore::StatusCode::kInvalidArgument,
aifocore::fmt::format(
"Slide path '{}' resolves to '{}', outside the slide directory "
"'{}'",
relative.string(), resolved.string(), resolved_root.string()));
}
}

return resolved;
}

} // namespace io
} // namespace runtime

using runtime::io::ResolveContainedPath;

} // namespace fastslide

#endif // AIFO_FASTSLIDE_INCLUDE_FASTSLIDE_RUNTIME_IO_PATH_UTILS_H_
16 changes: 8 additions & 8 deletions package/versions.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"versions": [
{
"display": "FastSlide",
"id": "fastslide",
"type": "primary",
"version": "0.8.1"
}
]
"versions": [
{
"display": "FastSlide",
"id": "fastslide",
"type": "primary",
"version": "0.8.1"
}
]
}
33 changes: 33 additions & 0 deletions src/core/tile_plan_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -553,5 +553,38 @@ TEST(BatchTilePlanTest, DeduplicatedOperations) {
EXPECT_EQ(batch.GetUniqueOperations(), 1); // Only one unique tile
}

/// @brief The output format must never be wider than the source data type.
///
/// The tile sinks copy raw samples out of a decoded tile using the canvas's
/// sample width.
TEST(OutputPixelFormatTest, PreservesSampleWidth) {
const auto width_of = [](OutputSpec::PixelFormat format) -> size_t {
switch (format) {
case OutputSpec::PixelFormat::kUInt8:
return 1;
case OutputSpec::PixelFormat::kUInt16:
return 2;
case OutputSpec::PixelFormat::kUInt32:
case OutputSpec::PixelFormat::kFloat32:
return 4;
}
return 0;
};

for (const auto dtype :
{DataType::kUInt8, DataType::kUInt16, DataType::kInt16,
DataType::kUInt32, DataType::kInt32, DataType::kFloat32,
DataType::kFloat64}) {
EXPECT_LE(width_of(ToOutputPixelFormat(dtype)), GetDataTypeSize(dtype))
<< "output format wider than source for DataType "
<< static_cast<int>(dtype);
}

EXPECT_EQ(ToOutputPixelFormat(DataType::kInt16),
OutputSpec::PixelFormat::kUInt16);
EXPECT_EQ(ToOutputPixelFormat(DataType::kInt32),
OutputSpec::PixelFormat::kUInt32);
}

} // namespace core
} // namespace fastslide
22 changes: 22 additions & 0 deletions src/image_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -306,4 +306,26 @@ TEST(DataTypeFromBitsPerSampleTest, MapsCommonBitDepths) {
EXPECT_EQ(DataTypeFromBitsPerSample(32), DataType::kFloat32);
}

/// The chosen type must keep the storage width of the page it describes;
/// otherwise the tile sinks stride a decoded strip by the wrong sample size.
TEST(DataTypeFromSampleFormatTest, PreservesStorageWidth) {
constexpr uint16_t kUnsigned = 1;
constexpr uint16_t kSigned = 2;
constexpr uint16_t kFloat = 3;

for (const uint16_t bits : {uint16_t{8}, uint16_t{16}, uint16_t{32}}) {
for (const uint16_t format : {kUnsigned, kSigned, kFloat}) {
if (format == kFloat && bits < 32) {
continue; // No sub-32-bit IEEE format is defined.
}
EXPECT_EQ(GetDataTypeSize(DataTypeFromSampleFormat(bits, format)),
bits / 8u)
<< "bits=" << bits << " sample_format=" << format;
}
}

// Signed 8-bit has no exact DataType member; it must still stay one byte.
EXPECT_EQ(DataTypeFromSampleFormat(8, kSigned), DataType::kUInt8);
}

} // namespace fastslide
Loading
Loading