diff --git a/CMakeLists.txt b/CMakeLists.txt index 77a0d0a..41b3772 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,6 +158,11 @@ if(NOT CUCASCADE_TOPOLOGY_ONLY) pkg_check_modules(CURL REQUIRED IMPORTED_TARGET libcurl) find_package(OpenSSL REQUIRED) + # kvikIO — backs the local-file fallback ioctx (kvikio_context). Used + # directly (not via cudf) so the io library stays cudf-free. Not swappable: + # unlike moodycamel/invocable below there is no in-tree stand-in to replace. + find_package(kvikio REQUIRED CONFIG) + # cucascade_io_thirdparty carries the swappable moodycamel + invocable # (abseil) usage requirements from a single place; the io object library, # its installable static/shared variants, and their in-tree consumers @@ -402,7 +407,7 @@ if(CUCASCADE_BUILD_IO) # side by cuCascadeConfig.cmake (same names), mirroring the Numa::Numa # approach. set(CUCASCADE_IO_LINK_LIBS PkgConfig::LIBURING PkgConfig::CURL - OpenSSL::Crypto) + OpenSSL::Crypto kvikio::kvikio) target_link_libraries( cucascade_io_objects PUBLIC cucascade_objects ${CUCASCADE_IO_LINK_LIBS} diff --git a/include/cucascade/cudf/datasource.hpp b/include/cucascade/cudf/datasource.hpp index 9ec79a8..7a1a67b 100644 --- a/include/cucascade/cudf/datasource.hpp +++ b/include/cucascade/cudf/datasource.hpp @@ -159,4 +159,19 @@ class datasource : public cudf::io::datasource { [[nodiscard]] std::unique_ptr open_datasource(std::shared_ptr io_ctx, std::string path); +/// As above, forwarding @p hint to the backend's io_object resolution so it can, +/// e.g., prefetch a parquet footer in the same round-trip as the size +/// (@c open_hint::parquet_footer_probe). Backends that cannot act on the hint +/// fall back to the plain open. +[[nodiscard]] std::unique_ptr open_datasource(std::shared_ptr io_ctx, + std::string path, + open_hint hint); + +/// As above, with the object's size already known (e.g. from an S3 +/// ListObjectsV2 response), so a backend that can act on it skips its size +/// discovery entirely (no HEAD for object stores). +[[nodiscard]] std::unique_ptr open_datasource(std::shared_ptr io_ctx, + std::string path, + std::uint64_t known_size); + } // namespace cucascade::io diff --git a/include/cucascade/io/cache/metadata_store.hpp b/include/cucascade/io/cache/metadata_store.hpp index bdf3cf3..7d5240f 100644 --- a/include/cucascade/io/cache/metadata_store.hpp +++ b/include/cucascade/io/cache/metadata_store.hpp @@ -20,13 +20,34 @@ #include +#include +#include #include #include #include +#include #include namespace cucascade::io::cache { +namespace detail { + +/// Transparent hasher so the store can be looked up by @c std::string_view (or +/// @c const char*) without materialising a @c std::string. Paired with +/// @c std::equal_to<> below, this enables C++20 heterogeneous lookup on the +/// underlying @c unordered_map — without both, a string_view-taking getter +/// would just construct a temporary key on every call and be strictly worse +/// than taking @c std::string const&. +struct string_hash { + using is_transparent = void; + [[nodiscard]] std::size_t operator()(std::string_view sv) const noexcept + { + return std::hash{}(sv); + } +}; + +} // namespace detail + /** * @brief Thread-safe per-file metadata cache, keyed by an io_object's * raw_file_cache_id(). @@ -58,9 +79,19 @@ class metadata_store { /// miss. [[nodiscard]] std::shared_ptr get_metadata(io_object const& obj) const; + /// As above but keyed directly by @c raw_file_cache_id() — for callers that + /// know the path but have not built an io_object yet. Returns nullptr on miss. + /// Looked up heterogeneously, so passing a @c string_view or a string literal + /// allocates nothing. + [[nodiscard]] std::shared_ptr get_metadata(std::string_view cache_key) const; + private: mutable std::shared_mutex _mtx; - std::unordered_map> _by_key; + std::unordered_map, + detail::string_hash, + std::equal_to<>> + _by_key; }; } // namespace cucascade::io::cache diff --git a/include/cucascade/io/config.hpp b/include/cucascade/io/config.hpp index 78d13e8..250120b 100644 --- a/include/cucascade/io/config.hpp +++ b/include/cucascade/io/config.hpp @@ -18,6 +18,7 @@ #pragma once #include +#include #include #include #include @@ -34,6 +35,7 @@ namespace cucascade::io { * Sub-configs: * - @c local — uring reactor tunables (local-disk IO path). * - @c rest — REST reactor tunables (S3/object-store IO path). + * - @c kvikio — kvikIO fallback tunables (local-disk catch-all path). * - @c cache — prefetching cache tunables. * - @c object_store — object-store credentials and endpoint. */ @@ -57,6 +59,12 @@ struct io_config { /// retry policy, etc. rest::config rest{}; + /// kvikIO fallback configuration — thread-pool size, task/bounce sizing, + /// O_DIRECT, compat mode. All fields default to "unset", leaving kvikIO's + /// own env-var-seeded defaults in place. Note these are process-global once + /// applied; see @ref kvikio_config. + kvikio_config kvikio{}; + /// Prefetching cache configuration — in-flight budget, pool sizing, /// dispose-after-use policy. cache::config cache{}; diff --git a/include/cucascade/io/datasource_factory.hpp b/include/cucascade/io/datasource_factory.hpp index 2f59831..a73ec2a 100644 --- a/include/cucascade/io/datasource_factory.hpp +++ b/include/cucascade/io/datasource_factory.hpp @@ -38,17 +38,13 @@ namespace cucascade::io { // --------------------------------------------------------------------------- /** - * @brief Thread-safe registry mapping URI schemes to @c ioctx instances. + * @brief Thread-safe registry of @c ioctx backends, resolved by full path. * - * The engine constructs a registry at startup and populates it with one - * @c ioctx per backend (uring / gds / s3 / rdma_s3). The factory looks - * up the correct backend by URI scheme at datasource-creation time. - * - * Scheme matching is case-insensitive: @c register_ioctx and @c lookup both - * lowercase the scheme before storing / searching, matching the - * normalization done by @c cucascade::io::parse (RFC 3986 §3.1). Callers may - * register / look up with any casing — @c register_ioctx("S3", ...) and - * @c lookup("s3") refer to the same entry. + * The engine constructs a registry at startup and registers one entry per + * backend (kvikio / uring / restful), each carrying a path-capability checker. + * At datasource-creation time @c lookup_path runs the checkers against a full + * path (the checkers parse the URI / stat the filesystem themselves) and picks + * the backend, preferring an explicit backend over the kvikio catch-all. * * All operations are safe under concurrent reads; mutations take an exclusive * lock but are expected only at engine bootstrap / shutdown. @@ -75,17 +71,21 @@ class io_context_registry { using factory_type = std::function(const config_type&)>; /** - * @brief Register an ioctx for a scheme. Replaces any prior registration - * for the same scheme. + * @brief Register an ioctx backend. Replaces any prior registration for the + * same type. * - * The scheme is lowercased before storage; subsequent @c lookup calls - * with any casing of the same scheme resolve to this entry. - * @param type Opaque identifier for the ioctx type. Used by the engine to - * identify the backend. + * @param type Backend identifier (uring / restful / kvikio). + * @param checker Decides whether this backend claims a given path. + * @param factory Constructs the backend's ioctx; invoked by @c make_ioctx. */ void register_ioctx(io_context_type type, scheme_checker_type checker, factory_type factory); - std::optional lookup(std::string_view scheme) const noexcept; + /// Resolve the backend for a full @p path (not a bare scheme — the checkers + /// parse the URI / stat the filesystem themselves). Explicit backends + /// (uring / restful) take precedence over the kvikio catch-all, so `s3://` + /// never resolves to kvikio and a local file routes to uring before the + /// universal fallback. std::nullopt when nothing matches. + std::optional lookup_path(std::string_view path) const noexcept; std::shared_ptr make_ioctx(io_context_type type) const noexcept; diff --git a/include/cucascade/io/io_context.hpp b/include/cucascade/io/io_context.hpp index 04a4631..97d87b5 100644 --- a/include/cucascade/io/io_context.hpp +++ b/include/cucascade/io/io_context.hpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -36,7 +37,15 @@ namespace cucascade::io { -enum class io_context_type { uring, restful }; +enum class io_context_type { uring, restful, kvikio }; + +/// Hint passed to @c open_io_object so a backend can tailor how it resolves an +/// object's metadata. @c generic resolves the size however is cheapest for the +/// scheme (a HEAD for object stores). @c parquet_footer_probe asks the backend +/// to resolve the size *and* stash the object's trailing bytes in one +/// round-trip (a suffix-range GET), so the parquet footer reads that follow are +/// served locally instead of costing extra round-trips. +enum class open_hint { generic, parquet_footer_probe }; namespace cache { class prefetching_cache; @@ -102,6 +111,22 @@ class ioctx : public std::enable_shared_from_this { return create_io_object(std::move(path)); } + /// As above, forwarding @p hint to the backend's io_object resolution so it + /// can, e.g., prefetch a parquet footer in the same round-trip as the size. + [[nodiscard]] std::shared_ptr open_io_object(std::string path, open_hint hint) + { + return create_io_object(std::move(path), hint); + } + + /// As above, with the object's size already known (e.g. from an S3 + /// ListObjectsV2 response), so a backend that can act on it skips its size + /// discovery entirely (no HEAD for object stores). + [[nodiscard]] std::shared_ptr open_io_object(std::string path, + std::uint64_t known_size) + { + return create_io_object(std::move(path), known_size); + } + /// Whether this backend can serve reads for @p path. Backends should /// validate scheme/protocol support and any backend-specific /// preconditions (e.g. file existence for local-disk backends). @@ -263,6 +288,20 @@ class ioctx : public std::enable_shared_from_this { /// on unsupported / unreachable paths. virtual std::shared_ptr create_io_object(std::string path) = 0; + /// Hinted variant. The base implementation ignores @p hint and delegates to + /// the required @c create_io_object(path); a backend that can act on the hint + /// (e.g. rest_ioctx's suffix-range footer probe) overrides this. Kept a + /// distinct virtual — not a defaulted argument on the pure virtual above — so + /// the hint dispatches on the dynamic type instead of binding statically. + virtual std::shared_ptr create_io_object(std::string path, open_hint hint); + + /// Known-size variant. The base implementation ignores @p known_size and + /// delegates to the required @c create_io_object(path); a backend whose size + /// discovery would otherwise cost a round-trip overrides this to build the + /// io_object without one. Same distinct-virtual rationale as the hint + /// variant above. + virtual std::shared_ptr create_io_object(std::string path, std::uint64_t known_size); + /// Owned by this ioctx. Built by @ref initialize_cache, destroyed /// by @ref shutdown_cache (or the ioctx destructor as a safety net, /// though callers are expected to drive the lifecycle explicitly so diff --git a/include/cucascade/io/io_request.hpp b/include/cucascade/io/io_request.hpp index a6d7376..718eb7f 100644 --- a/include/cucascade/io/io_request.hpp +++ b/include/cucascade/io/io_request.hpp @@ -147,9 +147,7 @@ struct device_cpy_request { // Issue every copy on @p stream (a batch when there is more than one), then // record @p event once after the last so a single wait covers them all. - cudaError_t copy_async(uint8_t* host_buffer, - [[maybe_unused]] size_t bytes, - cudaEvent_t event = nullptr) noexcept + cudaError_t copy_async(uint8_t* host_buffer, size_t bytes, cudaEvent_t event = nullptr) noexcept { assert(host_buffer != nullptr && "Caller must provide a valid host buffer for the copy."); rmm::cuda_set_device_raii device_guard(rmm::cuda_device_id{device_id}); @@ -159,8 +157,24 @@ struct device_cpy_request { "Caller must provide a valid device destination buffer for the copy."); assert((c.src != nullptr || c.src_off + c.size <= bytes) && "Caller must ensure the copy fits in the host buffer."); - uint8_t* src_ptr = c.src != nullptr ? c.src : host_buffer + c.src_off; - err = cudaMemcpyAsync(c.dst, src_ptr, c.size, cudaMemcpyHostToDevice, stream); + // Resolve the host source. The asserts above are compiled out in release, + // so validate *before* forming the pointer: for a bounce-staged copy + // (c.src == nullptr) the source is host_buffer + c.src_off, but a null + // host_buffer or an out-of-range [src_off, src_off + size) would otherwise + // produce UB (nullptr + offset) or a wild in-range pointer that the + // near-null check below cannot catch. A null-buffer segment must reach + // here as c.src == nullptr, never as a non-null "nullptr + offset" pointer. + uint8_t* src_ptr = nullptr; + if (c.src != nullptr) { + src_ptr = c.src; + } else if (host_buffer != nullptr && c.src_off <= bytes && c.size <= bytes - c.src_off) { + src_ptr = host_buffer + c.src_off; + } + if (c.dst == nullptr || src_ptr == nullptr || + reinterpret_cast(src_ptr) < 4096U) { + return cudaErrorInvalidValue; + } + err = cudaMemcpyAsync(c.dst, src_ptr, c.size, cudaMemcpyHostToDevice, stream); if (err != cudaSuccess) { return err; } } if (event != nullptr) { err = cudaEventRecord(event, stream); } diff --git a/include/cucascade/io/kvikio/config.hpp b/include/cucascade/io/kvikio/config.hpp new file mode 100644 index 0000000..1fb12a2 --- /dev/null +++ b/include/cucascade/io/kvikio/config.hpp @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * + * 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. + */ + +#pragma once + +#include + +#include +#include + +// NOTE ON NAMESPACING: this lives in `cucascade::io`, not `cucascade::io::kvikio`, +// deliberately. A `cucascade::io::kvikio` namespace would shadow the upstream +// `::kvikio` namespace for every unqualified `kvikio::` use inside +// `cucascade::io` (e.g. `kvikio::FileHandle` in kvikio_context.hpp), forcing +// global qualification everywhere. The header still lives under io/kvikio/ so +// the file layout matches the uring / rest backends. +namespace cucascade::io { + +/** + * @brief Tunables for the kvikIO-backed local-file ioctx (@c kvikio_context). + * + * Every field is optional and means "leave kvikIO's own default alone". kvikIO + * seeds each setting from an environment variable at first use + * (@c KVIKIO_NTHREADS, @c KVIKIO_TASK_SIZE, ...), so an unset field here keeps + * that env-var value; an engaged field overrides it. This makes the config an + * explicit, in-process override layered on top of the env-var defaults rather + * than a replacement for them. + * + * @warning PROCESS-GLOBAL. Every field except @c compat_mode maps to a setter + * on kvikIO's @c kvikio::defaults singleton, so applying a config + * mutates state shared by ALL kvikIO users in the process — including + * other cuCascade ioctxs and any direct kvikIO use elsewhere. Two + * @c kvikio_context instances built with different configs do not get + * independent settings; the last one constructed wins. Treat this as + * startup configuration, applied once. + * + * @warning @c nthreads is especially disruptive: kvikIO's setter waits for all + * currently running tasks, destroys the pool, and rebuilds it. Do not + * change it while other kvikIO I/O is in flight. + * + * @c compat_mode is the exception — it is passed per @c FileHandle at open + * time, so it affects only files this ioctx opens and mutates nothing global. + * + * Write-side knobs (@c KVIKIO_AUTO_DIRECT_IO_WRITE) are intentionally absent: + * @c kvikio_context opens every file read-only, so they would be dead config. + */ +struct kvikio_config { + /// Threads in kvikIO's task pool — the parallelism bound for a single + /// @c pread (it splits the read into @c task_size chunks and runs them on + /// this pool). This is the local-file analogue of a connection count; kvikIO + /// has no per-file connection concept. Env: @c KVIKIO_NTHREADS (default 1). + /// Must be non-zero. + std::optional nthreads; + + /// Chunk size a parallel read is split into. Env: @c KVIKIO_TASK_SIZE + /// (default 4 MiB). Must be non-zero. When @c auto_direct_io_read is on, + /// keep this a multiple of the page size (typically 4 KiB) so tasks start at + /// page-aligned offsets — otherwise kvikIO falls back to buffered I/O for the + /// misaligned head/tail. + std::optional task_size; + + /// Minimum read size that goes through GDS + the thread pool; smaller reads + /// take a direct POSIX shortcut that skips the pool. Env: + /// @c KVIKIO_GDS_THRESHOLD (default 1 MiB). Zero is legal (always use GDS). + std::optional gds_threshold; + + /// Host staging buffer size for device reads that cannot go straight to GPU + /// memory. Env: @c KVIKIO_BOUNCE_BUFFER_SIZE (default 16 MiB). Must be + /// non-zero. + std::optional bounce_buffer_size; + + /// Use Direct I/O (@c O_DIRECT) for POSIX reads where possible. Env: + /// @c KVIKIO_AUTO_DIRECT_IO_READ. This is the O_DIRECT switch: it applies to + /// the POSIX path only — the cuFile/GDS path manages its own I/O mode — so it + /// matters most in compatibility mode or below @c gds_threshold. + std::optional auto_direct_io_read; + + /// For device reads, align offsets down and sizes up to page boundaries so + /// the whole transfer is pure Direct I/O, at the cost of reading extra bytes. + /// When false (kvikIO's default) the unaligned head/tail falls back to + /// buffered I/O instead. Env: @c KVIKIO_AUTO_DIRECT_IO_READ_OVERREAD. + /// Requires @c auto_direct_io_read to have any effect; device path only. + std::optional auto_direct_io_read_overread; + + /// Give each block device its own thread pool (each sized @c nthreads) + /// instead of sharing one global pool. Helps when reads span several + /// physical devices. Env: @c KVIKIO_THREAD_POOL_PER_BLOCK_DEVICE (default + /// false). Takes effect only for files opened after it is applied. + std::optional thread_pool_per_block_device; + + /// cuFile vs POSIX selection, applied PER FILE HANDLE (not global): + /// @c OFF enforces cuFile/GDS, @c ON enforces POSIX, @c AUTO tries cuFile and + /// falls back. Unset leaves it to kvikIO's own default, which honours + /// @c KVIKIO_COMPAT_MODE. + std::optional compat_mode; +}; + +/** + * @brief Push @p cfg's engaged fields into kvikIO's global @c defaults. + * + * Unset fields are left untouched, preserving kvikIO's env-var-seeded values. + * @c compat_mode is NOT applied here — it is per-handle and consumed at open + * time by @c kvikio_context::create_io_object. + * + * Called once by the @c kvikio_context constructor; exposed so an application + * that wants to configure kvikIO at startup (before any ioctx exists) can do + * the same thing explicitly. + * + * @throw std::invalid_argument on a zero @c nthreads, @c task_size, or + * @c bounce_buffer_size. + */ +void apply_kvikio_defaults(kvikio_config const& cfg); + +} // namespace cucascade::io diff --git a/include/cucascade/io/kvikio/kvikio_context.hpp b/include/cucascade/io/kvikio/kvikio_context.hpp new file mode 100644 index 0000000..2e5ec03 --- /dev/null +++ b/include/cucascade/io/kvikio/kvikio_context.hpp @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * + * 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. + */ + +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace cucascade::io { + +// --------------------------------------------------------------------------- +// kvikio_io_object +// --------------------------------------------------------------------------- + +/** + * @brief @c io_object that owns a kvikIO file handle. + * + * Owns the handle for the file's lifetime; @c kvikio_context's read primitives + * forward straight to it. kvikIO picks GDS or a POSIX/compat path per call + * based on the pointer type and its own compatibility mode, so this backend + * serves both host and device destinations from the same handle. + */ +class kvikio_io_object final : public io_object { + public: + kvikio_io_object(std::string path, kvikio::FileHandle handle, size_t file_size) + : _path(std::move(path)), _handle(std::move(handle)), _file_size(file_size) + { + } + + [[nodiscard]] const std::string& raw_file_cache_id() const noexcept final { return _path; } + [[nodiscard]] const std::string& object_path() const noexcept final { return _path; } + [[nodiscard]] size_t size() const noexcept final { return _file_size; } + + /// Mutable: kvikIO's read entry points are non-const, and the reads issued + /// through them do not mutate observable file state. + [[nodiscard]] kvikio::FileHandle& handle() const noexcept { return _handle; } + + private: + std::string _path; + mutable kvikio::FileHandle _handle; + size_t _file_size{0}; +}; + +// --------------------------------------------------------------------------- +// kvikio_context +// --------------------------------------------------------------------------- + +/** + * @brief Fallback @c ioctx backed directly by kvikIO (@c kvikio::FileHandle). + * + * The universal local-file backend: it claims any path, so the registry uses it + * only after the explicit backends (uring / rest) decline. Unlike those, it + * owns no reactors and no bounce staging — every read goes straight to a + * kvikIO handle, which internally chooses GDS or a POSIX/compat path. + * + * @note This is cuCascade's cudf-free equivalent of a cudf-datasource-backed + * fallback: the io library must not depend on cudf, so it drives kvikIO + * directly rather than through @c cudf::io::datasource. + * + * Capabilities: + * - @c supports_device_read: true (kvikIO reads into device memory, via GDS + * where the platform allows). + * - @c supports_vector_host_read: false — no batched dispatch path. + * - @c supports_host_to_device_read: false — no bounce-staging path. + * - @c preferred_prefetching_stage: @c none. + */ +class kvikio_context final : public ioctx { + public: + /// Construct with kvikIO left at its own (env-var-seeded) defaults. + kvikio_context() = default; + + /// Construct and apply @p cfg. Every field except @c compat_mode is pushed + /// into kvikIO's PROCESS-GLOBAL defaults — see @ref kvikio_config for the + /// sharing and ordering caveats. @c compat_mode is retained and applied per + /// file handle at open time instead. + /// + /// @throw std::invalid_argument on a zero @c nthreads, @c task_size, or + /// @c bounce_buffer_size. + explicit kvikio_context(kvikio_config cfg); + + ~kvikio_context() override + { + // See ioctx::pre_destroy — drains the cache (if any) while this derived + // part of the object is still alive. No reactors to tear down for + // kvikio_context, but the contract still applies. + this->pre_destroy(); + } + + [[nodiscard]] io_context_type type() const noexcept override { return io_context_type::kvikio; } + + void shutdown() noexcept override {} + + [[nodiscard]] bool supports(std::string_view path) const noexcept override; + [[nodiscard]] bool supports_device_read() const noexcept override { return true; } + [[nodiscard]] bool supports_vector_host_read() const noexcept override { return false; } + [[nodiscard]] bool supports_host_to_device_read() const noexcept override { return false; } + [[nodiscard]] cache::prefetching_stage preferred_prefetching_stage() const noexcept override + { + return cache::prefetching_stage::none; + } + + /// kvikIO applies no physical block alignment of its own, so ranges pass + /// through unchanged. + [[nodiscard]] std::vector align_and_coalesce( + std::span ranges, + std::optional /*alignment*/ = std::nullopt) const noexcept override; + + // -- Backend primitives --------------------------------------------------- + + size_t host_read_io(const io_object& obj, size_t offset, size_t size, uint8_t* dst) final; + + exec::semi_future host_read_async_io(const io_object& obj, + size_t offset, + size_t size, + uint8_t* dst) noexcept final; + + exec::semi_future device_read_async_io(const io_object& obj, + size_t offset, + size_t size, + uint8_t* dst, + rmm::cuda_stream_view stream) noexcept final; + + /// Unsupported: kvikIO has no bounce-staged host->device path here. Returns + /// a failed future rather than misbehaving silently. + exec::semi_future host_to_device_read_async_io( + const io_object& obj, + std::span slices, + size_t offset, + size_t size, + uint8_t* device_dst, + rmm::cuda_stream_view stream) noexcept final; + + /// Unsupported: no batched dispatch (hence @c supports_vector_host_read() + /// is false and the prefetching cache stays unarmed). + exec::semi_future host_read_ranges_async_io( + const io_object& obj, std::span segments) noexcept final; + + /// The config this context was built with (default-constructed when none was + /// supplied). Only @c compat_mode is still consulted after construction; the + /// rest already went into kvikIO's globals. + [[nodiscard]] kvikio_config const& config() const noexcept { return _config; } + + protected: + /// Backend hook invoked by @c ioctx::open_io_object: open @p path with kvikIO + /// and record its size. Applies @c config().compat_mode to the handle when + /// set. Throws when the file cannot be opened. + std::shared_ptr create_io_object(std::string path) override; + + private: + kvikio_config _config; +}; + +} // namespace cucascade::io diff --git a/include/cucascade/io/object_store_config.hpp b/include/cucascade/io/object_store_config.hpp index 4c0c847..c321f46 100644 --- a/include/cucascade/io/object_store_config.hpp +++ b/include/cucascade/io/object_store_config.hpp @@ -58,13 +58,6 @@ struct object_store_config { /// Verify the S3 endpoint's TLS certificate (peer + host). Default true; /// false disables verification — INSECURE, dev/test only. bool tls_verify = true; - - /// Select the async (libcurl-multi) S3 backend. Default true: the datasource - /// factory builds @c s3_ioctx (concurrent GETs + pipelined - /// device reads). Set false to fall back to the blocking @c s3_blocking_ioctx (a - /// per-request, serial-staging path) — the escape hatch if the async backend - /// misbehaves against a particular store. - bool s3_use_async_backend = true; }; inline bool string_to_enum(std::string_view sv, object_store_config::transport& t) diff --git a/include/cucascade/io/rest/authorizer.hpp b/include/cucascade/io/rest/authorizer.hpp index 33f1341..1a87596 100644 --- a/include/cucascade/io/rest/authorizer.hpp +++ b/include/cucascade/io/rest/authorizer.hpp @@ -18,9 +18,12 @@ #pragma once +#include + #include #include #include +#include #include #include @@ -115,6 +118,38 @@ class request_authorizer { [[nodiscard]] virtual authorized_request authorize(object_ref const& obj, request_method method, std::chrono::seconds timeout) = 0; + + /** + * @brief Authorize a bucket-level ListObjectsV2 GET. + * + * @param bucket Bucket name (no scheme / slashes). + * @param canonical_query The request query string, already percent-encoded, + * `&`-joined, and **sorted by encoded key** (SigV4 + * canonical order), WITHOUT any auth params — e.g. + * @c "list-type=2&max-keys=1000&prefix=a%2Fb" (with + * @c "continuation-token=..." sorted in first). The + * header-signing path signs this string verbatim, so + * an unsorted query would be signed but rejected by + * S3; the presigned path re-sorts when merging the + * @c X-Amz-* params, but callers should pass sorted + * regardless. Must not contain any @c X-Amz-* key — + * implementations reject those so callers cannot + * smuggle / override signing parameters. + * @param timeout Per-call URL lifetime (presigned @c X-Amz-Expires); + * ignored by header-signing authorizers. + * + * Default: throws — LIST is opt-in, so a pluggable authorizer that only knows + * how to sign object GET/HEAD need not implement it. + * + * @throw cucascade::io::credential_error when unsupported, or on signing failure. + */ + [[nodiscard]] virtual authorized_request authorize_list(std::string_view /*bucket*/, + std::string_view /*canonical_query*/, + std::chrono::seconds /*timeout*/) + { + throw cucascade::io::credential_error( + "request_authorizer: ListObjectsV2 is not supported by this authorizer"); + } }; } // namespace cucascade::io::rest diff --git a/include/cucascade/io/rest/config.hpp b/include/cucascade/io/rest/config.hpp index 0e38d68..efe2119 100644 --- a/include/cucascade/io/rest/config.hpp +++ b/include/cucascade/io/rest/config.hpp @@ -18,6 +18,8 @@ #pragma once +#include + #include #include @@ -84,6 +86,27 @@ struct config { std::chrono::milliseconds retry_backoff_base{50}; std::chrono::milliseconds retry_jitter{50}; bool honor_retry_after{true}; + + /// Suffix-range window (bytes) for the parquet footer probe + /// (@c open_hint::parquet_footer_probe): one `Range: bytes=-N` GET resolves the + /// object size and stashes its last N bytes, so cuDF's trailer/footer reads are + /// served locally. Tradeoff — a parquet footer is ~0.037% of the file (SF1 + /// lineitem 207 MB -> 78 KiB, SF10 2.2 GB -> 771 KiB): N must cover the footer, + /// else the probe wastes the suffix and re-GETs the footer body (worse than a + /// plain HEAD), so err large; the over-read when N exceeds the footer is a + /// one-time bind transfer (~10 ms on a high-bandwidth link). The 512 KiB + /// default covers files up to ~1.4 GB in one GET (the common range); raise it + /// for multi-GB single files, lower it for many-tiny-file / low-bandwidth + /// workloads. + std::size_t footer_probe_bytes{512UL << 10}; // 512 KiB + + /// S3 LIST / glob safety caps (both throw "narrow the glob prefix", never + /// truncate). @c list_max_matches bounds the files a glob keeps / a + /// whole-listing accumulates (result memory); @c list_max_scanned bounds the + /// objects a LIST sweep looks at across pages (time / LIST round-trips). The + /// two axes diverge when a prefix is huge but few keys match, so both exist. + std::size_t list_max_matches{s3::default_max_list_objects}; // 100'000 + std::size_t list_max_scanned{s3::default_max_scanned_objects}; // 1'000'000 }; } // namespace cucascade::io::rest diff --git a/include/cucascade/io/rest/mock_authorizer.hpp b/include/cucascade/io/rest/mock_authorizer.hpp new file mode 100644 index 0000000..c552ac1 --- /dev/null +++ b/include/cucascade/io/rest/mock_authorizer.hpp @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * + * 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. + */ + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace cucascade::io::rest { + +/** + * @brief Test-only @c request_authorizer that returns a canned + * @c authorized_request or throws. + * + * Header-only so test suites can include it without a separate library target. + * Produces deterministic output for unit tests of code that consumes + * @c request_authorizer through the abstract base class — typical pattern is: + * + * @code + * auto provider = std::make_shared( + * authorized_request{"https://canned/url", {}}); + * // ... wire it into a reactor_context and drive a read ... + * CHECK(provider->call_count() == 1); + * CHECK(provider->last_bucket() == "mybucket"); + * @endcode + * + * Default behavior: every call to @c authorize returns the + * @c authorized_request passed to the constructor verbatim (independent of + * @p obj / @p method) so tests can verify "the reactor passed our URL + + * headers to libcurl unchanged". To exercise error paths, call @c set_throw to + * make subsequent calls throw @c cucascade::io::credential_error. + * + * Thread safety: counters are atomic; @c last_bucket / @c last_key are + * guarded by an internal mutex. Safe to share across threads when tests + * exercise concurrent reactor paths. + */ +class mock_authorizer final : public request_authorizer { + public: + explicit mock_authorizer(authorized_request canned) : _canned(std::move(canned)) {} + + authorized_request authorize(object_ref const& obj, + request_method method, + std::chrono::seconds timeout) override + { + ++_call_count; + if (method == request_method::GET) ++_get_count; + if (method == request_method::HEAD) ++_head_count; + { + std::scoped_lock lk{_last_mtx}; + _last_bucket = obj.bucket; + _last_key = obj.key; + _last_timeout = timeout; + } + if (_should_throw.load()) { + std::string msg; + { + std::scoped_lock lk{_last_mtx}; + msg = _throw_msg.empty() ? std::string{"mock_authorizer: forced failure"} : _throw_msg; + } + throw credential_error(msg); + } + return _canned; + } + + /// Subsequent calls throw @c credential_error with @p msg (or default). + void set_throw(std::string msg = {}) + { + { + std::scoped_lock lk{_last_mtx}; + _throw_msg = std::move(msg); + } + _should_throw.store(true); + } + + /// Stop throwing. + void clear_throw() + { + _should_throw.store(false); + { + std::scoped_lock lk{_last_mtx}; + _throw_msg.clear(); + } + } + + [[nodiscard]] int call_count() const noexcept { return _call_count.load(); } + [[nodiscard]] int get_count() const noexcept { return _get_count.load(); } + [[nodiscard]] int head_count() const noexcept { return _head_count.load(); } + + [[nodiscard]] std::string last_bucket() const + { + std::scoped_lock lk{_last_mtx}; + return _last_bucket; + } + [[nodiscard]] std::string last_key() const + { + std::scoped_lock lk{_last_mtx}; + return _last_key; + } + [[nodiscard]] std::chrono::seconds last_timeout() const + { + std::scoped_lock lk{_last_mtx}; + return _last_timeout; + } + + private: + authorized_request _canned; + std::atomic _call_count{0}; + std::atomic _get_count{0}; + std::atomic _head_count{0}; + std::atomic _should_throw{false}; + mutable std::mutex _last_mtx; + std::string _last_bucket; + std::string _last_key; + std::chrono::seconds _last_timeout{0}; + std::string _throw_msg; +}; + +} // namespace cucascade::io::rest diff --git a/include/cucascade/io/rest/rest_ioctx.hpp b/include/cucascade/io/rest/rest_ioctx.hpp index a088b4f..78bc89e 100644 --- a/include/cucascade/io/rest/rest_ioctx.hpp +++ b/include/cucascade/io/rest/rest_ioctx.hpp @@ -19,11 +19,17 @@ #pragma once #include +#include #include #include +#include +#include #include +#include #include +#include +#include namespace cucascade::io::rest { @@ -51,11 +57,58 @@ class rest_ioctx : public templated_ioctx { [[nodiscard]] io_context_type type() const noexcept override { return io_context_type::restful; } + /// Stream a bucket's ListObjectsV2 pages under @p prefix to @p sink, one call + /// per page (a page holds at most 1000 entries, so peak memory is one page + /// regardless of bucket population). @p sink returns false to stop early — + /// no further LIST requests are issued. @p page_size is clamped to [1,1000] + /// (0 and >1000 mean 1000). Throws (never truncates) on a truncated page + /// without a continuation token, and once more than @p max_scanned entries + /// have been scanned across pages (bounds time / request count on a prefix + /// whose population dwarfs the caller's matches). + void list_objects_paged(std::string_view bucket, + std::string_view prefix, + std::size_t page_size, + std::function const& sink, + std::optional max_scanned = std::nullopt); + + /// Whole-listing convenience over @c list_objects_paged: every object under + /// @p prefix, in document order, with sizes. Throws (never truncates) when + /// the accumulated entries would exceed @p max_keys — a partial key set would + /// resolve a glob to a silently incomplete table. + [[nodiscard]] std::vector list_objects( + std::string_view bucket, + std::string_view prefix, + std::size_t page_size = 1000, + std::optional max_keys = std::nullopt); + + /// The configured matched cap (@c config.list_max_matches) — exposed so a + /// glob layer one level up can bound its match set without a reactor handle. + /// Falls back to the built-in default when the pool is empty (never in + /// practice). + [[nodiscard]] std::size_t list_max_matches() const; + protected: - /// Backend hook invoked by @c ioctx::open_datasource: parse @p path + /// Backend hook invoked by @c ioctx::open_io_object: parse @p path /// (s3://bucket/key), HEAD it for the size, and build a @c rest_io_object. /// Throws on a non-s3 scheme or a failed HEAD. std::shared_ptr create_io_object(std::string path) override; + + /// Hinted open: @c open_hint::parquet_footer_probe resolves the size AND stashes + /// the object's trailing bytes in one suffix-range GET, stashed on the + /// returned io_object; every other hint falls back to the plain HEAD path above. + std::shared_ptr create_io_object(std::string path, open_hint hint) override; + + /// Known-size open: the caller already learned the object's size (e.g. from a + /// ListObjectsV2 response), so the io_object is built with ZERO network — no + /// HEAD, no probe. + std::shared_ptr create_io_object(std::string path, std::uint64_t known_size) override; + + private: + /// Resolve @p path with a single suffix-range GET: it discovers the size and + /// stashes the object's trailing bytes on the returned io_object so cuDF's + /// footer reads are served locally. Falls back to a plain HEAD (no stash) + /// when the response is unusable. + std::shared_ptr create_footer_probe_object(std::string path); }; } // namespace cucascade::io::rest diff --git a/include/cucascade/io/rest/rest_reactor.hpp b/include/cucascade/io/rest/rest_reactor.hpp index a282553..64c5f4c 100644 --- a/include/cucascade/io/rest/rest_reactor.hpp +++ b/include/cucascade/io/rest/rest_reactor.hpp @@ -42,6 +42,69 @@ namespace cucascade::io::rest { +/// Parse the total object length out of a Content-Range value of the form +/// "bytes -/". Returns nullopt when the unit is not +/// "bytes", the range is unsatisfied ("bytes */..."), or the total is unknown +/// ("*") — i.e. any response the footer probe cannot trust. +[[nodiscard]] std::optional content_range_total(std::string_view content_range); + +// --------------------------------------------------------------------------- +// shared_byte_span +// --------------------------------------------------------------------------- + +namespace detail { + +/// Owns a byte buffer plus a span over it. Exists so @ref make_shared_byte_span +/// can hand out a shared_ptr to the *span* (via the aliasing constructor) while +/// the shared_ptr's control block keeps the *buffer* alive. Never held +/// directly by callers. +struct byte_storage { + std::vector bytes; + std::span view; + + // `bytes` is declared first, so it is already initialised when `view` binds + // to it — the span never sees a moved-from buffer. + explicit byte_storage(std::vector b) : bytes(std::move(b)), view(bytes) {} + + // Non-copyable, non-movable: `view` points into `bytes`, so copying would + // deep-copy the buffer and leave the copy's span aimed at the original's + // allocation. Only ever built in place by make_shared, so neither is needed. + byte_storage(byte_storage const&) = delete; + byte_storage& operator=(byte_storage const&) = delete; + byte_storage(byte_storage&&) = delete; + byte_storage& operator=(byte_storage&&) = delete; +}; + +} // namespace detail + +/// A shared, immutable view over a byte buffer. +/// +/// Deliberately a span rather than a @c vector: consumers only ever read +/// through it (@c data / @c size / @c subspan), so exposing the container type — +/// and with it its allocator, growth policy and mutation API — would leak an +/// implementation detail into the interface. Ownership still rides along: the +/// shared_ptr is built with the aliasing constructor, so the control block +/// retains the underlying buffer while the pointer itself refers to the span. +using shared_byte_span = std::shared_ptr>; + +/// Take ownership of @p bytes and return a @ref shared_byte_span over it. +/// A single allocation: the buffer and its span live in one control block. +[[nodiscard]] shared_byte_span make_shared_byte_span(std::vector bytes); + +// --------------------------------------------------------------------------- +// footer_probe +// --------------------------------------------------------------------------- + +/// Result of a suffix-range footer probe: the object's total size plus the +/// trailing window [window_lo, object_size) captured in @c bytes. @c bytes is +/// null when the probe could not be satisfied (the caller then falls back to a +/// HEAD). Shared, not copied, with the io_object that carries it for this open. +struct footer_probe { + std::size_t object_size{0}; + std::size_t window_lo{0}; + shared_byte_span bytes; +}; + // --------------------------------------------------------------------------- // rest_io_object // --------------------------------------------------------------------------- @@ -60,6 +123,24 @@ class rest_io_object : public io_object { { } + /// As above, but carrying a suffix-range footer stash: @p stash holds the + /// object's bytes over [window_lo, object_size), so @c rest_reactor::host_read + /// serves any read fully inside that window from memory instead of a GET. + rest_io_object(std::string path, + std::string bucket, + std::string key, + size_t object_size, + size_t window_lo, + shared_byte_span stash) + : _path(std::move(path)), + _bucket(std::move(bucket)), + _key(std::move(key)), + _file_size(object_size), + _window_lo(window_lo), + _stash(std::move(stash)) + { + } + [[nodiscard]] const std::string& raw_file_cache_id() const noexcept override { return _path; } [[nodiscard]] const std::string& object_path() const noexcept override { return _path; } [[nodiscard]] size_t size() const noexcept override { return _file_size; } @@ -68,11 +149,19 @@ class rest_io_object : public io_object { [[nodiscard]] const std::string& key() const noexcept { return _key; } [[nodiscard]] object_ref get_object_ref() const { return object_ref{_bucket, _key}; } + /// Trailing bytes prefetched at open (a suffix-range footer probe), or null + /// when the object was opened without one. A read fully inside + /// [stash_window_lo, size) is served from here by @c host_read. + [[nodiscard]] shared_byte_span const& stash() const noexcept { return _stash; } + [[nodiscard]] size_t stash_window_lo() const noexcept { return _window_lo; } + private: std::string _path; std::string _bucket; std::string _key; size_t _file_size{0}; + size_t _window_lo{0}; + shared_byte_span _stash; }; // --------------------------------------------------------------------------- @@ -190,6 +279,24 @@ class rest_reactor { /// an @c rest_io_object. @p bucket / @p key identify the object. size_t head_object_size(std::string_view bucket, std::string_view key); + /// Blocking suffix-range GET of the last @p n bytes of an object, resolving + /// the size and stashing the parquet footer in a single round-trip. On a + /// well-formed 206 the returned @c footer_probe carries the object size, the + /// window origin, and the trailing bytes; on any unusable response (200 full + /// body, missing / unsatisfied Content-Range) @c bytes is null so the caller + /// falls back to a HEAD. @p bucket / @p key identify the object. + footer_probe fetch_footer_suffix(std::string_view bucket, std::string_view key, std::size_t n); + + /// Blocking bucket-level ListObjectsV2 GET for one page: returns the raw XML + /// body on HTTP 200. @p canonical_query is the pre-encoded, key-sorted + /// request query (no auth params — authorization is added via + /// @c authorize_list). @p prefix is only for retry-log / error text. + /// Control-plane op: retries are WARN-logged like every retry loop here, but + /// the XML body is never treated as object-read payload. + std::string list_page(std::string_view bucket, + std::string_view prefix, + std::string_view canonical_query); + // -- capabilities / factory ---------------------------------------------- /// True iff @p path is an s3:// URL this reactor can serve. @@ -203,7 +310,7 @@ class rest_reactor { { // Network round-trips are high-latency; read ahead on demand rather than // eagerly prefilling the whole working set. - return cache::prefetching_stage::opportunistic; + return cache::prefetching_stage::just_in_time; } /// REST has no physical block alignment, so this only coalesces overlapping / diff --git a/include/cucascade/io/rest/s3/list_parser.hpp b/include/cucascade/io/rest/s3/list_parser.hpp new file mode 100644 index 0000000..f8f8f66 --- /dev/null +++ b/include/cucascade/io/rest/s3/list_parser.hpp @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * + * 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. + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace cucascade::io::rest::s3 { + +/// Default cap on the number of entries a whole-listing call will accumulate. +/// Exceeding it throws (never truncates) — a partial key set would resolve a +/// glob to a silently incomplete table. +inline constexpr std::size_t default_max_list_objects = 100'000; + +/// Default cap on the total number of entries a paged listing will scan across +/// all pages. Bounds the scanned-object count and request cost on a prefix +/// whose population dwarfs the matches. +inline constexpr std::size_t default_max_scanned_objects = 1'000'000; + +/// One object from a ListObjectsV2 page: full key + object size in bytes. +/// The size rides the LIST response for free, so downstream opens need no +/// size-discovery round-trip. +struct list_entry { + std::string key; + std::uint64_t size = 0; +}; + +/// One parsed page of an S3 ListObjectsV2 response. +struct list_objects_v2_page { + /// Every `` object in document order, XML-entity-unescaped. + /// Excludes `` entries (directory rollups, not keys). + std::vector entries; + /// `` — true when another page follows. + bool is_truncated = false; + /// `` — the cursor for the next page; empty when absent. + std::string next_continuation_token; +}; + +/** + * @brief Parse one complete ListObjectsV2 response. + * + * Hand-rolled (no XML dependency). Accepts an optional XML declaration and root + * attributes, and unescapes the five predefined XML entities. Fails closed so a + * malformed body never parses as a silently-incomplete listing. + * + * @throws std::runtime_error for malformed roots, entries, sizes, or paging + * fields. + */ +list_objects_v2_page parse_list_objects_v2(std::string_view xml); + +} // namespace cucascade::io::rest::s3 diff --git a/include/cucascade/io/rest/s3/sigv4.hpp b/include/cucascade/io/rest/s3/sigv4.hpp index 3c52ffa..9d183a9 100644 --- a/include/cucascade/io/rest/s3/sigv4.hpp +++ b/include/cucascade/io/rest/s3/sigv4.hpp @@ -107,6 +107,16 @@ sigv4_signed_request sign_request( * Passed explicitly so tests are deterministic. * @param ttl Validity window. Becomes the @c X-Amz-Expires query * parameter (in seconds). + * @param extra_canonical_query Additional request query parameters, already + * RFC3986-encoded, `&`-joined, and in SigV4 canonical + * (byte-sorted) order (e.g. + * @c "list-type=2&max-keys=1000&prefix=p%2F" for a + * ListObjectsV2 request). Empty for plain object GET/HEAD. + * These are merged with the @c X-Amz-* parameters and the + * whole set is re-sorted before signing, so they + * participate in the signature (required for S3 to accept + * a presigned LIST). Each element is taken verbatim — pass + * it pre-encoded, not raw. * * @throw std::invalid_argument on empty credentials / region / service / * method / scheme / host, or non-positive @p ttl. @@ -117,7 +127,8 @@ std::string presign_url(std::string_view method, std::string_view canonical_uri, sigv4_signer_config const& creds, std::time_t timestamp_utc, - std::chrono::seconds ttl); + std::chrono::seconds ttl, + std::string_view extra_canonical_query = {}); /// Hex-encoded SHA256 digest of @p data. Thin wrapper around OpenSSL SHA256. std::string sha256_hex(std::string_view data); diff --git a/include/cucascade/io/rest/s3/sigv4_authorizer.hpp b/include/cucascade/io/rest/s3/sigv4_authorizer.hpp index a4b5f24..4068dcd 100644 --- a/include/cucascade/io/rest/s3/sigv4_authorizer.hpp +++ b/include/cucascade/io/rest/s3/sigv4_authorizer.hpp @@ -78,6 +78,15 @@ class sigv4_presigned_authorizer final : public sigv4_authorizer_base { request_method method, std::chrono::seconds timeout) override; + /// Presigned bucket-level ListObjectsV2: the request params are merged into + /// the signed query, so the returned URL carries both the list params and the + /// X-Amz-* auth params; headers are empty. + /// @throw cucascade::io::credential_error on empty bucket, an X-Amz-* key + /// inside @p canonical_query (signing-param smuggling), or SigV4 failure. + authorized_request authorize_list(std::string_view bucket, + std::string_view canonical_query, + std::chrono::seconds timeout) override; + private: std::chrono::seconds _ttl; }; @@ -105,6 +114,16 @@ class sigv4_header_authorizer final : public sigv4_authorizer_base { authorized_request authorize(object_ref const& obj, request_method method, std::chrono::seconds timeout) override; + + /// Header-signed bucket-level ListObjectsV2: returns a plain + /// @c "{scheme}://{host}/{bucket}?{canonical_query}" URL plus the signed + /// Authorization / x-amz-* headers. @c timeout is unused (header auth carries + /// no explicit expiry). + /// @throw cucascade::io::credential_error on empty bucket, an X-Amz-* key + /// inside @p canonical_query (signing-param smuggling), or SigV4 failure. + authorized_request authorize_list(std::string_view bucket, + std::string_view canonical_query, + std::chrono::seconds timeout) override; }; } // namespace cucascade::io::rest::s3 diff --git a/include/cucascade/io/templated_ioctx.hpp b/include/cucascade/io/templated_ioctx.hpp index 6446fe9..e28976d 100644 --- a/include/cucascade/io/templated_ioctx.hpp +++ b/include/cucascade/io/templated_ioctx.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -251,7 +252,9 @@ class templated_ioctx : public ioctx { try { r->shutdown(); } catch (const std::exception& e) { + CUCASCADE_LOG_ERROR("templated_ioctx: reactor shutdown failed: {}", e.what()); } catch (...) { + CUCASCADE_LOG_ERROR("templated_ioctx: reactor shutdown failed: unknown error"); } } } diff --git a/include/cucascade/io/uri_parser.hpp b/include/cucascade/io/uri_parser.hpp index fe3cdee..951ee81 100644 --- a/include/cucascade/io/uri_parser.hpp +++ b/include/cucascade/io/uri_parser.hpp @@ -32,14 +32,17 @@ namespace cucascade::io { * - @c host is the authority verbatim (no percent-decoding); may contain * @c ":port" (e.g. `bucket:9000`). Empty for schemes without an authority * (the `file` scheme and bare absolute paths). - * - @c path is percent-decoded. For object-store schemes (s3/gs/azure) it - * is the object key after stripping exactly one bucket/key separator - * slash; further leading slashes are part of the key per S3 REST - * semantics (e.g. `s3://b/k` -> `k`, `s3://b//k` -> `/k`, - * `s3://b///k` -> `//k`). For the `file` scheme it keeps its leading - * `/`. + * - @c path is percent-decoded EXCEPT for the `s3` scheme, whose key is taken + * literally (AWS-CLI semantics): `s3://b/a%20b` -> `a%20b`, and `?`/`#` are + * ordinary key bytes, not delimiters. For the other object-store schemes + * (gs/azure/http/https/rdma_s3) @c path is the percent-decoded object key. + * All object-store schemes strip exactly one bucket/key separator slash; + * further leading slashes are part of the key per S3 REST semantics (e.g. + * `s3://b/k` -> `k`, `s3://b//k` -> `/k`, `s3://b///k` -> `//k`). For the + * `file` scheme @c path keeps its leading `/`. * - @c query holds percent-decoded values. Duplicate keys are last-wins * (unordered_map cannot represent multi-values; matches AWS SDK behavior). + * The `s3` scheme never populates @c query (its `?...` is literal key bytes). */ struct parsed_uri { std::string scheme; @@ -52,11 +55,13 @@ struct parsed_uri { * @brief Parse @p uri into a @c parsed_uri. * * Supported shapes: - * - `s3://bucket/key`, `s3://bucket/key?region=us-west-2` - * - `gs://bucket/key`, `azure://container/blob` + * - `s3://bucket/key` — key taken LITERALLY (no percent-decode, no `?`/`#` + * split): `s3://b/a%20b` opens the object whose key is `a%20b` + * - `gs://bucket/key`, `azure://container/blob` (percent-decoded key + query) * - `file:///abs/path`, bare absolute `/abs/path` - * - Uppercase schemes (normalized to lowercase) - * - Fragments (`#...`) are silently stripped + * - Uppercase schemes (normalized to lowercase; `S3://` takes the literal path) + * - Fragments (`#...`) are silently stripped for non-s3 schemes; for s3 a `#` + * is a literal key byte * - Exactly one bucket/key separator slash is consumed; any further * leading slashes survive into the key (`s3://b/k` -> `k`; * `s3://b//k` -> `/k`; `s3://b///k` -> `//k`) @@ -66,8 +71,9 @@ struct parsed_uri { * - empty scheme (`://foo`) * - relative bare path (`relative/x`, `./x`) * - empty object key (`s3://bucket`, `s3://bucket/`) - * - empty query key (`?=val`) - * - malformed percent-encoding (`%ZZ`, truncated `%A`) + * - empty query key (`?=val`) — non-s3 schemes only + * - malformed percent-encoding (`%ZZ`, truncated `%A`) — non-s3 schemes only + * (for s3 these are valid literal key bytes) */ parsed_uri parse(std::string_view uri); diff --git a/src/cudf/datasource.cpp b/src/cudf/datasource.cpp index 76143c4..22353a3 100644 --- a/src/cudf/datasource.cpp +++ b/src/cudf/datasource.cpp @@ -216,4 +216,22 @@ std::unique_ptr open_datasource(std::shared_ptr io_ctx, std:: return std::make_unique(std::move(io_ctx), std::move(obj)); } +std::unique_ptr open_datasource(std::shared_ptr io_ctx, + std::string path, + open_hint hint) +{ + if (!io_ctx) { throw std::invalid_argument("open_datasource: io_ctx must be non-null"); } + auto obj = io_ctx->open_io_object(std::move(path), hint); + return std::make_unique(std::move(io_ctx), std::move(obj)); +} + +std::unique_ptr open_datasource(std::shared_ptr io_ctx, + std::string path, + std::uint64_t known_size) +{ + if (!io_ctx) { throw std::invalid_argument("open_datasource: io_ctx must be non-null"); } + auto obj = io_ctx->open_io_object(std::move(path), known_size); + return std::make_unique(std::move(io_ctx), std::move(obj)); +} + } // namespace cucascade::io diff --git a/src/io/CMakeLists.txt b/src/io/CMakeLists.txt index 0e2ed5e..d645721 100644 --- a/src/io/CMakeLists.txt +++ b/src/io/CMakeLists.txt @@ -25,8 +25,10 @@ target_sources( ${CMAKE_CURRENT_SOURCE_DIR}/rest/rest_reactor.cpp ${CMAKE_CURRENT_SOURCE_DIR}/uring/uring_ioctx.cpp ${CMAKE_CURRENT_SOURCE_DIR}/uring/uring_reactor.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/kvikio/kvikio_context.cpp ${CMAKE_CURRENT_SOURCE_DIR}/rest/s3/sigv4.cpp ${CMAKE_CURRENT_SOURCE_DIR}/rest/s3/sigv4_authorizer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/rest/s3/list_parser.cpp ${CMAKE_CURRENT_SOURCE_DIR}/cache/types.cpp ${CMAKE_CURRENT_SOURCE_DIR}/cache/metadata_store.cpp ${CMAKE_CURRENT_SOURCE_DIR}/cache/prefetching_cache.cpp) diff --git a/src/io/cache/metadata_store.cpp b/src/io/cache/metadata_store.cpp index 109580a..86a885f 100644 --- a/src/io/cache/metadata_store.cpp +++ b/src/io/cache/metadata_store.cpp @@ -19,6 +19,7 @@ #include #include +#include #include namespace cucascade::io::cache { @@ -34,9 +35,15 @@ void metadata_store::register_metadata(io_object const& obj, std::shared_ptr metadata_store::get_metadata(io_object const& obj) const { - auto const& key = obj.raw_file_cache_id(); + return get_metadata(obj.raw_file_cache_id()); +} + +std::shared_ptr metadata_store::get_metadata(std::string_view cache_key) const +{ std::shared_lock lk(_mtx); - auto it = _by_key.find(key); + // Heterogeneous find (transparent hash + std::equal_to<>): no temporary + // std::string is built for the probe. + auto it = _by_key.find(cache_key); if (it == _by_key.end()) return nullptr; return it->second; } diff --git a/src/io/cache/prefetching_cache.cpp b/src/io/cache/prefetching_cache.cpp index a994170..647aead 100644 --- a/src/io/cache/prefetching_cache.cpp +++ b/src/io/cache/prefetching_cache.cpp @@ -639,6 +639,7 @@ std::string prefetching_cache::summary() const void prefetching_cache::prepare_for_query() noexcept { + CUCASCADE_LOG_TRACE("prefetching_cache: summary of cache performance {}", summary()); _ticker.fetch_add(1, std::memory_order_relaxed); // Snapshot the counters so the next summary() can report this cycle's deltas. @@ -656,6 +657,7 @@ void prefetching_cache::prepare_for_query() noexcept void prefetching_cache::prepare_loop(const std::stop_token& st) { std::stop_callback cb(st, [this]() { + CUCASCADE_LOG_TRACE("prefetching_cache: prepare_loop received stop request, unblocking queue"); _preparation_queue.enqueue(nullptr); // unblock the worker if it's waiting on an empty queueue }); @@ -697,6 +699,10 @@ void prefetching_cache::prepare_loop(const std::stop_token& st) c->numa_node = numa_allocated; if (!c->state.mark_allocated()) { buffers.push_back(buffer); // return the buffer to the pool + CUCASCADE_LOG_ERROR( + "prefetching_cache: chunk at offset {} was marked queued but failed to mark " + "allocated", + c->offset); } } } @@ -721,6 +727,7 @@ void prefetching_cache::prepare_loop(const std::stop_token& st) void prefetching_cache::prefetch_loop(const std::stop_token& st) { std::stop_callback cb(st, [this]() { + CUCASCADE_LOG_TRACE("prefetching_cache: prefetch_loop received stop request, unblocking queue"); _prefetch_queue.enqueue(nullptr); // unblock the worker if it's waiting on an empty queueue }); while (!_shutting_down && !st.stop_requested()) { @@ -752,6 +759,7 @@ void prefetching_cache::prefetch_loop(const std::stop_token& st) if (req->is_cancelled() || st.stop_requested()) { std::ranges::for_each(allocated_chunks, [](cached_chunk* c) { std::ignore = c->state.mark_load_failed(); }); + std::ignore = req->state->mark_load_failed(); continue; } @@ -771,6 +779,7 @@ void prefetching_cache::prefetch_loop(const std::stop_token& st) void prefetching_cache::evict_loop(const std::stop_token& st) { std::stop_callback cb(st, [this]() { + CUCASCADE_LOG_TRACE("prefetching_cache: evict_loop received stop request, unblocking queue"); _eviction_queue.enqueue(nullptr); // unblock the worker if it's waiting on an empty queueue }); diff --git a/src/io/datasource_factory.cpp b/src/io/datasource_factory.cpp index fe105eb..edefff4 100644 --- a/src/io/datasource_factory.cpp +++ b/src/io/datasource_factory.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -77,6 +78,20 @@ std::shared_ptr make_s3_authorizer(const object_store_ using scheme_checker_type = io_context_registry::scheme_checker_type; using factory_type = io_context_registry::factory_type; +factory_type make_kvikio_ioctx_factory() +{ + return [](const io_config& config) -> std::shared_ptr { + try { + // Applies config.kvikio to kvikIO's process-global defaults (see + // kvikio_config): unset fields keep kvikIO's env-var-seeded values. + return std::make_shared(config.kvikio); + } catch (const std::exception& e) { + CUCASCADE_LOG_ERROR("make_kvikio_ioctx_factory: construction failed: {}", e.what()); + return nullptr; + } + }; +} + factory_type make_uring_ioctx_factory( cucascade::memory::memory_reservation_manager& reservation_manager) { @@ -143,7 +158,13 @@ io_context_registry::io_context_registry( : _config(std::move(config)), _reservation_manager(reservation_manager) { // uring / rest claim paths via their reactor's static supports() (local - // files and s3:// URLs respectively). + // files and s3:// URLs respectively). kvikio is the universal fallback — + // it can open any local path — so it matches everything and lookup_path + // defers it behind the explicit backends. + _entries.emplace( + io_context_type::kvikio, + entry{ + io_context_type::kvikio, [](std::string_view) { return true; }, make_kvikio_ioctx_factory()}); _entries.emplace(io_context_type::uring, entry{io_context_type::uring, &uring::uring_reactor::supports, @@ -164,13 +185,22 @@ void io_context_registry::register_ioctx(io_context_type type, _entries[type] = {type, std::move(checker), std::move(factory)}; } -std::optional io_context_registry::lookup(std::string_view scheme) const noexcept +std::optional io_context_registry::lookup_path( + std::string_view path) const noexcept { std::shared_lock lk{_mtx}; + // kvikio's checker matches everything; _entries iterates in unspecified order, + // so defer the catch-all and let an explicit backend (uring/restful) win. + std::optional fallback; for (const auto& [type, entry] : _entries) { - if (entry.checker(scheme)) return type; + if (!entry.checker(path)) continue; + if (type == io_context_type::kvikio) { + fallback = type; + continue; + } + return type; } - return std::nullopt; + return fallback; } std::shared_ptr io_context_registry::make_ioctx(io_context_type type) const noexcept diff --git a/src/io/io_context.cpp b/src/io/io_context.cpp index 5349e73..962e937 100644 --- a/src/io/io_context.cpp +++ b/src/io/io_context.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -39,20 +40,39 @@ void ioctx::initialize_cache( { // One-shot. Repeated calls are silent no-ops so callers can be // robust to multiple wiring sites. - if (_cache) { return; } - if (!can_use_prefetching_cache()) { return; } + if (_cache) { + CUCASCADE_LOG_WARN("ioctx::initialize_cache() called but prefetching_cache already present"); + return; + } + if (!can_use_prefetching_cache()) { + CUCASCADE_LOG_WARN( + "ioctx::initialize_cache() called but backend does not support vector host read"); + return; + } try { _cache = std::make_unique( reservation_manager, this, cache_config, std::move(topology_index)); } catch (const std::exception& e) { + CUCASCADE_LOG_ERROR("prefetching_cache construction failed: {}", e.what()); _cache.reset(); } catch (...) { + CUCASCADE_LOG_ERROR("prefetching_cache construction failed: unknown error"); _cache.reset(); } } void ioctx::shutdown_cache() noexcept { _cache.reset(); } +std::shared_ptr ioctx::create_io_object(std::string path, open_hint /*hint*/) +{ + return create_io_object(std::move(path)); +} + +std::shared_ptr ioctx::create_io_object(std::string path, std::uint64_t /*known_size*/) +{ + return create_io_object(std::move(path)); +} + size_t ioctx::host_read( const io_object& obj, size_t offset, size_t size, uint8_t* dst, cache::prefetching_handle* handle) { diff --git a/src/io/kvikio/kvikio_context.cpp b/src/io/kvikio/kvikio_context.cpp new file mode 100644 index 0000000..f6cf5bb --- /dev/null +++ b/src/io/kvikio/kvikio_context.cpp @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * + * 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. + */ + +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace cucascade::io { + +namespace { + +const kvikio_io_object& as_kvikio(const io_object& obj) +{ + // Concrete type is enforced by create_io_object below; a mismatch is a + // programmer error (e.g. mixing io_objects across backends), not user + // input, so a static_cast is appropriate. + return static_cast(obj); +} + +} // namespace + +void apply_kvikio_defaults(kvikio_config const& cfg) +{ + // Validate before touching anything so a bad config leaves kvikIO's globals + // untouched rather than half-applied. + if (cfg.nthreads && *cfg.nthreads == 0) { + throw std::invalid_argument("kvikio_config: nthreads must be non-zero"); + } + if (cfg.task_size && *cfg.task_size == 0) { + throw std::invalid_argument("kvikio_config: task_size must be non-zero"); + } + if (cfg.bounce_buffer_size && *cfg.bounce_buffer_size == 0) { + throw std::invalid_argument("kvikio_config: bounce_buffer_size must be non-zero"); + } + + // Only engaged fields are pushed, so an unset field keeps whatever kvikIO + // seeded from its environment variable. + // + // Order matters for the two thread-pool knobs: set the per-block-device flag + // first so that pools created afterwards are sized by the nthreads below, + // rather than rebuilding a global pool we are about to replace anyway. + if (cfg.thread_pool_per_block_device) { + kvikio::defaults::set_thread_pool_per_block_device(*cfg.thread_pool_per_block_device); + } + if (cfg.nthreads) { kvikio::defaults::set_thread_pool_nthreads(*cfg.nthreads); } + if (cfg.task_size) { kvikio::defaults::set_task_size(*cfg.task_size); } + if (cfg.gds_threshold) { kvikio::defaults::set_gds_threshold(*cfg.gds_threshold); } + if (cfg.bounce_buffer_size) { kvikio::defaults::set_bounce_buffer_size(*cfg.bounce_buffer_size); } + if (cfg.auto_direct_io_read) { + kvikio::defaults::set_auto_direct_io_read(*cfg.auto_direct_io_read); + } + if (cfg.auto_direct_io_read_overread) { + kvikio::defaults::set_auto_direct_io_read_overread(*cfg.auto_direct_io_read_overread); + } + // compat_mode is deliberately NOT set globally — it rides the FileHandle + // constructor in create_io_object so it scopes to this ioctx's files. +} + +kvikio_context::kvikio_context(kvikio_config cfg) : _config(std::move(cfg)) +{ + apply_kvikio_defaults(_config); +} + +std::shared_ptr kvikio_context::create_io_object(std::string path) +{ + // Read-only: this ioctx serves the scan path only. The handle owns the fd + // (and any cuFile registration) for the io_object's lifetime, and the + // io_object outlives any single datasource wrapping it. + // + // compat_mode is passed per handle (rather than through kvikio::defaults) so + // it applies only to files this ioctx opens; unset falls back to kvikIO's own + // default, which honours KVIKIO_COMPAT_MODE. + kvikio::FileHandle handle = + _config.compat_mode + ? kvikio::FileHandle{path, "r", kvikio::FileHandle::m644, *_config.compat_mode} + : kvikio::FileHandle{path, "r"}; + auto const file_size = handle.nbytes(); + return std::make_shared(std::move(path), std::move(handle), file_size); +} + +bool kvikio_context::supports(std::string_view /*path*/) const noexcept +{ + // Universal fallback: kvikIO handles local paths, and the actual feasibility + // check happens at create_io_object time, where opening the file may throw. + // The registry consults this last, so an explicit backend always wins. + return true; +} + +std::vector kvikio_context::align_and_coalesce( + std::span ranges, std::optional /*alignment*/) const noexcept +{ + return {ranges.begin(), ranges.end()}; +} + +size_t kvikio_context::host_read_io(const io_object& obj, size_t offset, size_t size, uint8_t* dst) +{ + // pread dispatches on the destination pointer type, so the same call serves + // host and device buffers; here it is always host memory. + return as_kvikio(obj).handle().pread(dst, size, offset).get(); +} + +exec::semi_future kvikio_context::host_read_async_io(const io_object& obj, + size_t offset, + size_t size, + uint8_t* dst) noexcept +{ + // make_semi_future_with invokes eagerly, so the kvikIO future is consumed + // here and the returned semi_future is already satisfied. That matches the + // contract's "noexcept, never blocks the caller on IO completion downstream" + // only insofar as kvikIO's own thread pool did the transfer; callers that + // need true overlap use the uring backend. + return exec::make_semi_future_with( + [&obj, offset, size, dst]() { return as_kvikio(obj).handle().pread(dst, size, offset).get(); }); +} + +exec::semi_future kvikio_context::device_read_async_io( + const io_object& obj, + size_t offset, + size_t size, + uint8_t* dst, + rmm::cuda_stream_view stream) noexcept +{ + return exec::make_semi_future_with([&obj, offset, size, dst, stream]() { + // read_async enqueues the transfer on `stream` (so it is ordered against + // the caller's other stream work, unlike pread); check_bytes_done then + // synchronizes that stream and yields the byte count. + auto fut = as_kvikio(obj).handle().read_async(dst, + size, + static_cast(offset), + /*devPtr_offset=*/0, + stream.value()); + return fut.check_bytes_done(); + }); +} + +exec::semi_future kvikio_context::host_to_device_read_async_io( + const io_object& /*obj*/, + std::span /*slices*/, + size_t /*offset*/, + size_t /*size*/, + uint8_t* /*device_dst*/, + rmm::cuda_stream_view /*stream*/) noexcept +{ + return exec::make_semi_future(std::make_exception_ptr( + std::runtime_error("kvikio_context does not support host_to_device_read_async_io; use " + "device_read_async instead"))); +} + +exec::semi_future kvikio_context::host_read_ranges_async_io( + const io_object& /*obj*/, std::span /*segments*/) noexcept +{ + return exec::make_semi_future(std::make_exception_ptr( + std::runtime_error("kvikio_context does not support host_read_ranges_async_io"))); +} + +} // namespace cucascade::io diff --git a/src/io/rest/rest_ioctx.cpp b/src/io/rest/rest_ioctx.cpp index 0612d45..47aa8d3 100644 --- a/src/io/rest/rest_ioctx.cpp +++ b/src/io/rest/rest_ioctx.cpp @@ -17,8 +17,11 @@ */ #include +#include #include +#include +#include #include #include #include @@ -32,6 +35,95 @@ rest_ioctx::rest_ioctx(std::size_t n_reactors, std::shared_ptr const& sink, + std::optional max_scanned) +{ + if (_reactors.empty()) { throw std::runtime_error("rest_ioctx::list_objects: no reactors"); } + std::size_t const clamped = (page_size == 0 || page_size > 1000) ? 1000 : page_size; + std::size_t const scanned_cap = + max_scanned.value_or(_reactors.front()->get_config().list_max_scanned); + + std::size_t scanned = 0; + std::string token; + bool truncated = false; + do { + // SigV4 canonical order = byte order of the encoded keys; for these params + // that is continuation-token < list-type < max-keys < prefix. + std::string query; + if (!token.empty()) { + query += "continuation-token="; + query += s3::uri_encode(token, /*encode_slash=*/true); + query += '&'; + } + query += "list-type=2&max-keys="; + query += std::to_string(clamped); + query += "&prefix="; + query += s3::uri_encode(prefix, /*encode_slash=*/true); + + auto const page = + s3::parse_list_objects_v2(_reactors.front()->list_page(bucket, prefix, query)); + + scanned += page.entries.size(); + if (scanned > scanned_cap) { + throw std::runtime_error("rest_ioctx::list_objects: scanned more than " + + std::to_string(scanned_cap) + " objects under s3://" + + std::string(bucket) + "/" + std::string(prefix) + + " — narrow the glob prefix"); + } + if (page.is_truncated && page.next_continuation_token.empty()) { + throw std::runtime_error( + "rest_ioctx::list_objects: truncated ListObjectsV2 page without a continuation token for " + "s3://" + + std::string(bucket) + "/" + std::string(prefix)); + } + // A truncated page must contain entries and advance the token. Together + // with scanned_cap these bound pagination for non-conforming backends that + // would otherwise loop on empty or non-advancing pages. + if (page.is_truncated && page.entries.empty()) { + throw std::runtime_error( + "rest_ioctx::list_objects: truncated ListObjectsV2 page with no entries for s3://" + + std::string(bucket) + "/" + std::string(prefix)); + } + if (page.is_truncated && page.next_continuation_token == token) { + throw std::runtime_error( + "rest_ioctx::list_objects: ListObjectsV2 continuation token did not advance for s3://" + + std::string(bucket) + "/" + std::string(prefix)); + } + truncated = page.is_truncated; + token = page.next_continuation_token; + if (!sink(page)) { return; } + } while (truncated); +} + +std::vector rest_ioctx::list_objects(std::string_view bucket, + std::string_view prefix, + std::size_t page_size, + std::optional max_keys) +{ + std::size_t const keys_cap = max_keys.value_or(list_max_matches()); + std::vector out; + list_objects_paged(bucket, prefix, page_size, [&](s3::list_objects_v2_page const& page) { + if (out.size() + page.entries.size() > keys_cap) { + throw std::runtime_error("rest_ioctx::list_objects: more than " + std::to_string(keys_cap) + + " objects under s3://" + std::string(bucket) + "/" + + std::string(prefix) + " — narrow the glob prefix"); + } + out.insert(out.end(), page.entries.begin(), page.entries.end()); + return true; + }); + return out; +} + +std::size_t rest_ioctx::list_max_matches() const +{ + return _reactors.empty() ? s3::default_max_list_objects + : _reactors.front()->get_config().list_max_matches; +} + std::shared_ptr rest_ioctx::create_io_object(std::string path) { auto parsed = cucascade::io::parse(path); @@ -49,4 +141,55 @@ std::shared_ptr rest_ioctx::create_io_object(std::string path) std::move(path), std::move(parsed.host), std::move(parsed.path), size); } +std::shared_ptr rest_ioctx::create_io_object(std::string path, open_hint hint) +{ + if (hint == open_hint::parquet_footer_probe) { + return create_footer_probe_object(std::move(path)); + } + return create_io_object(std::move(path)); +} + +std::shared_ptr rest_ioctx::create_io_object(std::string path, std::uint64_t known_size) +{ + auto parsed = cucascade::io::parse(path); + if (parsed.scheme != "s3") { + throw std::invalid_argument("rest_ioctx::create_io_object: unsupported scheme '" + + parsed.scheme + "'"); + } + // The size came from a ListObjectsV2 response: build the io_object with zero + // network — no HEAD, no probe. + return std::make_shared(std::move(path), + std::move(parsed.host), + std::move(parsed.path), + static_cast(known_size)); +} + +std::shared_ptr rest_ioctx::create_footer_probe_object(std::string path) +{ + auto parsed = cucascade::io::parse(path); + if (parsed.scheme != "s3") { + throw std::invalid_argument("rest_ioctx::create_io_object: unsupported scheme '" + + parsed.scheme + "'"); + } + if (_reactors.empty()) { throw std::runtime_error("rest_ioctx::create_io_object: no reactors"); } + + // One suffix-range GET resolves the size and stashes the footer; cuDF's + // trailer/footer reads are then served from the stash by host_read. + footer_probe probe = _reactors.front()->fetch_footer_suffix( + parsed.host, parsed.path, _reactors.front()->get_config().footer_probe_bytes); + if (!probe.bytes) { + // Unusable suffix response (200 full body, 416, missing / "*" Content-Range): + // fall back to a plain HEAD for the size, with no stash. + size_t const size = _reactors.front()->head_object_size(parsed.host, parsed.path); + return std::make_shared( + std::move(path), std::move(parsed.host), std::move(parsed.path), size); + } + return std::make_shared(std::move(path), + std::move(parsed.host), + std::move(parsed.path), + probe.object_size, + probe.window_lo, + probe.bytes); +} + } // namespace cucascade::io::rest diff --git a/src/io/rest/rest_reactor.cpp b/src/io/rest/rest_reactor.cpp index e4acd39..05e4e3d 100644 --- a/src/io/rest/rest_reactor.cpp +++ b/src/io/rest/rest_reactor.cpp @@ -89,6 +89,15 @@ size_t write_discard(char* /*ptr*/, size_t size, size_t nmemb, void* /*userdata* return size * nmemb; } +/// Accumulate the whole response body into a std::string (small control-plane +/// responses only — e.g. one ListObjectsV2 XML page). +size_t write_string(char* ptr, size_t size, size_t nmemb, void* userdata) +{ + auto* out = static_cast(userdata); + out->append(ptr, size * nmemb); + return size * nmemb; +} + /// Lowercase a byte. char ascii_lower(char c) { return static_cast(std::tolower(static_cast(c))); } @@ -126,6 +135,60 @@ size_t capture_header(char* buffer, size_t size, size_t nitems, void* userdata) return bytes; } +/// Shared sink for a suffix-range footer probe: the header callback records the +/// HTTP status (from the status line) plus Content-Range / Retry-After; the body +/// callback consults @c status to abort a non-206 response before it streams a +/// whole object into us. @c HEADERDATA and @c WRITEDATA point at the same one. +struct suffix_sink { + std::vector data; + std::size_t cap{0}; + std::size_t total_received{0}; // wire bytes, incl. those dropped by cap/abort + long status{0}; + std::string content_range; + std::string retry_after; +}; + +/// Header callback for a suffix probe: parse the status code out of the status +/// line so the body callback can abort a non-206 early, and capture the headers +/// the caller needs (Content-Range to verify the 206, Retry-After for backoff). +size_t suffix_header_cb(char* buffer, size_t size, size_t nitems, void* userdata) +{ + auto* s = static_cast(userdata); + size_t const bytes = size * nitems; + std::string_view const line(buffer, bytes); + if (line.size() >= 5 && ascii_lower(line[0]) == 'h' && ascii_lower(line[1]) == 't' && + ascii_lower(line[2]) == 't' && ascii_lower(line[3]) == 'p' && line[4] == '/') { + if (auto const sp = line.find(' '); sp != std::string_view::npos) { + long code = 0; + for (size_t i = sp + 1; i < line.size() && line[i] >= '0' && line[i] <= '9'; ++i) { + code = code * 10 + (line[i] - '0'); + } + if (code != 0) { s->status = code; } + } + } + if (auto v = match_header(line, "content-range"); !v.empty()) { s->content_range = std::move(v); } + if (auto v = match_header(line, "retry-after"); !v.empty()) { s->retry_after = std::move(v); } + return bytes; +} + +/// Body callback for a suffix probe: abort a non-206 response (a deliberate +/// short write, surfacing as CURLE_WRITE_ERROR) so a server that ignores the +/// Range or answers 416/4xx never streams a whole object into us; otherwise +/// append up to @c cap bytes and report the full incoming size to curl. +size_t suffix_write_cb(char* ptr, size_t size, size_t nmemb, void* userdata) +{ + auto* s = static_cast(userdata); + size_t const bytes = size * nmemb; + s->total_received += bytes; + if (s->status != 206) { return 0; } + if (s->data.size() < s->cap) { + size_t const take = std::min(s->cap - s->data.size(), bytes); + auto const* src = reinterpret_cast(ptr); + s->data.insert(s->data.end(), src, src + take); + } + return bytes; +} + // ---- retry classification -------------------------------------------------- /// HTTP status codes worth retrying (transient server / throttling). Only the @@ -202,11 +265,15 @@ std::string range_header(size_t offset, size_t size) return "Range: bytes=" + std::to_string(offset) + "-" + std::to_string(offset + size - 1); } +/// "Range: bytes=-" — the last @p n bytes of an object (a suffix range). +/// Unlike range_header this needs no prior knowledge of the object's size. +std::string suffix_range_header(size_t n) { return "Range: bytes=-" + std::to_string(n); } + /// Parse the first-byte position out of a Content-Range value of the form /// "bytes -/" (the trimmed value captured by the header /// callback). Returns nullopt for any value that does not start with a /// well-formed "bytes -" so the caller can reject an unverifiable 206. -std::optional content_range_start(std::string const& cr) +std::optional content_range_start(std::string_view cr) { constexpr std::string_view kUnit = "bytes"; std::string_view sv{cr}; @@ -320,6 +387,41 @@ std::vector chunk_host_segments(std::span bytes) +{ + auto owner = std::make_shared(std::move(bytes)); + // Aliasing constructor: shares `owner`'s control block (keeping the buffer + // alive) while the pointer itself refers to the span member inside it. + return shared_byte_span{owner, &owner->view}; +} + +std::optional content_range_total(std::string_view cr) +{ + constexpr std::string_view kUnit = "bytes"; + std::string_view sv{cr}; + if (sv.size() < kUnit.size()) { return std::nullopt; } + for (size_t i = 0; i < kUnit.size(); ++i) { + if (ascii_lower(sv[i]) != kUnit[i]) { return std::nullopt; } + } + sv.remove_prefix(kUnit.size()); + while (!sv.empty() && (sv.front() == ' ' || sv.front() == '\t')) { + sv.remove_prefix(1); + } + // The range part must be a satisfied "-", never "*": a leading + // digit both rejects "bytes */..." and confirms a total follows the '/'. + if (sv.empty() || sv.front() < '0' || sv.front() > '9') { return std::nullopt; } + auto const slash = sv.find('/'); + if (slash == std::string_view::npos) { return std::nullopt; } + std::string_view const total = sv.substr(slash + 1); + if (total.empty() || total.front() < '0' || total.front() > '9') { return std::nullopt; } + size_t value = 0; + for (char const c : total) { + if (c < '0' || c > '9') { break; } + value = value * 10 + static_cast(c - '0'); + } + return value; +} + // --------------------------------------------------------------------------- // construction / lifecycle // --------------------------------------------------------------------------- @@ -672,6 +774,17 @@ size_t rest_reactor::host_read(const io_object_type& file, size_t offset, size_t size = std::min(size, file.size() > offset ? file.size() - offset : size_t{0}); if (size == 0) { return 0; } + // Serve reads fully inside the suffix-range footer stash locally (the parquet + // trailer/footer reads after a probe); a straddling read falls through to a GET. + if (auto const& stash = file.stash(); stash) { + size_t const lo = file.stash_window_lo(); + size_t const hi = lo + stash->size(); + if (offset >= lo && offset + size <= hi) { + std::memcpy(dst, stash->data() + (offset - lo), size); + return size; + } + } + // Drive the blocking read through the worker's async pipeline (pooled // connections, parallel ranged GETs, the shared retry/backoff policy) and // synchronize on its future — rather than a one-shot easy handle that pays a @@ -729,6 +842,12 @@ size_t rest_reactor::head_object_size(std::string_view bucket, std::string_view obj.bucket + "/" + obj.key); } if (attempt + 1 < _config.max_retry_attempts) { + CUCASCADE_LOG_WARN("rest_reactor::head_object_size: retrying {}/{} after {} (attempt {}/{})", + obj.bucket, + obj.key, + last_error, + attempt + 1, + _config.max_retry_attempts); std::this_thread::sleep_for(compute_backoff(attempt, hc.retry_after, _config)); } } @@ -736,6 +855,145 @@ size_t rest_reactor::head_object_size(std::string_view bucket, std::string_view ") for " + obj.bucket + "/" + obj.key); } +std::string rest_reactor::list_page(std::string_view bucket, + std::string_view prefix, + std::string_view canonical_query) +{ + std::string const bucket_s{bucket}; + std::string const prefix_s{prefix}; + std::string last_error; + for (std::size_t attempt = 0; attempt < _config.max_retry_attempts; ++attempt) { + header_capture hc; + auto const authd = _ctx->authorizer()->authorize_list( + bucket_s, std::string{canonical_query}, presign_ttl(_config)); + + curl_easy_ptr h{curl_easy_init()}; + if (!h) { throw std::runtime_error("rest_reactor::list_page: curl_easy_init failed"); } + configure_easy_handle(h.get(), global_curl_context::instance().share_handle()); + apply_request_opts(h.get(), _config); + + std::string body; + curl_slist_ptr hdrs = build_header_list(authd.headers, nullptr); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_URL, authd.url.c_str())); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_HTTPGET, 1L)); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_HTTPHEADER, hdrs.get())); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_WRITEFUNCTION, &write_string)); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_WRITEDATA, &body)); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_HEADERFUNCTION, &capture_header)); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_HEADERDATA, &hc)); + + CURLcode const rc = curl_easy_perform(h.get()); + long status = 0; + curl_easy_getinfo(h.get(), CURLINFO_RESPONSE_CODE, &status); + + if (rc == CURLE_OK && status == 200) { return body; } + + last_error = + rc != CURLE_OK ? std::string(curl_easy_strerror(rc)) : ("HTTP " + std::to_string(status)); + bool const retriable = + (rc != CURLE_OK && is_retriable_curl(rc)) || (rc == CURLE_OK && is_retriable_status(status)); + if (!retriable) { + throw std::runtime_error("rest_reactor::list_page: " + last_error + " for " + bucket_s + "/" + + prefix_s); + } + if (attempt + 1 < _config.max_retry_attempts) { + CUCASCADE_LOG_WARN("rest_reactor::list_page: retrying {}/{} after {} (attempt {}/{})", + bucket_s, + prefix_s, + last_error, + attempt + 1, + _config.max_retry_attempts); + std::this_thread::sleep_for(compute_backoff(attempt, hc.retry_after, _config)); + } + } + throw std::runtime_error("rest_reactor::list_page: exhausted retries (" + last_error + ") for " + + bucket_s + "/" + prefix_s); +} + +footer_probe rest_reactor::fetch_footer_suffix(std::string_view bucket, + std::string_view key, + std::size_t n) +{ + footer_probe probe; + if (n == 0) { return probe; } + + object_ref const obj{std::string(bucket), std::string(key)}; + std::string last_error; + for (std::size_t attempt = 0; attempt < _config.max_retry_attempts; ++attempt) { + suffix_sink sink; + sink.cap = n; + + auto const authd = + _ctx->authorizer()->authorize(obj, request_method::GET, presign_ttl(_config)); + + curl_easy_ptr h{curl_easy_init()}; + if (!h) { + throw std::runtime_error("rest_reactor::fetch_footer_suffix: curl_easy_init failed"); + } + configure_easy_handle(h.get(), global_curl_context::instance().share_handle()); + apply_request_opts(h.get(), _config); + + std::string const range = suffix_range_header(n); + curl_slist_ptr hdrs = build_header_list(authd.headers, &range); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_URL, authd.url.c_str())); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_HTTPHEADER, hdrs.get())); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_WRITEFUNCTION, &suffix_write_cb)); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_WRITEDATA, &sink)); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_HEADERFUNCTION, &suffix_header_cb)); + CUCASCADE_CURL_CHECK(curl_easy_setopt(h.get(), CURLOPT_HEADERDATA, &sink)); + + CURLcode const rc = curl_easy_perform(h.get()); + long status = 0; + curl_easy_getinfo(h.get(), CURLINFO_RESPONSE_CODE, &status); + + // suffix_write_cb aborts any non-206 body, so a CURLE_WRITE_ERROR here is our + // own doing and the HTTP status is still valid; only a different curl error + // (no HTTP status) is a genuine transport failure. + if (rc != CURLE_OK && rc != CURLE_WRITE_ERROR) { + last_error = std::string(curl_easy_strerror(rc)); + if (!is_retriable_curl(rc)) { + throw std::runtime_error("rest_reactor::fetch_footer_suffix: " + last_error + " for " + + obj.bucket + "/" + obj.key); + } + } else if (status == 206) { + // Trust the 206 only when the window origin and total both parse and the + // delivered byte count matches exactly; an unverifiable 206 (missing / + // "*" Content-Range) reports an empty probe so the caller HEADs instead. + auto const total = content_range_total(sink.content_range); + auto const start = content_range_start(sink.content_range); + if (total && start && *start <= *total && sink.data.size() == *total - *start) { + probe.object_size = *total; + probe.window_lo = *start; + probe.bytes = make_shared_byte_span(std::move(sink.data)); + } + return probe; + } else if (status == 200 || status == 416) { + // Range ignored (full body) or unsatisfiable (416): probe unusable but the + // object exists — report empty so the caller falls back to a HEAD. + return probe; + } else if (is_retriable_status(status)) { + last_error = "HTTP " + std::to_string(status); + } else { + // 404 / 403 / 401 / ... — an error a HEAD would not recover from either. + throw std::runtime_error("rest_reactor::fetch_footer_suffix: HTTP " + std::to_string(status) + + " for " + obj.bucket + "/" + obj.key); + } + + if (attempt + 1 < _config.max_retry_attempts) { + CUCASCADE_LOG_WARN( + "rest_reactor::fetch_footer_suffix: retrying {}/{} after {} (attempt {}/{})", + obj.bucket, + obj.key, + last_error, + attempt + 1, + _config.max_retry_attempts); + std::this_thread::sleep_for(compute_backoff(attempt, sink.retry_after, _config)); + } + } + throw std::runtime_error("rest_reactor::fetch_footer_suffix: exhausted retries (" + last_error + + ") for " + obj.bucket + "/" + obj.key); +} + // --------------------------------------------------------------------------- // capabilities / factory // --------------------------------------------------------------------------- @@ -1083,7 +1341,8 @@ void rest_reactor::worker_loop(const std::stop_token& stop_token) // a genuine AccessDenied still fails fast. auto schedule_retry = [&](std::unique_ptr req, std::string const& retry_after, - bool is_auth) { + bool is_auth, + std::string const& reason) { std::size_t& counter = is_auth ? req->auth_attempt : req->attempt; std::size_t const max_attempts = is_auth ? _config.max_auth_retry_attempts : _config.max_retry_attempts; @@ -1095,6 +1354,12 @@ void rest_reactor::worker_loop(const std::stop_token& stop_token) // Backoff tracks the transient-attempt count; an auth retry re-presigns // and reuses the current step without inflating it. auto const delay = compute_backoff(req->attempt, retry_after, _config); + CUCASCADE_LOG_WARN("rest_reactor: retrying {}/{} after {} (attempt {}/{})", + req->object.bucket, + req->object.key, + reason, + counter + 1, + max_attempts); counter += 1; retry_heap.push_back(retry_entry{std::chrono::steady_clock::now() + delay, std::move(req)}); std::push_heap(retry_heap.begin(), retry_heap.end(), retry_cmp); @@ -1258,8 +1523,13 @@ void rest_reactor::worker_loop(const std::stop_token& stop_token) // with a fresh signature a bounded number of times before giving up. bool const auth_retriable = rc == CURLE_OK && status == 403; if (retriable || auth_retriable) { - schedule_retry( - std::move(s.req), s.hc.retry_after, /*is_auth=*/auth_retriable && !retriable); + std::string const reason = + rc != CURLE_OK ? std::string(curl_easy_strerror(rc)) + : (short_read ? "short read" : "HTTP " + std::to_string(status)); + schedule_retry(std::move(s.req), + s.hc.retry_after, + /*is_auth=*/auth_retriable && !retriable, + reason); return false; } std::string const msg = rc != CURLE_OK @@ -1356,6 +1626,8 @@ void rest_reactor::worker_loop(const std::stop_token& stop_token) pc.event->synchronize(); pc.manager->chunk_complete(pc.bytes); } catch (const std::exception& e) { + CUCASCADE_LOG_ERROR("rest_reactor: copy-event synchronize on shutdown failed: {}", + e.what()); pc.manager->report_error(std::make_exception_ptr(std::runtime_error( std::string("rest_reactor: device H2D copy failed on shutdown: ") + e.what()))); } @@ -1382,6 +1654,7 @@ void rest_reactor::worker_loop(const std::stop_token& stop_token) } ready.clear(); } catch (const std::exception& e) { + CUCASCADE_LOG_ERROR("rest_reactor worker_loop: {}", e.what()); } std::unique_ptr dr; diff --git a/src/io/rest/s3/list_parser.cpp b/src/io/rest/s3/list_parser.cpp new file mode 100644 index 0000000..fd4dba7 --- /dev/null +++ b/src/io/rest/s3/list_parser.cpp @@ -0,0 +1,252 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * + * 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. + */ + +#include + +#include +#include +#include +#include + +namespace cucascade::io::rest::s3 { + +namespace { + +// Single-pass unescape of the five predefined XML entities. Unknown sequences +// (e.g. numeric character references, which S3 does not emit for keys) pass +// through verbatim. +std::string xml_unescape(std::string_view s) +{ + std::string out; + out.reserve(s.size()); + for (std::size_t i = 0; i < s.size();) { + if (s[i] == '&') { + if (s.compare(i, 5, "&") == 0) { + out += '&'; + i += 5; + continue; + } + if (s.compare(i, 4, "<") == 0) { + out += '<'; + i += 4; + continue; + } + if (s.compare(i, 4, ">") == 0) { + out += '>'; + i += 4; + continue; + } + if (s.compare(i, 6, """) == 0) { + out += '"'; + i += 6; + continue; + } + if (s.compare(i, 6, "'") == 0) { + out += '\''; + i += 6; + continue; + } + } + out += s[i]; + ++i; + } + return out; +} + +std::string_view trim(std::string_view s) +{ + std::size_t b = 0; + std::size_t e = s.size(); + while (b < e && std::isspace(static_cast(s[b])) != 0) { + ++b; + } + while (e > b && std::isspace(static_cast(s[e - 1])) != 0) { + --e; + } + return s.substr(b, e - b); +} + +// Raw text between the first `` and its `` (these S3 elements carry +// no attributes), searching from @p from. nullopt when the element is absent. +std::optional element_text(std::string_view xml, + std::string_view tag, + std::size_t from = 0) +{ + std::string const open = "<" + std::string{tag} + ">"; + std::string const close = ""; + auto const o = xml.find(open, from); + if (o == std::string_view::npos) { return std::nullopt; } + auto const s = o + open.size(); + auto const c = xml.find(close, s); + if (c == std::string_view::npos) { return std::nullopt; } + return xml.substr(s, c - s); +} + +std::uint64_t parse_size(std::string_view raw) +{ + auto const text = trim(raw); + if (text.empty()) { + throw std::runtime_error("parse_list_objects_v2: empty in "); + } + std::uint64_t value = 0; + for (char const c : text) { + if (c < '0' || c > '9') { + throw std::runtime_error("parse_list_objects_v2: non-numeric '" + std::string{text} + + "' in "); + } + auto const digit = static_cast(c - '0'); + // Guard the accumulation: a wrapped-small size is later trusted to skip the + // HEAD in the known-size open, so an overflow would silently truncate reads. + if (value > (std::numeric_limits::max() - digit) / 10) { + throw std::runtime_error("parse_list_objects_v2: '" + std::string{text} + + "' overflows uint64 in "); + } + value = value * 10 + digit; + } + return value; +} + +} // namespace + +list_objects_v2_page parse_list_objects_v2(std::string_view xml) +{ + // Fail closed: a malformed body must never parse as a silently-incomplete + // listing (dropped objects), nor let elements outside the root steer paging. + constexpr std::string_view k_root_open = "' or XML + // whitespace, else would parse as a real listing. + auto const is_xml_space = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; }; + std::size_t root_open = std::string_view::npos; + for (auto cand = xml.find(k_root_open); cand != std::string_view::npos; + cand = xml.find(k_root_open, cand + 1)) { + auto const after = cand + k_root_open.size(); + if (after < xml.size() && (xml[after] == '>' || is_xml_space(xml[after]))) { + root_open = cand; + break; + } + } + if (root_open == std::string_view::npos) { + throw std::runtime_error( + "parse_list_objects_v2: not a ListObjectsV2 response (no )"); + } + // Only an optional XML declaration may precede the root. + auto const pre = trim(xml.substr(0, root_open)); + if (!pre.empty()) { + bool prologue_only = false; + if (pre.substr(0, 5) == ""); + prologue_only = end != std::string_view::npos && trim(pre.substr(end + 2)).empty(); + } + if (!prologue_only) { + throw std::runtime_error( + "parse_list_objects_v2: unexpected content before "); + } + } + // Restrict all field lookup below to the root element's content. The window + // starts after the open tag's '>'; the close is searched after the open, so a + // close-before-open (or absent close) body is rejected as truncated. + auto const root_open_end = xml.find('>', root_open + k_root_open.size()); + if (root_open_end == std::string_view::npos) { + throw std::runtime_error( + "parse_list_objects_v2: truncated ListObjectsV2 response (unterminated " + "open tag)"); + } + auto const root_close = xml.find(k_root_close, root_open_end + 1); + if (root_close == std::string_view::npos) { + throw std::runtime_error( + "parse_list_objects_v2: truncated ListObjectsV2 response (missing )"); + } + auto const body = xml.substr(root_open_end + 1, root_close - root_open_end - 1); + + list_objects_v2_page page; + + // Parse Key and Size only from complete elements; scoping to the + // block excludes stray s and rollups. A missing Key or + // Size means a mangled body (both are always present), so throw rather than + // drop the entry or open with a zero size. + constexpr std::string_view k_contents_open = ""; + constexpr std::string_view k_contents_close = ""; + for (std::size_t pos = 0;;) { + auto const co = body.find(k_contents_open, pos); + if (co == std::string_view::npos) { break; } // no more objects — clean end + auto const block_begin = co + k_contents_open.size(); + auto const ce = body.find(k_contents_close, block_begin); + if (ce == std::string_view::npos) { + // An opened with no close is a mid-block truncation — throw + // rather than silently returning the objects parsed so far. + throw std::runtime_error( + "parse_list_objects_v2: truncated ListObjectsV2 page (unclosed )"); + } + auto const block = body.substr(block_begin, ce - block_begin); + auto const key = element_text(block, "Key"); + if (!key.has_value()) { + throw std::runtime_error( + "parse_list_objects_v2: malformed ListObjectsV2 page ( without )"); + } + auto object_key = xml_unescape(*key); + if (object_key.empty()) { + throw std::runtime_error( + "parse_list_objects_v2: malformed ListObjectsV2 page (empty in )"); + } + auto const size = element_text(block, "Size"); + if (!size.has_value()) { + throw std::runtime_error("parse_list_objects_v2: without for key '" + + object_key + "'"); + } + page.entries.push_back({std::move(object_key), parse_size(*size)}); + pos = ce + k_contents_close.size(); + } + + // IsTruncated is required and strictly boolean; defaulting a missing/garbage + // value to "not truncated" would end the paged loop on a partial listing. + auto const truncated = element_text(body, "IsTruncated"); + if (!truncated.has_value()) { + throw std::runtime_error( + "parse_list_objects_v2: malformed ListObjectsV2 page (missing )"); + } + auto const truncated_text = trim(*truncated); + if (truncated_text == "true") { + page.is_truncated = true; + } else if (truncated_text == "false") { + page.is_truncated = false; + } else { + throw std::runtime_error("parse_list_objects_v2: invalid value '" + + std::string{truncated_text} + "'"); + } + + if (auto const token = element_text(body, "NextContinuationToken"); token.has_value()) { + page.next_continuation_token = xml_unescape(trim(*token)); + } else if (page.is_truncated) { + // A truncated page must carry the token for the next page. (An empty token + // element passes here and is rejected by the paged caller.) + throw std::runtime_error( + "parse_list_objects_v2: truncated ListObjectsV2 page without a continuation token " + "( missing)"); + } + + // Reject non-whitespace content after the root close. Checked last so a + // malformed window reports its own, more specific error first. + if (!trim(xml.substr(root_close + k_root_close.size())).empty()) { + throw std::runtime_error("parse_list_objects_v2: unexpected content after "); + } + + return page; +} + +} // namespace cucascade::io::rest::s3 diff --git a/src/io/rest/s3/sigv4.cpp b/src/io/rest/s3/sigv4.cpp index 1ccded8..c6d49fe 100644 --- a/src/io/rest/s3/sigv4.cpp +++ b/src/io/rest/s3/sigv4.cpp @@ -270,7 +270,8 @@ std::string presign_url(std::string_view method, std::string_view canonical_uri, sigv4_signer_config const& creds, std::time_t timestamp_utc, - std::chrono::seconds ttl) + std::chrono::seconds ttl, + std::string_view extra_canonical_query) { if (creds.access_key.empty() || creds.secret_key.empty() || creds.region.empty() || creds.service.empty()) { @@ -309,16 +310,39 @@ std::string presign_url(std::string_view method, // encode_slash=true (per AWS query-encoding rules). The // X-Amz-Signature itself is appended *after* signing — it is not // part of the canonical request. ---- + // Stored already-encoded so the X-Amz-* parameters and any + // caller-supplied request parameters (@p extra_canonical_query, which + // arrives pre-encoded) can be merged and sorted together by encoded + // key — AWS signs the combined, sorted set. std::vector> qparams; - qparams.reserve(6); - qparams.emplace_back("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); - qparams.emplace_back("X-Amz-Credential", credential_qparam); - qparams.emplace_back("X-Amz-Date", amz_date); - qparams.emplace_back("X-Amz-Expires", std::to_string(ttl.count())); - if (!creds.session_token.empty()) { - qparams.emplace_back("X-Amz-Security-Token", creds.session_token); + auto add_amz = [&qparams](std::string_view k, std::string_view v) { + qparams.emplace_back(uri_encode(k, /*encode_slash=*/true), + uri_encode(v, /*encode_slash=*/true)); + }; + add_amz("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); + add_amz("X-Amz-Credential", credential_qparam); + add_amz("X-Amz-Date", amz_date); + add_amz("X-Amz-Expires", std::to_string(ttl.count())); + if (!creds.session_token.empty()) { add_amz("X-Amz-Security-Token", creds.session_token); } + add_amz("X-Amz-SignedHeaders", "host"); + + // Merge the caller's pre-encoded request params (e.g. ListObjectsV2's + // list-type / prefix / max-keys / continuation-token), taken verbatim. + for (std::size_t b = 0; b < extra_canonical_query.size();) { + auto const amp = extra_canonical_query.find('&', b); + auto const pair = extra_canonical_query.substr( + b, amp == std::string_view::npos ? std::string_view::npos : amp - b); + if (!pair.empty()) { + auto const eq = pair.find('='); + if (eq == std::string_view::npos) { + qparams.emplace_back(std::string{pair}, std::string{}); + } else { + qparams.emplace_back(std::string{pair.substr(0, eq)}, std::string{pair.substr(eq + 1)}); + } + } + if (amp == std::string_view::npos) { break; } + b = amp + 1; } - qparams.emplace_back("X-Amz-SignedHeaders", "host"); std::sort( qparams.begin(), qparams.end(), [](auto const& a, auto const& b) { return a.first < b.first; }); @@ -326,9 +350,9 @@ std::string presign_url(std::string_view method, std::string canonical_query; for (std::size_t i = 0; i < qparams.size(); ++i) { if (i != 0) canonical_query += '&'; - canonical_query += uri_encode(qparams[i].first, /*encode_slash=*/true); + canonical_query += qparams[i].first; canonical_query += '='; - canonical_query += uri_encode(qparams[i].second, /*encode_slash=*/true); + canonical_query += qparams[i].second; } // ---- 4. Canonical headers (host only) and signed headers list. ---- @@ -379,19 +403,38 @@ std::string presign_url(std::string_view method, std::string signature_hex = hex_encode(sig.data(), sig.size()); - // ---- 8. Assemble the final URL: - // scheme://host?&X-Amz-Signature=. ---- + // ---- 8. Assemble the final URL. S3 accepts the query params in any order + // (only the canonical query fed into the signature must be sorted, + // and X-Amz-Signature is never part of it), so signature placement is + // presentation-only. Preserve the existing object-request layout — + // a pure X-Amz query appends the signature last; merged LIST queries + // insert it in sorted position for a fully canonical-ordered URL. ---- + std::string final_query; + if (extra_canonical_query.empty()) { + final_query = canonical_query; + final_query += "&X-Amz-Signature="; + final_query += signature_hex; + } else { + qparams.emplace_back("X-Amz-Signature", std::move(signature_hex)); + std::sort(qparams.begin(), qparams.end(), [](auto const& a, auto const& b) { + return a.first < b.first; + }); + for (std::size_t i = 0; i < qparams.size(); ++i) { + if (i != 0) final_query += '&'; + final_query += qparams[i].first; + final_query += '='; + final_query += qparams[i].second; + } + } + std::string out; - out.reserve(scheme.size() + 3 + host.size() + canonical_uri.size() + 1 + canonical_query.size() + - /* &X-Amz-Signature= */ 18 + signature_hex.size()); + out.reserve(scheme.size() + 3 + host.size() + canonical_uri.size() + 1 + final_query.size()); out.append(scheme.data(), scheme.size()); out += "://"; out.append(host.data(), host.size()); out.append(canonical_uri.data(), canonical_uri.size()); out += '?'; - out += canonical_query; - out += "&X-Amz-Signature="; - out += signature_hex; + out += final_query; return out; } diff --git a/src/io/rest/s3/sigv4_authorizer.cpp b/src/io/rest/s3/sigv4_authorizer.cpp index 6e4737f..9f551f7 100644 --- a/src/io/rest/s3/sigv4_authorizer.cpp +++ b/src/io/rest/s3/sigv4_authorizer.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -106,6 +107,37 @@ std::string_view method_to_str(request_method method) return "GET"; // unreachable; all enumerators handled } +// Reject X-Amz-* parameters case-insensitively to prevent signing-parameter +// injection: a caller-supplied X-Amz-* key would otherwise be merged into the +// signed set (presigned mode) or signed verbatim (header mode), duplicating or +// overriding the authorizer's own signing parameters. +void reject_amz_query_params(std::string_view canonical_query) +{ + constexpr std::string_view k_amz = "x-amz-"; + for (std::size_t b = 0; b < canonical_query.size();) { + auto const amp = canonical_query.find('&', b); + auto const len = (amp == std::string::npos ? canonical_query.size() : amp) - b; + auto const key_end = std::min(std::string_view{canonical_query}.substr(b, len).find('='), len); + auto const key = std::string_view{canonical_query}.substr(b, key_end); + if (key.size() >= k_amz.size()) { + bool amz = true; + for (std::size_t i = 0; i < k_amz.size(); ++i) { + if (std::tolower(static_cast(key[i])) != k_amz[i]) { + amz = false; + break; + } + } + if (amz) { + throw credential_error( + "sigv4_authorizer: X-Amz-* keys are not allowed in a LIST canonical query (got '" + + std::string{key} + "')"); + } + } + if (amp == std::string::npos) { break; } + b = amp + 1; + } +} + } // namespace sigv4_authorizer_base::sigv4_authorizer_base(static_credentials creds, @@ -165,6 +197,35 @@ authorized_request sigv4_presigned_authorizer::authorize(object_ref const& obj, } } +authorized_request sigv4_presigned_authorizer::authorize_list(std::string_view bucket, + std::string_view canonical_query, + std::chrono::seconds timeout) +{ + if (bucket.empty()) { throw credential_error("sigv4_authorizer: empty bucket"); } + reject_amz_query_params(canonical_query); + std::string const canonical_uri = "/" + uri_encode(bucket, /*encode_slash=*/true); + auto const signer = make_signer(_creds, _region); + auto const effective_ttl = timeout.count() > 0 ? timeout : _ttl; + + try { + // The list params are merged into the signed canonical query by presign_url, + // so the URL carries both them and the X-Amz-* auth params; headers empty. + return authorized_request{presign_url(/*method=*/"GET", + _scheme, + _host, + canonical_uri, + signer, + std::time(nullptr), + effective_ttl, + canonical_query), + {}}; + } catch (credential_error const&) { + throw; + } catch (std::exception const& e) { + throw credential_error(std::string("sigv4_presigned_authorizer: ") + e.what()); + } +} + sigv4_header_authorizer::sigv4_header_authorizer(static_credentials creds, std::string region, std::string endpoint) @@ -200,4 +261,37 @@ authorized_request sigv4_header_authorizer::authorize(object_ref const& obj, } } +authorized_request sigv4_header_authorizer::authorize_list(std::string_view bucket, + std::string_view canonical_query, + std::chrono::seconds /*timeout*/) +{ + if (bucket.empty()) { throw credential_error("sigv4_authorizer: empty bucket"); } + reject_amz_query_params(canonical_query); + std::string const canonical_uri = "/" + uri_encode(bucket, /*encode_slash=*/true); + auto const signer = make_signer(_creds, _region); + + try { + // sign_request already signs an arbitrary pre-sorted canonical query; the + // URL carries the same query verbatim, auth rides in the returned headers. + auto signed_req = sign_request(/*method=*/"GET", + _host, + canonical_uri, + canonical_query, + sha256_hex(""), + /*extra_headers=*/{}, + signer, + std::time(nullptr)); + std::string url = _scheme + "://" + _host + canonical_uri; + if (!canonical_query.empty()) { + url += '?'; + url += canonical_query; + } + return authorized_request{std::move(url), std::move(signed_req.headers)}; + } catch (credential_error const&) { + throw; + } catch (std::exception const& e) { + throw credential_error(std::string("sigv4_header_authorizer: ") + e.what()); + } +} + } // namespace cucascade::io::rest::s3 diff --git a/src/io/uri_parser.cpp b/src/io/uri_parser.cpp index aafc3dd..129fbbf 100644 --- a/src/io/uri_parser.cpp +++ b/src/io/uri_parser.cpp @@ -29,6 +29,7 @@ namespace { constexpr std::string_view kSchemeDelim = "://"; constexpr std::string_view kFileScheme = "file"; +constexpr std::string_view kS3Scheme = "s3"; [[noreturn]] void fail(std::string_view reason, std::string_view uri) { @@ -127,6 +128,27 @@ parsed_uri parse(std::string_view uri) { if (uri.empty()) fail("empty URI", uri); + // S3 object keys are literal: '%', '?', '#' are ordinary key bytes. Handle s3 + // before the fragment strip / query split / percent-decode below (which would + // mutate the key) and return the raw key. Non-s3 schemes fall through unchanged. + if (auto delim = uri.find(kSchemeDelim); + delim != std::string_view::npos && delim > 0 && to_lower(uri.substr(0, delim)) == kS3Scheme) { + auto rest = uri.substr(delim + kSchemeDelim.size()); + auto slash = rest.find('/'); + auto host_sv = (slash == std::string_view::npos) ? rest : rest.substr(0, slash); + auto key_sv = (slash == std::string_view::npos) ? std::string_view{} : rest.substr(slash); + if (host_sv.empty()) fail("empty host", uri); + // Strip exactly one bucket/key separator slash (S3 REST semantics, matching + // the general object-store branch); any further leading slashes are key bytes. + if (!key_sv.empty() && key_sv.front() == '/') key_sv.remove_prefix(1); + if (key_sv.empty()) fail("empty object key", uri); + parsed_uri s3; + s3.scheme = std::string{kS3Scheme}; + s3.host = std::string{host_sv}; + s3.path = std::string{key_sv}; // RAW literal key: no percent-decode. + return s3; + } + // Strip fragment early: fragments have no semantics for object-store URIs. // Why: users may paste browser URLs; silent drop avoids noisy errors. if (auto hash = uri.find('#'); hash != std::string_view::npos) { diff --git a/src/io/uring/uring_reactor.cpp b/src/io/uring/uring_reactor.cpp index 4d4d5b2..8025b0c 100644 --- a/src/io/uring/uring_reactor.cpp +++ b/src/io/uring/uring_reactor.cpp @@ -400,11 +400,20 @@ unique_ring_ptr make_ring(unsigned depth) p.flags |= IORING_SETUP_SINGLE_ISSUER; p.flags |= IORING_SETUP_COOP_TASKRUN | IORING_SETUP_DEFER_TASKRUN; int rc = io_uring_queue_init_params(depth, r.get(), &p); - if (rc == 0) { return unique_ring_ptr{r.release()}; } + if (rc == 0) { + CUCASCADE_LOG_TRACE("uring_device_reactor: ring using SINGLE_ISSUER|DEFER_TASKRUN, entries={}", + depth); + return unique_ring_ptr{r.release()}; + } + CUCASCADE_LOG_TRACE( + "uring_device_reactor: SINGLE_ISSUER|DEFER_TASKRUN unsupported " + "({}), falling back to plain flags", + strerror(-rc)); #endif auto r2 = std::make_unique(); int rc2 = io_uring_queue_init(depth, r2.get(), 0); if (rc2 < 0) throw std::runtime_error("uring_reactor: ring init: " + std::string(strerror(-rc2))); + CUCASCADE_LOG_TRACE("uring_reactor: ring using plain flags, entries={}", depth); return unique_ring_ptr{r2.release()}; } @@ -422,6 +431,9 @@ struct unique_ring { if (int rc = io_uring_register_buffers( ring.get(), iovecs.data(), static_cast(iovecs.size())); rc < 0) { + CUCASCADE_LOG_WARN( + "uring_reactor: io_uring_register_buffers failed ({}); fixed buffers disabled", + strerror(-rc)); return false; } return true; @@ -855,6 +867,7 @@ void uring_reactor::worker_loop(const std::stop_token& stop_token) static constexpr std::chrono::milliseconds SHUTDOWN_POLL_MS{100}; std::stop_callback cb(stop_token, [this] { + CUCASCADE_LOG_TRACE("uring_reactor worker_loop: stop requested"); _requests.enqueue(nullptr); // unblock the worker if it's waiting on an empty queue }); @@ -983,6 +996,11 @@ void uring_reactor::worker_loop(const std::stop_token& stop_token) // resubmit — register_bound_buffer re-preps it as a plain read. No // bytes landed, so the resubmit reads the whole range from scratch. if (s.used_fixed_buffer && is_fixed_buffer_error(errc)) { + CUCASCADE_LOG_WARN( + "uring_reactor: fixed-buffer read failed on slot {} ({}); " + "falling back to plain read", + si, + strerror(errc)); s.support_fixed_buffers = false; incomplete_requests.push_back(si); continue; @@ -1038,7 +1056,11 @@ void uring_reactor::worker_loop(const std::stop_token& stop_token) // wait for all in-flight requests to complete so we don't report spurious errors on shutdown while (inflight > 0) { auto s = ring.wait_for(SHUTDOWN_POLL_MS); - if (s) { break; } + if (s) { + CUCASCADE_LOG_ERROR("uring_reactor: io_uring_wait_cqe failed during shutdown: {}", + strerror(s)); + break; + } reap_cqes(); } @@ -1081,7 +1103,10 @@ void uring_reactor::worker_loop(const std::stop_token& stop_token) if (inflight > 0) { auto s = ring.wait_for(SHUTDOWN_POLL_MS); - if (s) { break; } + if (s) { + CUCASCADE_LOG_ERROR("uring_reactor: io_uring_wait_cqe_timeout failed: {}", strerror(s)); + break; + } reap_cqes(); } @@ -1090,6 +1115,7 @@ void uring_reactor::worker_loop(const std::stop_token& stop_token) poll_copy_completions(); } } catch (const std::exception& e) { + CUCASCADE_LOG_ERROR("uring_reactor: exception: {}", e.what()); } } } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e098d2e..99bfe17 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -56,6 +56,33 @@ if(NOT CUCASCADE_TOPOLOGY_ONLY) add_test(NAME cucascade_tests COMMAND cucascade_tests) endif() +if(NOT CUCASCADE_TOPOLOGY_ONLY AND CUCASCADE_BUILD_IO) + # IO test executable - links the cudf-free cucascade-io datasource layer. + add_executable( + cucascade_io_tests + io/test_uri_parser.cpp + io/cache/test_metadata_store.cpp + io/kvikio/test_kvikio_config.cpp + io/rest/test_shared_byte_span.cpp + io/rest/s3/test_sigv4.cpp + io/rest/s3/test_sigv4_authorizer.cpp + io/rest/s3/test_static_credentials.cpp + # Main test runner + unittest.cpp) + set_target_properties(cucascade_io_tests PROPERTIES CUDA_STANDARD 20 + CUDA_STANDARD_REQUIRED ON) + + target_include_directories( + cucascade_io_tests + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/utils + ${Catch2_SOURCE_DIR}/single_include) + + target_link_libraries(cucascade_io_tests PRIVATE cucascade_io Catch2::Catch2 + CUDA::cudart_static) + + add_test(NAME cucascade_io_tests COMMAND cucascade_io_tests) +endif() + if(NOT CUCASCADE_TOPOLOGY_ONLY AND CUCASCADE_BUILD_CUDF) # cudf-coupled test executable - links the cucascade-cudf library. add_executable( diff --git a/test/io/cache/test_metadata_store.cpp b/test/io/cache/test_metadata_store.cpp new file mode 100644 index 0000000..9984f33 --- /dev/null +++ b/test/io/cache/test_metadata_store.cpp @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * + * 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. + */ + +#include + +#include + +#include +#include +#include +#include + +using cucascade::io::io_object; +using cucascade::io::io_object_metadata; +using cucascade::io::cache::metadata_store; + +namespace { + +/// Minimal io_object stand-in: the store only ever reads raw_file_cache_id(). +class fake_io_object final : public io_object { + public: + explicit fake_io_object(std::string id) : _id(std::move(id)) {} + + [[nodiscard]] const std::string& raw_file_cache_id() const noexcept override { return _id; } + [[nodiscard]] const std::string& object_path() const noexcept override { return _id; } + [[nodiscard]] size_t size() const noexcept override { return 0; } + + private: + std::string _id; +}; + +struct tagged_metadata final : io_object_metadata { + explicit tagged_metadata(int t) : tag(t) {} + int tag; +}; + +} // namespace + +TEST_CASE("metadata_store round-trips through both getters", "[cache][metadata_store]") +{ + metadata_store store; + fake_io_object const obj{"s3://bucket/key.parquet"}; + auto const meta = std::make_shared(7); + + store.register_metadata(obj, meta); + + auto const by_obj = store.get_metadata(obj); + REQUIRE(by_obj); + CHECK(std::static_pointer_cast(by_obj)->tag == 7); + + auto const by_key = store.get_metadata(std::string_view{"s3://bucket/key.parquet"}); + REQUIRE(by_key); + CHECK(by_key == by_obj); +} + +TEST_CASE("metadata_store looks up heterogeneously without building a key", + "[cache][metadata_store]") +{ + metadata_store store; + fake_io_object const obj{"/data/lineitem.parquet"}; + store.register_metadata(obj, std::make_shared(1)); + + // All three spellings must reach the same entry: a string_view, a string + // literal (const char*), and an owning std::string. This only compiles — + // and only avoids a temporary — because the map has a transparent hash and + // std::equal_to<>. + auto const from_view = store.get_metadata(std::string_view{"/data/lineitem.parquet"}); + auto const from_literal = store.get_metadata("/data/lineitem.parquet"); + auto const from_string = store.get_metadata(std::string{"/data/lineitem.parquet"}); + + REQUIRE(from_view); + CHECK(from_view == from_literal); + CHECK(from_view == from_string); +} + +TEST_CASE("metadata_store looks up a key inside a larger buffer", "[cache][metadata_store]") +{ + metadata_store store; + fake_io_object const obj{"abc"}; + store.register_metadata(obj, std::make_shared(3)); + + // A string_view that is NOT NUL-terminated and is a slice of a longer buffer: + // heterogeneous lookup must respect the view's length, not run to a NUL. + std::string const haystack = "abcdef"; + auto const key = std::string_view{haystack}.substr(0, 3); + REQUIRE(key == "abc"); + + auto const found = store.get_metadata(key); + REQUIRE(found); + CHECK(std::static_pointer_cast(found)->tag == 3); + + // ...and the longer spelling must miss. + CHECK(store.get_metadata(std::string_view{haystack}) == nullptr); +} + +TEST_CASE("metadata_store returns nullptr on miss", "[cache][metadata_store]") +{ + metadata_store store; + + CHECK(store.get_metadata(std::string_view{"absent"}) == nullptr); + CHECK(store.get_metadata(fake_io_object{"absent"}) == nullptr); +} + +TEST_CASE("metadata_store overwrites an existing key and ignores null metadata", + "[cache][metadata_store]") +{ + metadata_store store; + fake_io_object const obj{"k"}; + + store.register_metadata(obj, std::make_shared(1)); + store.register_metadata(obj, std::make_shared(2)); + + auto const after_overwrite = store.get_metadata(std::string_view{"k"}); + REQUIRE(after_overwrite); + CHECK(std::static_pointer_cast(after_overwrite)->tag == 2); + + // A null registration is silently ignored — it must not erase the entry. + store.register_metadata(obj, nullptr); + auto const still_there = store.get_metadata(std::string_view{"k"}); + REQUIRE(still_there); + CHECK(std::static_pointer_cast(still_there)->tag == 2); +} diff --git a/test/io/kvikio/test_kvikio_config.cpp b/test/io/kvikio/test_kvikio_config.cpp new file mode 100644 index 0000000..d20eea4 --- /dev/null +++ b/test/io/kvikio/test_kvikio_config.cpp @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * + * 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. + */ + +#include + +#include + +#include + +#include + +using cucascade::io::apply_kvikio_defaults; +using cucascade::io::kvikio_config; + +namespace { + +/// kvikio::defaults is a process-global singleton, so every test here must put +/// back what it found or it leaks into the next one. +struct defaults_guard { + unsigned int nthreads = kvikio::defaults::thread_pool_nthreads(); + std::size_t task_size = kvikio::defaults::task_size(); + std::size_t gds_threshold = kvikio::defaults::gds_threshold(); + std::size_t bounce_size = kvikio::defaults::bounce_buffer_size(); + bool dio_read = kvikio::defaults::auto_direct_io_read(); + bool dio_overread = kvikio::defaults::auto_direct_io_read_overread(); + bool pool_per_block_device = kvikio::defaults::thread_pool_per_block_device(); + + ~defaults_guard() + { + kvikio::defaults::set_thread_pool_per_block_device(pool_per_block_device); + kvikio::defaults::set_thread_pool_nthreads(nthreads); + kvikio::defaults::set_task_size(task_size); + kvikio::defaults::set_gds_threshold(gds_threshold); + kvikio::defaults::set_bounce_buffer_size(bounce_size); + kvikio::defaults::set_auto_direct_io_read(dio_read); + kvikio::defaults::set_auto_direct_io_read_overread(dio_overread); + } +}; + +} // namespace + +TEST_CASE("kvikio_config default-constructs with every field unset", "[kvikio][config]") +{ + kvikio_config cfg; + + CHECK_FALSE(cfg.nthreads.has_value()); + CHECK_FALSE(cfg.task_size.has_value()); + CHECK_FALSE(cfg.gds_threshold.has_value()); + CHECK_FALSE(cfg.bounce_buffer_size.has_value()); + CHECK_FALSE(cfg.auto_direct_io_read.has_value()); + CHECK_FALSE(cfg.auto_direct_io_read_overread.has_value()); + CHECK_FALSE(cfg.thread_pool_per_block_device.has_value()); + CHECK_FALSE(cfg.compat_mode.has_value()); +} + +TEST_CASE("apply_kvikio_defaults leaves kvikIO untouched for an empty config", "[kvikio][config]") +{ + defaults_guard guard; + + // Move every knob off its current value first, then apply an empty config and + // confirm nothing moved back — an unset field must not overwrite. + kvikio::defaults::set_thread_pool_nthreads(guard.nthreads + 3); + kvikio::defaults::set_task_size(guard.task_size + 4096); + kvikio::defaults::set_auto_direct_io_read(!guard.dio_read); + + apply_kvikio_defaults(kvikio_config{}); + + CHECK(kvikio::defaults::thread_pool_nthreads() == guard.nthreads + 3); + CHECK(kvikio::defaults::task_size() == guard.task_size + 4096); + CHECK(kvikio::defaults::auto_direct_io_read() == !guard.dio_read); +} + +TEST_CASE("apply_kvikio_defaults pushes engaged fields into kvikIO", "[kvikio][config]") +{ + defaults_guard guard; + + kvikio_config cfg; + cfg.nthreads = 6; + cfg.task_size = 2UL << 20; // 2 MiB, page-aligned + cfg.gds_threshold = 512UL << 10; + cfg.bounce_buffer_size = 8UL << 20; + cfg.auto_direct_io_read = true; + cfg.auto_direct_io_read_overread = true; + cfg.thread_pool_per_block_device = false; + + apply_kvikio_defaults(cfg); + + CHECK(kvikio::defaults::thread_pool_nthreads() == 6); + CHECK(kvikio::defaults::task_size() == (2UL << 20)); + CHECK(kvikio::defaults::gds_threshold() == (512UL << 10)); + CHECK(kvikio::defaults::bounce_buffer_size() == (8UL << 20)); + CHECK(kvikio::defaults::auto_direct_io_read()); + CHECK(kvikio::defaults::auto_direct_io_read_overread()); + CHECK_FALSE(kvikio::defaults::thread_pool_per_block_device()); +} + +TEST_CASE("apply_kvikio_defaults applies a partial config without disturbing the rest", + "[kvikio][config]") +{ + defaults_guard guard; + + kvikio::defaults::set_gds_threshold(guard.gds_threshold + 1024); + auto const untouched = kvikio::defaults::gds_threshold(); + + kvikio_config cfg; + cfg.nthreads = 2; + apply_kvikio_defaults(cfg); + + CHECK(kvikio::defaults::thread_pool_nthreads() == 2); + CHECK(kvikio::defaults::gds_threshold() == untouched); +} + +TEST_CASE("apply_kvikio_defaults rejects zero sizes before mutating anything", "[kvikio][config]") +{ + defaults_guard guard; + + auto const before_nthreads = kvikio::defaults::thread_pool_nthreads(); + + SECTION("zero nthreads") + { + kvikio_config cfg; + cfg.nthreads = 0; + CHECK_THROWS_AS(apply_kvikio_defaults(cfg), std::invalid_argument); + } + + SECTION("zero task_size") + { + kvikio_config cfg; + cfg.task_size = 0; + CHECK_THROWS_AS(apply_kvikio_defaults(cfg), std::invalid_argument); + } + + SECTION("zero bounce_buffer_size") + { + kvikio_config cfg; + cfg.bounce_buffer_size = 0; + CHECK_THROWS_AS(apply_kvikio_defaults(cfg), std::invalid_argument); + } + + SECTION("a valid field alongside an invalid one is not applied") + { + kvikio_config cfg; + cfg.nthreads = before_nthreads + 5; + cfg.task_size = 0; + CHECK_THROWS_AS(apply_kvikio_defaults(cfg), std::invalid_argument); + // Validation runs before any setter, so nthreads must be unchanged. + CHECK(kvikio::defaults::thread_pool_nthreads() == before_nthreads); + } +} + +TEST_CASE("kvikio_config accepts a zero gds_threshold (always use GDS)", "[kvikio][config]") +{ + defaults_guard guard; + + kvikio_config cfg; + cfg.gds_threshold = 0; + CHECK_NOTHROW(apply_kvikio_defaults(cfg)); + CHECK(kvikio::defaults::gds_threshold() == 0); +} + +TEST_CASE("kvikio_config carries compat_mode without touching kvikIO globals", "[kvikio][config]") +{ + defaults_guard guard; + + auto const before = kvikio::defaults::compat_mode(); + + kvikio_config cfg; + cfg.compat_mode = kvikio::CompatMode::ON; + apply_kvikio_defaults(cfg); + + // compat_mode rides the FileHandle constructor instead, so the global default + // must be left exactly as it was. + CHECK(kvikio::defaults::compat_mode() == before); +} diff --git a/test/io/rest/s3/test_sigv4.cpp b/test/io/rest/s3/test_sigv4.cpp new file mode 100644 index 0000000..6165844 --- /dev/null +++ b/test/io/rest/s3/test_sigv4.cpp @@ -0,0 +1,410 @@ +/* + * Copyright 2025, cuCascade Contributors. + * + * 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. + */ + +#include + +#include + +#include +#include +#include +#include +#include +#include + +using cucascade::io::rest::s3::presign_url; +using cucascade::io::rest::s3::sha256_hex; +using cucascade::io::rest::s3::sign_request; +using cucascade::io::rest::s3::sigv4_signer_config; +using cucascade::io::rest::s3::uri_encode; + +namespace { + +sigv4_signer_config aws_example_creds() +{ + sigv4_signer_config creds; + creds.access_key = "AKIAIOSFODNN7EXAMPLE"; + creds.secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + creds.region = "us-east-1"; + creds.service = "s3"; + return creds; +} + +std::string authorization_header(cucascade::io::rest::s3::sigv4_signed_request const& req) +{ + for (auto const& [key, value] : req.headers) { + if (key == "Authorization") { return value; } + } + return {}; +} + +std::string header_value(cucascade::io::rest::s3::sigv4_signed_request const& req, + std::string_view wanted) +{ + for (auto const& [key, value] : req.headers) { + if (key == wanted) { return value; } + } + return {}; +} + +std::string query_string(std::string_view url) +{ + auto pos = url.find('?'); + REQUIRE(pos != std::string_view::npos); + return std::string{url.substr(pos + 1)}; +} + +std::string query_value(std::string_view url, std::string_view key) +{ + auto query = query_string(url); + auto needle = std::string{key} + "="; + auto begin = query.find(needle); + if (begin == std::string::npos) { return {}; } + begin += needle.size(); + auto end = query.find('&', begin); + if (end == std::string::npos) { end = query.size(); } + return query.substr(begin, end - begin); +} + +bool contains(std::string_view haystack, std::string_view needle) +{ + return haystack.find(needle) != std::string_view::npos; +} + +bool is_lower_hex_64(std::string_view value) +{ + return value.size() == 64 && std::all_of(value.begin(), value.end(), [](unsigned char c) { + return std::isdigit(c) || (c >= 'a' && c <= 'f'); + }); +} + +} // namespace + +TEST_CASE("sha256_hex returns standard digests", "[s3][sigv4]") +{ + CHECK(sha256_hex("") == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + CHECK(sha256_hex("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); +} + +TEST_CASE("uri_encode follows RFC3986 rules needed by SigV4", "[s3][sigv4]") +{ + CHECK(uri_encode("Abc-_.~", false) == "Abc-_.~"); + CHECK(uri_encode("a/b/c", false) == "a/b/c"); + CHECK(uri_encode("a/b/c", true) == "a%2Fb%2Fc"); + CHECK(uri_encode("a b", true) == "a%20b"); + CHECK(uri_encode("~!@#$", true) == "~%21%40%23%24"); +} + +TEST_CASE("uri_encode canonicalizes literal S3 keys exactly once", "[s3][sigv4]") +{ + CHECK(uri_encode("path with space.parquet", false) == "path%20with%20space.parquet"); + CHECK(uri_encode("100%.parquet", false) == "100%25.parquet"); + CHECK(uri_encode("a%2Fb.parquet", false) == "a%252Fb.parquet"); +} + +TEST_CASE("sign_request rejects incomplete signer config", "[s3][sigv4]") +{ + auto creds = aws_example_creds(); + + auto bad = creds; + bad.access_key.clear(); + CHECK_THROWS_AS(sign_request("GET", + "examplebucket.s3.amazonaws.com", + "/test.txt", + "", + sha256_hex(""), + {}, + bad, + 1369353600), + std::invalid_argument); + + bad = creds; + bad.secret_key.clear(); + CHECK_THROWS_AS(sign_request("GET", + "examplebucket.s3.amazonaws.com", + "/test.txt", + "", + sha256_hex(""), + {}, + bad, + 1369353600), + std::invalid_argument); + + bad = creds; + bad.region.clear(); + CHECK_THROWS_AS(sign_request("GET", + "examplebucket.s3.amazonaws.com", + "/test.txt", + "", + sha256_hex(""), + {}, + bad, + 1369353600), + std::invalid_argument); + + bad = creds; + bad.service.clear(); + CHECK_THROWS_AS(sign_request("GET", + "examplebucket.s3.amazonaws.com", + "/test.txt", + "", + sha256_hex(""), + {}, + bad, + 1369353600), + std::invalid_argument); +} + +TEST_CASE("sign_request signs empty GET payload with sha256 empty digest", "[s3][sigv4]") +{ + auto out = sign_request("GET", + "examplebucket.s3.amazonaws.com", + "/test.txt", + "", + sha256_hex(""), + {}, + aws_example_creds(), + 1369353600); + + CHECK(header_value(out, "x-amz-content-sha256") == + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + CHECK(contains(authorization_header(out), "SignedHeaders=host;x-amz-content-sha256;x-amz-date")); +} + +TEST_CASE("sign_request injects temporary credential session token header", "[s3][sigv4]") +{ + auto creds = aws_example_creds(); + creds.session_token = "temporary/session+token="; + + auto out = sign_request("GET", + "examplebucket.s3.amazonaws.com", + "/test.txt", + "", + sha256_hex(""), + {}, + creds, + 1369353600); + + CHECK(header_value(out, "x-amz-security-token") == "temporary/session+token="); + CHECK(contains(authorization_header(out), + "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token")); +} + +TEST_CASE("presign_url matches AWS S3 published query-auth vector", "[s3][sigv4]") +{ + auto url = presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + aws_example_creds(), + 1369353600, + std::chrono::seconds{86400}); + + CHECK(url == + "https://examplebucket.s3.amazonaws.com/test.txt?" + "X-Amz-Algorithm=AWS4-HMAC-SHA256&" + "X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&" + "X-Amz-Date=20130524T000000Z&" + "X-Amz-Expires=86400&" + "X-Amz-SignedHeaders=host&" + "X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"); +} + +TEST_CASE("presign_url binds the signature to the HTTP method", "[s3][sigv4]") +{ + auto get_url = presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + aws_example_creds(), + 1369353600, + std::chrono::seconds{300}); + auto head_url = presign_url("HEAD", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + aws_example_creds(), + 1369353600, + std::chrono::seconds{300}); + + CHECK(query_value(get_url, "X-Amz-Signature") != query_value(head_url, "X-Amz-Signature")); +} + +TEST_CASE("presign_url injects session token only when present", "[s3][sigv4]") +{ + auto without_token = presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + aws_example_creds(), + 1369353600, + std::chrono::seconds{300}); + CHECK_FALSE(contains(without_token, "X-Amz-Security-Token=")); + + auto creds = aws_example_creds(); + creds.session_token = "temporary/session+token="; + auto with_token = presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + creds, + 1369353600, + std::chrono::seconds{300}); + + CHECK(contains(with_token, "X-Amz-Security-Token=temporary%2Fsession%2Btoken%3D")); + CHECK(query_value(with_token, "X-Amz-Signature") != + query_value(without_token, "X-Amz-Signature")); +} + +TEST_CASE("presign_url keeps canonical query ordering deterministic", "[s3][sigv4]") +{ + auto creds = aws_example_creds(); + creds.session_token = "token"; + auto url = presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + creds, + 1369353600, + std::chrono::seconds{300}); + auto query = query_string(url); + + auto algorithm = query.find("X-Amz-Algorithm="); + auto credential = query.find("X-Amz-Credential="); + auto date = query.find("X-Amz-Date="); + auto expires = query.find("X-Amz-Expires="); + auto token = query.find("X-Amz-Security-Token="); + auto signed_headers = query.find("X-Amz-SignedHeaders="); + auto signature = query.find("X-Amz-Signature="); + + REQUIRE(algorithm != std::string::npos); + REQUIRE(credential != std::string::npos); + REQUIRE(date != std::string::npos); + REQUIRE(expires != std::string::npos); + REQUIRE(token != std::string::npos); + REQUIRE(signed_headers != std::string::npos); + REQUIRE(signature != std::string::npos); + + CHECK(algorithm < credential); + CHECK(credential < date); + CHECK(date < expires); + CHECK(expires < token); + CHECK(token < signed_headers); + CHECK(signed_headers < signature); +} + +TEST_CASE("presign_url signs only the host header", "[s3][sigv4]") +{ + auto url = presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + aws_example_creds(), + 1369353600, + std::chrono::seconds{300}); + + CHECK(query_value(url, "X-Amz-SignedHeaders") == "host"); +} + +TEST_CASE("presign_url propagates ttl into X-Amz-Expires", "[s3][sigv4]") +{ + auto url_300 = presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + aws_example_creds(), + 1369353600, + std::chrono::seconds{300}); + auto url_86400 = presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + aws_example_creds(), + 1369353600, + std::chrono::seconds{86400}); + + CHECK(query_value(url_300, "X-Amz-Expires") == "300"); + CHECK(query_value(url_86400, "X-Amz-Expires") == "86400"); +} + +TEST_CASE("presign_url rejects invalid required inputs", "[s3][sigv4]") +{ + auto creds = aws_example_creds(); + + CHECK_THROWS_AS(presign_url("", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + creds, + 1369353600, + std::chrono::seconds{300}), + std::invalid_argument); + CHECK_THROWS_AS(presign_url("GET", + "", + "examplebucket.s3.amazonaws.com", + "/test.txt", + creds, + 1369353600, + std::chrono::seconds{300}), + std::invalid_argument); + CHECK_THROWS_AS( + presign_url("GET", "https", "", "/test.txt", creds, 1369353600, std::chrono::seconds{300}), + std::invalid_argument); + CHECK_THROWS_AS(presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + creds, + 1369353600, + std::chrono::seconds{0}), + std::invalid_argument); + CHECK_THROWS_AS(presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/test.txt", + creds, + 1369353600, + std::chrono::seconds{-1}), + std::invalid_argument); +} + +TEST_CASE("presign_url leaves already-encoded canonical URI untouched", "[s3][sigv4]") +{ + auto url = presign_url("GET", + "https", + "examplebucket.s3.amazonaws.com", + "/path/with%20space.parquet", + aws_example_creds(), + 1369353600, + std::chrono::seconds{300}); + + CHECK(contains(url, "https://examplebucket.s3.amazonaws.com/path/with%20space.parquet?")); + CHECK_FALSE(contains(url, "%2520")); +} + +TEST_CASE("presign_url preserves the caller-selected scheme", "[s3][sigv4]") +{ + auto url = presign_url("GET", + "http", + "minio.local:9000", + "/bucket/test.txt", + aws_example_creds(), + 1369353600, + std::chrono::seconds{300}); + + CHECK(url.find("http://minio.local:9000/bucket/test.txt?") == 0); + CHECK(is_lower_hex_64(query_value(url, "X-Amz-Signature"))); +} diff --git a/test/io/rest/s3/test_sigv4_authorizer.cpp b/test/io/rest/s3/test_sigv4_authorizer.cpp new file mode 100644 index 0000000..fbadc80 --- /dev/null +++ b/test/io/rest/s3/test_sigv4_authorizer.cpp @@ -0,0 +1,743 @@ +/* + * Copyright 2025, cuCascade Contributors. + * + * 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. + */ + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using cucascade::io::credential_error; +using cucascade::io::object_store_config; +using cucascade::io::rest::authorized_request; +using cucascade::io::rest::mock_authorizer; +using cucascade::io::rest::object_ref; +using cucascade::io::rest::request_method; +using cucascade::io::rest::s3::sigv4_header_authorizer; +using cucascade::io::rest::s3::sigv4_presigned_authorizer; +using cucascade::io::rest::s3::static_credentials; +using cucascade::io::rest::s3::static_credentials_from; + +namespace { + +constexpr auto k_presign_timeout = std::chrono::seconds{300}; + +static_credentials example_static_credentials() +{ + static_credentials creds; + creds.access_key_id = "AKIAIOSFODNN7EXAMPLE"; + creds.secret_access_key = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; + return creds; +} + +std::string query_string(std::string_view url) +{ + auto pos = url.find('?'); + REQUIRE(pos != std::string_view::npos); + return std::string{url.substr(pos + 1)}; +} + +std::string query_value(std::string_view url, std::string_view key) +{ + auto query = query_string(url); + auto needle = std::string{key} + "="; + auto begin = query.find(needle); + if (begin == std::string::npos) { return {}; } + begin += needle.size(); + auto end = query.find('&', begin); + if (end == std::string::npos) { end = query.size(); } + return query.substr(begin, end - begin); +} + +bool contains(std::string_view haystack, std::string_view needle) +{ + return haystack.find(needle) != std::string_view::npos; +} + +bool starts_with(std::string_view s, std::string_view prefix) +{ + return s.size() >= prefix.size() && s.substr(0, prefix.size()) == prefix; +} + +bool ascii_iequals(std::string_view lhs, std::string_view rhs) +{ + return lhs.size() == rhs.size() && + std::equal(lhs.begin(), lhs.end(), rhs.begin(), [](unsigned char a, unsigned char b) { + return std::tolower(a) == std::tolower(b); + }); +} + +std::string header_value(std::vector> const& headers, + std::string_view name) +{ + for (auto const& [key, value] : headers) { + if (ascii_iequals(key, name)) { return value; } + } + return {}; +} + +bool is_lower_hex_64(std::string_view value) +{ + return value.size() == 64 && std::all_of(value.begin(), value.end(), [](unsigned char c) { + return std::isdigit(c) || (c >= 'a' && c <= 'f'); + }); +} + +std::vector query_keys(std::string_view url) +{ + auto query = query_string(url); + std::vector keys; + std::size_t pos = 0; + while (pos < query.size()) { + auto amp = query.find('&', pos); + if (amp == std::string::npos) { amp = query.size(); } + auto eq = query.find('=', pos); + REQUIRE(eq != std::string::npos); + REQUIRE(eq <= amp); + keys.push_back(query.substr(pos, eq - pos)); + pos = amp + 1; + } + return keys; +} + +class object_only_authorizer final : public cucascade::io::rest::request_authorizer { + public: + authorized_request authorize(object_ref const& /*obj*/, + request_method /*method*/, + std::chrono::seconds /*timeout*/) override + { + return {"https://example.invalid/object", {}}; + } +}; + +} // namespace + +TEST_CASE("ListObjectsV2 parser extracts ordered keys, sizes, and pagination", "[s3][list_parser]") +{ + using cucascade::io::rest::s3::parse_list_objects_v2; + + auto page = parse_list_objects_v2( + R"()" + R"()" + R"(bucketlake/)" + R"(lake/a&b.parquet12)" + R"(lake/year=2024/file.parquet0)" + R"(true)" + R"(token/with+chars=)" + R"()"); + + REQUIRE(page.entries.size() == 2); + CHECK(page.entries[0].key == "lake/a&b.parquet"); + CHECK(page.entries[0].size == 12); + CHECK(page.entries[1].key == "lake/year=2024/file.parquet"); + CHECK(page.entries[1].size == 0); + CHECK(page.is_truncated); + CHECK(page.next_continuation_token == "token/with+chars="); +} + +TEST_CASE("ListObjectsV2 parser ignores non-object keys and preserves flat-key order", + "[s3][list_parser]") +{ + using cucascade::io::rest::s3::parse_list_objects_v2; + + auto page = parse_list_objects_v2( + R"()" + R"(not-an-object.parquet)" + R"(lake/year=2024/also-not-object)" + R"(lake/part-000.parquet7)" + R"(lake/nested/year=2025/part-001.parquet8)" + R"(false)" + R"()"); + + REQUIRE(page.entries.size() == 2); + CHECK(page.entries[0].key == "lake/part-000.parquet"); + CHECK(page.entries[0].size == 7); + CHECK(page.entries[1].key == "lake/nested/year=2025/part-001.parquet"); + CHECK(page.entries[1].size == 8); + CHECK_FALSE(page.is_truncated); + CHECK(page.next_continuation_token.empty()); +} + +TEST_CASE("ListObjectsV2 parser rejects non-list bodies and malformed sizes", "[s3][list_parser]") +{ + using cucascade::io::rest::s3::parse_list_objects_v2; + + auto empty = parse_list_objects_v2( + R"(false)"); + CHECK(empty.entries.empty()); + + CHECK_THROWS_AS(parse_list_objects_v2( + R"(NoSuchBucketno bucket)"), + std::runtime_error); + CHECK_THROWS_AS(parse_list_objects_v2( + R"(a)"), + std::runtime_error); + CHECK_THROWS_AS( + parse_list_objects_v2( + R"(anot-a-size)"), + std::runtime_error); + CHECK_THROWS_AS( + parse_list_objects_v2(R"(a1)"), + std::runtime_error); + CHECK_THROWS_AS( + parse_list_objects_v2( + R"(a1b)"), + std::runtime_error); + CHECK_THROWS_AS( + parse_list_objects_v2( + R"(a99999999999999999999999999)"), + std::runtime_error); + + auto max_size = parse_list_objects_v2( + R"(a18446744073709551615false)"); + REQUIRE(max_size.entries.size() == 1); + CHECK(max_size.entries[0].size == std::numeric_limits::max()); +} + +TEST_CASE("ListObjectsV2 parser rejects Contents without a Key", "[s3][list_parser]") +{ + CHECK_THROWS_WITH( + cucascade::io::rest::s3::parse_list_objects_v2( + R"(5false)"), + Catch::Contains(" without ")); +} + +TEST_CASE("ListObjectsV2 parser rejects an unclosed Key", "[s3][list_parser]") +{ + CHECK_THROWS_WITH( + cucascade::io::rest::s3::parse_list_objects_v2( + R"(a5false)"), + Catch::Contains(" without ")); +} + +TEST_CASE("ListObjectsV2 parser rejects an empty Key", "[s3][list_parser]") +{ + CHECK_THROWS_WITH( + cucascade::io::rest::s3::parse_list_objects_v2( + R"(5false)"), + Catch::Contains("empty ")); +} + +TEST_CASE("ListObjectsV2 parser requires IsTruncated", "[s3][list_parser]") +{ + CHECK_THROWS_WITH( + cucascade::io::rest::s3::parse_list_objects_v2( + R"(a5)"), + Catch::Contains("missing ")); +} + +TEST_CASE("ListObjectsV2 parser rejects invalid IsTruncated values", "[s3][list_parser]") +{ + for (auto const value : {"TRUE", "1", "garbage"}) { + DYNAMIC_SECTION("value=" << value) + { + auto const xml = + std::string{ + "a5" + ""} + + value + ""; + CHECK_THROWS_WITH(cucascade::io::rest::s3::parse_list_objects_v2(xml), + Catch::Contains("invalid ")); + } + } + + SECTION("unclosed element") + { + CHECK_THROWS_WITH( + cucascade::io::rest::s3::parse_list_objects_v2( + R"(a5true)"), + Catch::Contains("missing ")); + } +} + +TEST_CASE("ListObjectsV2 parser requires a token for a truncated page", "[s3][list_parser]") +{ + CHECK_THROWS_WITH( + cucascade::io::rest::s3::parse_list_objects_v2( + R"(a5true)"), + Catch::Contains("without") && Catch::Contains("ContinuationToken")); +} + +TEST_CASE("ListObjectsV2 parser trims a valid IsTruncated value", "[s3][list_parser]") +{ + auto const page = cucascade::io::rest::s3::parse_list_objects_v2( + R"(a5 true next)"); + + CHECK(page.is_truncated); + CHECK(page.next_continuation_token == "next"); +} + +TEST_CASE("ListObjectsV2 parser rejects object entries after the root element", "[s3][list_parser]") +{ + CHECK_THROWS_WITH( + cucascade::io::rest::s3::parse_list_objects_v2( + R"(falseoutside1)"), + Catch::Contains("after ")); +} + +TEST_CASE("ListObjectsV2 parser does not read IsTruncated outside the root", "[s3][list_parser]") +{ + CHECK_THROWS_WITH(cucascade::io::rest::s3::parse_list_objects_v2( + R"(false)"), + Catch::Contains("missing ")); +} + +TEST_CASE("ListObjectsV2 parser does not read a continuation token outside the root", + "[s3][list_parser]") +{ + CHECK_THROWS_WITH( + cucascade::io::rest::s3::parse_list_objects_v2( + R"(a1trueoutside)"), + Catch::Contains("without") && Catch::Contains("ContinuationToken")); +} + +TEST_CASE("ListObjectsV2 parser rejects a root close before the root open", "[s3][list_parser]") +{ + CHECK_THROWS_AS(cucascade::io::rest::s3::parse_list_objects_v2( + R"(false)"), + std::runtime_error); +} + +TEST_CASE("ListObjectsV2 parser accepts a prologue, root namespace, and trailing whitespace", + "[s3][list_parser]") +{ + auto const page = cucascade::io::rest::s3::parse_list_objects_v2( + "" + "" + "a1" + "false" + " \n\t"); + + REQUIRE(page.entries.size() == 1); + CHECK(page.entries[0].key == "a"); + CHECK(page.entries[0].size == 1); + CHECK_FALSE(page.is_truncated); +} + +TEST_CASE("ListObjectsV2 parser rejects a root-name prefix collision", "[s3][list_parser]") +{ + CHECK_THROWS_WITH( + cucascade::io::rest::s3::parse_list_objects_v2( + R"(false)"), + Catch::Contains("not a ListObjectsV2 response")); +} + +TEST_CASE("ListObjectsV2 parser rejects content before the root element", "[s3][list_parser]") +{ + CHECK_THROWS_WITH( + cucascade::io::rest::s3::parse_list_objects_v2( + R"(false)"), + Catch::Contains("before ")); +} + +TEST_CASE("ListObjectsV2 parser accepts a prologue and newline before the root", + "[s3][list_parser]") +{ + auto const page = cucascade::io::rest::s3::parse_list_objects_v2( + "\n " + "false"); + + CHECK(page.entries.empty()); + CHECK_FALSE(page.is_truncated); +} + +TEST_CASE("request_authorizer base rejects LIST until implementations opt in", "[s3][authorizer]") +{ + object_only_authorizer provider; + + CHECK_THROWS_AS( + provider.authorize_list("bucket", "list-type=2&max-keys=1000&prefix=p%2F", k_presign_timeout), + credential_error); +} + +TEST_CASE("sigv4_presigned_authorizer signs sorted ListObjectsV2 query params", "[s3][authorizer]") +{ + auto creds = example_static_credentials(); + creds.session_token = "temporary/session+token="; + sigv4_presigned_authorizer provider(creds, "us-east-1", "https://s3.us-east-1.amazonaws.com"); + + auto request = + provider.authorize_list("bucket", "list-type=2&max-keys=1000&prefix=p%2F", k_presign_timeout); + + REQUIRE(request.headers.empty()); + CHECK(starts_with(request.url, "https://s3.us-east-1.amazonaws.com/bucket?")); + CHECK(contains(request.url, "list-type=2")); + CHECK(contains(request.url, "max-keys=1000")); + CHECK(contains(request.url, "prefix=p%2F")); + CHECK(contains(request.url, "X-Amz-Security-Token=temporary%2Fsession%2Btoken%3D")); + CHECK(is_lower_hex_64(query_value(request.url, "X-Amz-Signature"))); + + auto keys = query_keys(request.url); + CHECK(std::is_sorted(keys.begin(), keys.end())); +} + +TEST_CASE("sigv4_header_authorizer signs ListObjectsV2 canonical queries", "[s3][authorizer]") +{ + sigv4_header_authorizer provider( + example_static_credentials(), "us-east-1", "http://minio.local:9000"); + + auto request = + provider.authorize_list("bucket", + "continuation-token=page%2F1%2B%3D&list-type=2&max-keys=1&prefix=p%2F", + k_presign_timeout); + + CHECK(request.url == + "http://minio.local:9000/bucket?continuation-token=page%2F1%2B%3D&list-type=2&max-keys=1&" + "prefix=p%2F"); + CHECK_FALSE(contains(request.url, "X-Amz-Signature")); + CHECK(starts_with(header_value(request.headers, "Authorization"), "AWS4-HMAC-SHA256 ")); + CHECK_FALSE(header_value(request.headers, "x-amz-date").empty()); + CHECK_FALSE(header_value(request.headers, "x-amz-content-sha256").empty()); +} + +TEST_CASE("SigV4 LIST rejects X-Amz query smuggling", "[s3][authorizer]") +{ + sigv4_presigned_authorizer presigned( + example_static_credentials(), "us-east-1", "https://s3.us-east-1.amazonaws.com"); + sigv4_header_authorizer header( + example_static_credentials(), "us-east-1", "https://s3.us-east-1.amazonaws.com"); + + CHECK_THROWS(presigned.authorize_list( + "bucket", "list-type=2&X-Amz-Signature=evil&prefix=p%2F", k_presign_timeout)); + CHECK_THROWS(header.authorize_list( + "bucket", "list-type=2&x-amz-credential=evil&prefix=p%2F", k_presign_timeout)); +} + +TEST_CASE("sigv4_presigned_authorizer normalizes HTTPS endpoint", "[s3][authorizer]") +{ + sigv4_presigned_authorizer provider( + example_static_credentials(), "us-west-2", "HTTPS://S3.US-WEST-2.AMAZONAWS.COM"); + + auto request = + provider.authorize({"examplebucket", "test.txt"}, request_method::GET, k_presign_timeout); + CHECK(request.headers.empty()); + auto const& url = request.url; + + CHECK(starts_with(url, "https://s3.us-west-2.amazonaws.com/examplebucket/test.txt?")); + CHECK(query_value(url, "X-Amz-Credential").find("%2Fus-west-2%2Fs3%2Faws4_request") != + std::string::npos); + CHECK(query_value(url, "X-Amz-SignedHeaders") == "host"); +} + +TEST_CASE("sigv4_presigned_authorizer preserves HTTP endpoint ports", "[s3][authorizer]") +{ + sigv4_presigned_authorizer provider( + example_static_credentials(), "us-east-1", "http://minio.local:9000"); + + auto request = + provider.authorize({"bucket", "object.parquet"}, request_method::GET, k_presign_timeout); + CHECK(request.headers.empty()); + auto const& url = request.url; + + CHECK(starts_with(url, "http://minio.local:9000/bucket/object.parquet?")); + CHECK(is_lower_hex_64(query_value(url, "X-Amz-Signature"))); +} + +TEST_CASE("sigv4_presigned_authorizer rejects malformed construction inputs", "[s3][authorizer]") +{ + auto creds = example_static_credentials(); + + CHECK_THROWS_AS(sigv4_presigned_authorizer(creds, "us-east-1", ""), credential_error); + CHECK_THROWS_AS(sigv4_presigned_authorizer(creds, "us-east-1", "s3.us-east-1.amazonaws.com"), + credential_error); + CHECK_THROWS_AS(sigv4_presigned_authorizer(creds, "us-east-1", "ftp://example.com"), + credential_error); + CHECK_THROWS_AS(sigv4_presigned_authorizer(creds, "us-east-1", "https://example.com/prefix"), + credential_error); + CHECK_THROWS_AS(sigv4_presigned_authorizer(creds, "us-east-1", "https://example.com?x=1"), + credential_error); + CHECK_THROWS_AS(sigv4_presigned_authorizer(creds, "us-east-1", "https://example.com#fragment"), + credential_error); + + auto no_access_key = creds; + no_access_key.access_key_id.clear(); + CHECK_THROWS_AS(sigv4_presigned_authorizer(no_access_key, "us-east-1", "https://example.com"), + credential_error); + + auto no_secret_key = creds; + no_secret_key.secret_access_key.clear(); + CHECK_THROWS_AS(sigv4_presigned_authorizer(no_secret_key, "us-east-1", "https://example.com"), + credential_error); + + CHECK_THROWS_AS(sigv4_presigned_authorizer(creds, "", "https://example.com"), credential_error); + CHECK_THROWS_AS( + sigv4_presigned_authorizer(creds, "us-east-1", "https://example.com", std::chrono::seconds{0}), + credential_error); +} + +TEST_CASE("sigv4_header_authorizer signs with headers and plain path-style URLs", + "[s3][authorizer]") +{ + auto creds = example_static_credentials(); + creds.session_token = "temporary/session+token="; + sigv4_header_authorizer provider(creds, "us-east-1", "https://s3.us-east-1.amazonaws.com"); + + auto get_request = + provider.authorize({"examplebucket", "test.txt"}, request_method::GET, k_presign_timeout); + auto head_request = + provider.authorize({"examplebucket", "test.txt"}, request_method::HEAD, k_presign_timeout); + + CHECK(get_request.url == "https://s3.us-east-1.amazonaws.com/examplebucket/test.txt"); + CHECK_FALSE(contains(get_request.url, "X-Amz-Signature")); + CHECK_FALSE(contains(get_request.url, "?")); + + auto get_auth = header_value(get_request.headers, "Authorization"); + REQUIRE(starts_with(get_auth, "AWS4-HMAC-SHA256 ")); + CHECK_FALSE(header_value(get_request.headers, "x-amz-date").empty()); + CHECK_FALSE(header_value(get_request.headers, "x-amz-content-sha256").empty()); + CHECK(header_value(get_request.headers, "x-amz-security-token") == creds.session_token); + + auto head_auth = header_value(head_request.headers, "Authorization"); + REQUIRE(starts_with(head_auth, "AWS4-HMAC-SHA256 ")); + CHECK(get_auth != head_auth); +} + +TEST_CASE("sigv4_header_authorizer omits session-token header for long-lived keys", + "[s3][authorizer]") +{ + sigv4_header_authorizer provider( + example_static_credentials(), "us-east-1", "http://minio.local:9000"); + + auto request = provider.authorize( + {"bucket", "nested/object.parquet"}, request_method::GET, std::chrono::seconds{10}); + + CHECK(request.url == "http://minio.local:9000/bucket/nested/object.parquet"); + CHECK_FALSE(contains(request.url, "X-Amz-")); + CHECK(starts_with(header_value(request.headers, "Authorization"), "AWS4-HMAC-SHA256 ")); + CHECK(header_value(request.headers, "x-amz-security-token").empty()); +} + +TEST_CASE("sigv4_presigned_authorizer generates distinct GET and HEAD URLs", "[s3][authorizer]") +{ + sigv4_presigned_authorizer provider( + example_static_credentials(), "us-east-1", "https://s3.us-east-1.amazonaws.com"); + + auto get_request = + provider.authorize({"examplebucket", "test.txt"}, request_method::GET, k_presign_timeout); + auto head_request = + provider.authorize({"examplebucket", "test.txt"}, request_method::HEAD, k_presign_timeout); + CHECK(get_request.headers.empty()); + CHECK(head_request.headers.empty()); + auto const& get_url = get_request.url; + auto const& head_url = head_request.url; + + CHECK(query_value(get_url, "X-Amz-SignedHeaders") == "host"); + CHECK(query_value(head_url, "X-Amz-SignedHeaders") == "host"); + CHECK(query_value(get_url, "X-Amz-Signature") != query_value(head_url, "X-Amz-Signature")); +} + +TEST_CASE("sigv4_presigned_authorizer encodes bucket and key path components", "[s3][authorizer]") +{ + sigv4_presigned_authorizer provider( + example_static_credentials(), "us-east-1", "https://s3.us-east-1.amazonaws.com"); + + auto spaced = + provider + .authorize({"bucket", "path with space.parquet"}, request_method::GET, k_presign_timeout) + .url; + CHECK( + starts_with(spaced, "https://s3.us-east-1.amazonaws.com/bucket/path%20with%20space.parquet?")); + + auto nested = + provider.authorize({"bucket", "a/b/c.parquet"}, request_method::GET, k_presign_timeout).url; + CHECK(starts_with(nested, "https://s3.us-east-1.amazonaws.com/bucket/a/b/c.parquet?")); + CHECK_FALSE(contains(nested, "a%2Fb%2Fc.parquet")); + + auto leading = provider.authorize({"bucket", "/foo"}, request_method::GET, k_presign_timeout).url; + CHECK(starts_with(leading, "https://s3.us-east-1.amazonaws.com/bucket//foo?")); + + auto unicode_key = + provider + .authorize( + {"bucket", "\xE4\xB8\xAD\xE6\x96\x87.parquet"}, request_method::GET, k_presign_timeout) + .url; + CHECK(starts_with(unicode_key, + "https://s3.us-east-1.amazonaws.com/bucket/%E4%B8%AD%E6%96%87.parquet?")); +} + +TEST_CASE("sigv4_presigned_authorizer propagates session tokens", "[s3][authorizer]") +{ + auto creds = example_static_credentials(); + creds.session_token = "temporary/session+token="; + sigv4_presigned_authorizer provider(creds, "us-east-1", "https://s3.us-east-1.amazonaws.com"); + + auto request = + provider.authorize({"examplebucket", "test.txt"}, request_method::GET, k_presign_timeout); + CHECK(request.headers.empty()); + auto const& url = request.url; + + CHECK(contains(url, "X-Amz-Security-Token=temporary%2Fsession%2Btoken%3D")); +} + +TEST_CASE("static_credentials_from maps object_store_config session tokens into SigV4 URLs", + "[s3][authorizer]") +{ + object_store_config cfg; + cfg.endpoint = "https://s3.us-east-1.amazonaws.com"; + cfg.region = "us-east-1"; + cfg.access_key = "AKIAIOSFODNN7EXAMPLE"; + cfg.secret_key = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; + cfg.session_token = "temporary/session+token="; + + auto creds = static_credentials_from(cfg); + CHECK(creds.access_key_id == cfg.access_key); + CHECK(creds.secret_access_key == cfg.secret_key); + CHECK(creds.session_token == cfg.session_token); + + sigv4_presigned_authorizer token_provider(creds, cfg.region, cfg.endpoint); + auto token_url = + token_provider.authorize({"examplebucket", "test.txt"}, request_method::GET, k_presign_timeout) + .url; + CHECK(contains(token_url, "X-Amz-Security-Token=temporary%2Fsession%2Btoken%3D")); + + cfg.session_token.clear(); + auto no_token_creds = static_credentials_from(cfg); + CHECK(no_token_creds.session_token.empty()); + + sigv4_presigned_authorizer no_token_provider(no_token_creds, cfg.region, cfg.endpoint); + auto no_token_url = + no_token_provider + .authorize({"examplebucket", "test.txt"}, request_method::GET, k_presign_timeout) + .url; + CHECK_FALSE(contains(no_token_url, "X-Amz-Security-Token=")); +} + +TEST_CASE("sigv4_presigned_authorizer honors per-call timeout", "[s3][authorizer]") +{ + auto creds = example_static_credentials(); + creds.session_token = "temporary/session+token="; + sigv4_presigned_authorizer provider( + creds, "us-east-1", "https://s3.us-east-1.amazonaws.com", std::chrono::minutes{30}); + + auto short_request = provider.authorize( + {"examplebucket", "test.txt"}, request_method::GET, std::chrono::seconds{37}); + auto long_request = provider.authorize( + {"examplebucket", "test.txt"}, request_method::GET, std::chrono::seconds{1800}); + auto head_request = provider.authorize( + {"examplebucket", "test.txt"}, request_method::HEAD, std::chrono::seconds{37}); + CHECK(short_request.headers.empty()); + CHECK(long_request.headers.empty()); + CHECK(head_request.headers.empty()); + auto const& short_url = short_request.url; + auto const& long_url = long_request.url; + auto const& head_url = head_request.url; + + CHECK(query_value(short_url, "X-Amz-Expires") == "37"); + CHECK(query_value(long_url, "X-Amz-Expires") == "1800"); + CHECK(starts_with(short_url, "https://s3.us-east-1.amazonaws.com/examplebucket/test.txt?")); + CHECK(query_value(short_url, "X-Amz-SignedHeaders") == "host"); + CHECK(is_lower_hex_64(query_value(short_url, "X-Amz-Signature"))); + CHECK(query_value(short_url, "X-Amz-Signature") != query_value(head_url, "X-Amz-Signature")); + CHECK(contains(short_url, "X-Amz-Security-Token=temporary%2Fsession%2Btoken%3D")); +} + +TEST_CASE("sigv4_presigned_authorizer rejects empty object references", "[s3][authorizer]") +{ + sigv4_presigned_authorizer provider( + example_static_credentials(), "us-east-1", "https://s3.us-east-1.amazonaws.com"); + + CHECK_THROWS_AS(provider.authorize({"", "test.txt"}, request_method::GET, k_presign_timeout), + credential_error); + CHECK_THROWS_AS(provider.authorize({"bucket", ""}, request_method::GET, k_presign_timeout), + credential_error); +} + +TEST_CASE("sigv4_presigned_authorizer is safe under concurrent presigning", "[s3][authorizer]") +{ + sigv4_presigned_authorizer provider( + example_static_credentials(), "us-east-1", "https://s3.us-east-1.amazonaws.com"); + + constexpr int n_threads = 8; + constexpr int n_iters = 25; + std::atomic malformed{0}; + std::vector threads; + threads.reserve(n_threads); + + for (int t = 0; t < n_threads; ++t) { + threads.emplace_back([&provider, &malformed, t] { + for (int i = 0; i < n_iters; ++i) { + auto url = provider + .authorize({"bucket", "key-" + std::to_string(t) + ".parquet"}, + request_method::GET, + k_presign_timeout) + .url; + if (!starts_with(url, "https://s3.us-east-1.amazonaws.com/bucket/key-") || + query_value(url, "X-Amz-SignedHeaders") != "host" || + !is_lower_hex_64(query_value(url, "X-Amz-Signature"))) { + ++malformed; + } + } + }); + } + + for (auto& thread : threads) { + thread.join(); + } + + CHECK(malformed.load() == 0); +} + +TEST_CASE("mock_authorizer returns canned URLs and records calls", "[s3][authorizer]") +{ + mock_authorizer provider( + authorized_request{"https://signed.example/object", {{"x-test-header", "one"}}}); + + auto get_request = provider.authorize({"bucket", "key"}, request_method::GET, k_presign_timeout); + CHECK(get_request.url == "https://signed.example/object"); + CHECK(get_request.headers == + std::vector>{{"x-test-header", "one"}}); + auto head_request = + provider.authorize({"bucket", "head-key"}, request_method::HEAD, k_presign_timeout); + CHECK(head_request.url == "https://signed.example/object"); + CHECK(head_request.headers == + std::vector>{{"x-test-header", "one"}}); + + CHECK(provider.call_count() == 2); + CHECK(provider.get_count() == 1); + CHECK(provider.head_count() == 1); + CHECK(provider.last_bucket() == "bucket"); + CHECK(provider.last_key() == "head-key"); + CHECK(provider.last_timeout() == k_presign_timeout); +} + +TEST_CASE("mock_authorizer can force credential errors", "[s3][authorizer]") +{ + mock_authorizer provider(authorized_request{"https://signed.example/object", {}}); + provider.set_throw("boom"); + + CHECK_THROWS_AS(provider.authorize({"bucket", "key"}, request_method::GET, k_presign_timeout), + credential_error); + + provider.clear_throw(); + auto request = provider.authorize({"bucket", "key"}, request_method::GET, k_presign_timeout); + CHECK(request.url == "https://signed.example/object"); + CHECK(request.headers.empty()); +} diff --git a/test/io/rest/s3/test_static_credentials.cpp b/test/io/rest/s3/test_static_credentials.cpp new file mode 100644 index 0000000..2127b3a --- /dev/null +++ b/test/io/rest/s3/test_static_credentials.cpp @@ -0,0 +1,52 @@ +/* + * Copyright 2025, cuCascade Contributors. + * + * 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. + */ + +#include + +#include + +#include + +using cucascade::io::rest::s3::static_credentials; + +TEST_CASE("static_credentials default constructs to empty inert values", + "[s3][authorizer][static_credentials]") +{ + static_credentials creds; + + CHECK(creds.access_key_id.empty()); + CHECK(creds.secret_access_key.empty()); + CHECK(creds.session_token.empty()); + CHECK_FALSE(creds.expires_at.has_value()); +} + +TEST_CASE("static_credentials preserves session token and expiration across copies", + "[s3][authorizer][static_credentials]") +{ + static_credentials creds; + creds.access_key_id = "access"; + creds.secret_access_key = "secret"; + creds.session_token = "session"; + creds.expires_at = std::chrono::system_clock::time_point{std::chrono::seconds{12345}}; + + auto copy = creds; + + CHECK(copy.access_key_id == "access"); + CHECK(copy.secret_access_key == "secret"); + CHECK(copy.session_token == "session"); + REQUIRE(copy.expires_at.has_value()); + CHECK(*copy.expires_at == *creds.expires_at); +} diff --git a/test/io/rest/test_shared_byte_span.cpp b/test/io/rest/test_shared_byte_span.cpp new file mode 100644 index 0000000..e0285f9 --- /dev/null +++ b/test/io/rest/test_shared_byte_span.cpp @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * + * 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. + */ + +#include + +#include + +#include +#include +#include +#include +#include +#include + +using cucascade::io::rest::make_shared_byte_span; +using cucascade::io::rest::shared_byte_span; + +namespace { + +std::vector iota_bytes(std::size_t n) +{ + std::vector v(n); + std::iota(v.begin(), v.end(), std::uint8_t{0}); + return v; +} + +} // namespace + +TEST_CASE("shared_byte_span exposes a span, not the underlying container", + "[rest][shared_byte_span]") +{ + STATIC_REQUIRE( + std::is_same_v>>); + STATIC_REQUIRE( + std::is_same_v>); +} + +TEST_CASE("make_shared_byte_span preserves contents and size", "[rest][shared_byte_span]") +{ + auto const stash = make_shared_byte_span(iota_bytes(256)); + + REQUIRE(stash); + REQUIRE(stash->size() == 256); + for (std::size_t i = 0; i < stash->size(); ++i) { + REQUIRE((*stash)[i] == static_cast(i)); + } +} + +TEST_CASE("make_shared_byte_span takes ownership of the buffer", "[rest][shared_byte_span]") +{ + shared_byte_span stash; + { + // The source vector goes out of scope here; the span must still be valid + // because the aliasing shared_ptr's control block owns the moved-in buffer. + auto source = iota_bytes(64); + stash = make_shared_byte_span(std::move(source)); + } + + REQUIRE(stash); + REQUIRE(stash->size() == 64); + CHECK((*stash)[0] == 0); + CHECK((*stash)[63] == 63); +} + +TEST_CASE("shared_byte_span keeps the buffer alive through the last owner", + "[rest][shared_byte_span]") +{ + auto first = make_shared_byte_span(iota_bytes(32)); + auto const* data_before = first->data(); + + shared_byte_span second = first; + REQUIRE(first.use_count() == 2); + + first.reset(); // drop the original owner + + // The aliasing pointer shares one control block, so the buffer survives and + // does not move: the second handle still sees the same address and bytes. + REQUIRE(second); + CHECK(second->data() == data_before); + CHECK(second->size() == 32); + CHECK((*second)[31] == 31); +} + +TEST_CASE("shared_byte_span supports the read patterns host_read uses", "[rest][shared_byte_span]") +{ + auto const stash = make_shared_byte_span(iota_bytes(128)); + + // host_read's stash fast path: window arithmetic then a memcpy off .data(). + constexpr std::size_t window_lo = 1000; + std::size_t const hi = window_lo + stash->size(); + CHECK(hi == 1128); + + constexpr std::size_t offset = 1010; + constexpr std::size_t size = 16; + REQUIRE(offset >= window_lo); + REQUIRE(offset + size <= hi); + + auto const sub = stash->subspan(offset - window_lo, size); + REQUIRE(sub.size() == size); + CHECK(sub[0] == 10); + CHECK(sub[15] == 25); +} + +TEST_CASE("a default-constructed shared_byte_span is falsy", "[rest][shared_byte_span]") +{ + shared_byte_span const none; + CHECK_FALSE(none); +} + +TEST_CASE("make_shared_byte_span handles an empty buffer", "[rest][shared_byte_span]") +{ + auto const stash = make_shared_byte_span({}); + + // Non-null (the probe succeeded) but empty — distinct from the null "no + // probe" state the caller checks with operator bool. + REQUIRE(stash); + CHECK(stash->empty()); + CHECK(stash->size() == 0); +} diff --git a/test/io/test_uri_parser.cpp b/test/io/test_uri_parser.cpp new file mode 100644 index 0000000..4cc1fb9 --- /dev/null +++ b/test/io/test_uri_parser.cpp @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * + * 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. + */ + +#include + +#include + +#include +#include + +using cucascade::io::parse; + +TEST_CASE("uri_parser parses bare absolute paths as file URIs", "[uri_parser]") +{ + auto parsed = parse("/tmp/cucascade%20data.parquet?version=1&flag#ignored"); + + CHECK(parsed.scheme == "file"); + CHECK(parsed.host.empty()); + CHECK(parsed.path == "/tmp/cucascade data.parquet"); + REQUIRE(parsed.query.size() == 2); + CHECK(parsed.query.at("version") == "1"); + CHECK(parsed.query.at("flag").empty()); +} + +TEST_CASE("uri_parser parses file scheme with absolute path", "[uri_parser]") +{ + auto parsed = parse("file:///var/data/table%20one.parquet?version=1#ignored"); + + CHECK(parsed.scheme == "file"); + CHECK(parsed.host.empty()); + CHECK(parsed.path == "/var/data/table one.parquet"); + REQUIRE(parsed.query.size() == 1); + CHECK(parsed.query.at("version") == "1"); +} + +TEST_CASE("uri_parser treats S3 object keys as literal bytes", "[uri_parser][s3]") +{ + auto parsed = parse("s3://bkt/a%20b"); + + CHECK(parsed.scheme == "s3"); + CHECK(parsed.host == "bkt"); + CHECK(parsed.path == "a%20b"); + CHECK(parsed.query.empty()); +} + +TEST_CASE("uri_parser keeps query and fragment delimiters inside S3 keys", "[uri_parser][s3]") +{ + auto query_key = parse("s3://bkt/k?region=x"); + CHECK(query_key.path == "k?region=x"); + CHECK(query_key.query.empty()); + + auto fragment_key = parse("s3://bkt/k#frag"); + CHECK(fragment_key.path == "k#frag"); + CHECK(fragment_key.query.empty()); + + auto empty_query_key = parse("s3://bkt/key?=value"); + CHECK(empty_query_key.path == "key?=value"); + CHECK(empty_query_key.query.empty()); +} + +TEST_CASE("uri_parser accepts malformed percent sequences as literal S3 key bytes", + "[uri_parser][s3]") +{ + CHECK(parse("s3://bkt/key%ZZ").path == "key%ZZ"); + CHECK(parse("s3://bkt/key%A").path == "key%A"); +} + +TEST_CASE("uri_parser applies the literal S3 path to uppercase schemes", "[uri_parser][s3]") +{ + auto parsed = parse("S3://bkt/a%20b"); + CHECK(parsed.scheme == "s3"); + CHECK(parsed.host == "bkt"); + CHECK(parsed.path == "a%20b"); + CHECK(parsed.query.empty()); +} + +TEST_CASE("uri_parser preserves S3 leading slashes in object key", "[uri_parser]") +{ + CHECK(parse("s3://bucket/key").path == "key"); + CHECK(parse("s3://bucket//key").path == "/key"); + CHECK(parse("s3://bucket///key").path == "//key"); + CHECK(parse("s3://bucket/a//b").path == "a//b"); + + CHECK_THROWS_AS(parse("s3://bucket"), std::invalid_argument); + CHECK_THROWS_AS(parse("s3://bucket/"), std::invalid_argument); +} + +TEST_CASE("uri_parser accepts project-internal schemes", "[uri_parser]") +{ + auto parsed = parse("rdma_s3://bucket/a%20b?x=1#ignored"); + + CHECK(parsed.scheme == "rdma_s3"); + CHECK(parsed.host == "bucket"); + CHECK(parsed.path == "a b"); + REQUIRE(parsed.query.size() == 1); + CHECK(parsed.query.at("x") == "1"); +} + +TEST_CASE("uri_parser leaves non-S3 object-store URI semantics unchanged", "[uri_parser]") +{ + for (auto const scheme : {"gs", "azure", "http", "https"}) { + DYNAMIC_SECTION("scheme=" << scheme) + { + auto parsed = parse(std::string{scheme} + "://bkt/a%20b?x=1#ignored"); + CHECK(parsed.scheme == scheme); + CHECK(parsed.host == "bkt"); + CHECK(parsed.path == "a b"); + REQUIRE(parsed.query.size() == 1); + CHECK(parsed.query.at("x") == "1"); + } + } +} + +TEST_CASE("uri_parser query parser decodes values and keeps last duplicate", "[uri_parser]") +{ + auto parsed = parse("gs://bucket/key?k=old&encoded=a%2Fb&k=new"); + + REQUIRE(parsed.query.size() == 2); + CHECK(parsed.query.at("k") == "new"); + CHECK(parsed.query.at("encoded") == "a/b"); +} + +TEST_CASE("uri_parser rejects malformed input", "[uri_parser]") +{ + CHECK_THROWS_AS(parse(""), std::invalid_argument); + CHECK_THROWS_AS(parse("relative/file.parquet"), std::invalid_argument); + CHECK_THROWS_AS(parse("./file.parquet"), std::invalid_argument); + CHECK_THROWS_AS(parse("://bucket/key"), std::invalid_argument); + CHECK_THROWS_AS(parse("file://relative/path"), std::invalid_argument); + CHECK_THROWS_AS(parse("s3://bucket"), std::invalid_argument); + CHECK_THROWS_AS(parse("s3://bucket/"), std::invalid_argument); +}