]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
rgw: add range projection helpers for encrypted and compressed objects
authorMatthew N. Heler <matthew.heler@hotmail.com>
Sun, 29 Mar 2026 02:27:57 +0000 (21:27 -0500)
committerMatthew N. Heler <matthew.heler@hotmail.com>
Wed, 20 May 2026 18:31:02 +0000 (13:31 -0500)
Add stateless helpers that project plaintext byte ranges to on-disk
byte ranges for compressed and encrypted objects. fixup_range()
delegates to these for range computation.

Signed-off-by: Matthew N. Heler <matthew.heler@hotmail.com>
src/rgw/rgw_compression.cc
src/rgw/rgw_crypt.cc
src/rgw/rgw_crypt.h
src/rgw/rgw_range_projection.h [new file with mode: 0644]
src/test/rgw/CMakeLists.txt
src/test/rgw/test_rgw_crypto.cc
src/test/rgw/test_rgw_range_projection.cc [new file with mode: 0644]

index 61b50d45394b97ea8edfb6433e706b32b002d773..6a3d3285fd455e3b4b9e775de485b7afbab038cd 100644 (file)
@@ -2,6 +2,7 @@
 // vim: ts=8 sw=2 sts=2 expandtab ft=cpp
 
 #include "rgw_compression.h"
+#include "rgw_range_projection.h"
 
 #define dout_subsys ceph_subsys_rgw
 
@@ -182,38 +183,18 @@ int RGWGetObj_Decompress::handle_data(bufferlist& bl, off_t bl_ofs, off_t bl_len
 
 int RGWGetObj_Decompress::fixup_range(off_t& ofs, off_t& end)
 {
-  if (partial_content) {
-    // if user set range, we need to calculate it in decompressed data
-    first_block = cs_info->blocks.begin(); last_block = cs_info->blocks.begin();
-    if (cs_info->blocks.size() > 1) {
-      vector<compression_block>::iterator fb, lb;
-      // not bad to use auto for lambda, I think
-      auto cmp_u = [] (off_t ofs, const compression_block& e) { return (uint64_t)ofs < e.old_ofs; };
-      auto cmp_l = [] (const compression_block& e, off_t ofs) { return e.old_ofs <= (uint64_t)ofs; };
-      fb = upper_bound(cs_info->blocks.begin()+1,
-                       cs_info->blocks.end(),
-                       ofs,
-                       cmp_u);
-      first_block = fb - 1;
-      lb = lower_bound(fb,
-                       cs_info->blocks.end(),
-                       end,
-                       cmp_l);
-      last_block = lb - 1;
-    }
-  } else {
-    first_block = cs_info->blocks.begin(); last_block = cs_info->blocks.end() - 1;
-  }
+  auto result = project_compress_range(ofs, end, *cs_info, partial_content);
 
-  q_ofs = ofs - first_block->old_ofs;
-  q_len = end + 1 - ofs;
-
-  ofs = first_block->new_ofs;
-  end = last_block->new_ofs + last_block->len - 1;
-
-  cur_ofs = ofs;
+  first_block = cs_info->blocks.begin() + result.first_block_idx;
+  last_block = cs_info->blocks.begin() + result.last_block_idx;
+  q_ofs = result.q_ofs;
+  q_len = result.q_len;
+  cur_ofs = result.ofs;
   waiting.clear();
 
+  ofs = result.ofs;
+  end = result.end;
+
   return next->fixup_range(ofs, end);
 }
 
index 4aa843f48db6dad1e4d91ccd8e5c30a529a3ead0..ad796021a978e916956518ac4b251cafd1819dd4 100644 (file)
@@ -19,6 +19,7 @@
 #include "crypto/crypto_accel.h"
 #include "crypto/crypto_plugin.h"
 #include "rgw/rgw_kms.h"
+#include "rgw_range_projection.h"
 #include "rapidjson/document.h"
 #include "rapidjson/writer.h"
 #include "rapidjson/error/error.h"
@@ -1525,56 +1526,23 @@ int RGWGetObj_BlockDecrypt::fixup_range(off_t& bl_ofs, off_t& bl_end) {
     }
   }
 
-  if (parts_len.size() > 0) {
-    // Multipart object: find parts containing start and end offsets
-    PartLocation start_loc = find_part_for_plaintext_offset(bl_ofs, false);
-    PartLocation end_loc = find_part_for_plaintext_offset(bl_end, true);  // clamp to last
+  // Save the pre-encryption input offsets for filter state
+  off_t pre_enc_ofs = bl_ofs;
+  off_t pre_enc_end = bl_end;
 
-    // Block-align end within its part (in plaintext space)
-    size_t part_plaintext_end = encrypted_to_plaintext_size(parts_len[end_loc.part_idx]);
-    off_t rounded_end = std::min(
-        (off_t)((end_loc.offset_in_part & ~(block_size - 1)) + (block_size - 1)),
-        (off_t)(part_plaintext_end - 1));
+  auto range = project_encrypt_range(bl_ofs, bl_end, block_size,
+                               encrypted_block_size, encrypted_total_size,
+                               parts_len);
 
-    // enc_begin_skip is offset within the starting block
-    enc_begin_skip = start_loc.offset_in_part & (block_size - 1);
-    ofs = bl_ofs - enc_begin_skip;
-    end = bl_end;
+  // Initialize filter state from projection result.
+  // ofs/end are plaintext-space offsets used by process().
+  enc_begin_skip = range.enc_begin_skip;
+  ofs = pre_enc_ofs - enc_begin_skip;
+  end = pre_enc_end;
+  enc_ofs = range.ofs;
 
-    // Convert end offset: plaintext -> encrypted, then align to encrypted block
-    off_t enc_end = align_to_encrypted_block_end(logical_to_encrypted_offset(rounded_end));
-    enc_end = std::min(enc_end, (off_t)(parts_len[end_loc.part_idx] - 1));
-    bl_end = end_loc.cumulative_encrypted + enc_end;
-
-    // Convert start offset: align in plaintext, then convert to encrypted
-    off_t aligned_start = std::max((off_t)0, start_loc.offset_in_part - enc_begin_skip);
-    bl_ofs = start_loc.cumulative_encrypted + logical_to_encrypted_offset(aligned_start);
-
-    // Clamp start to end (handles invalid ranges)
-    bl_ofs = std::min(bl_ofs, bl_end);
-    enc_ofs = bl_ofs;
-  }
-  else
-  {
-    // Simple object (no multipart)
-    enc_begin_skip = bl_ofs & (block_size - 1);
-    ofs = bl_ofs & ~(block_size - 1);
-    end = bl_end;
-
-    // Calculate block-aligned logical range
-    off_t aligned_start = bl_ofs & ~(block_size - 1);
-    off_t aligned_end = (bl_end & ~(block_size - 1)) + (block_size - 1);
-
-    // Convert to encrypted offsets
-    bl_ofs = logical_to_encrypted_offset(aligned_start);
-    bl_end = align_to_encrypted_block_end(logical_to_encrypted_offset(aligned_end));
-    enc_ofs = bl_ofs;
-
-    // Clamp to actual encrypted object size
-    if (encrypted_total_size > 0 && bl_end >= encrypted_total_size) {
-      bl_end = encrypted_total_size - 1;
-    }
-  }
+  bl_ofs = range.ofs;
+  bl_end = range.end;
 
   ldpp_dout(this->dpp, 20) << "fixup_range [" << inp_ofs << "," << inp_end
       << "] => [" << bl_ofs << "," << bl_end << "]"
@@ -1673,29 +1641,6 @@ int RGWGetObj_BlockDecrypt::process_part_boundaries(size_t& plain_part_ofs_out)
   return 0;
 }
 
-RGWGetObj_BlockDecrypt::PartLocation
-RGWGetObj_BlockDecrypt::find_part_for_plaintext_offset(off_t plaintext_ofs, bool clamp_to_last) const
-{
-  PartLocation loc = {0, plaintext_ofs, 0};
-
-  // If clamp_to_last, stop at second-to-last part (used for end offsets)
-  size_t limit = (clamp_to_last && parts_len.size() > 0)
-                 ? (parts_len.size() - 1)
-                 : parts_len.size();
-
-  while (loc.part_idx < limit) {
-    size_t part_plain = encrypted_to_plaintext_size(parts_len[loc.part_idx]);
-    if (loc.offset_in_part < (off_t)part_plain) {
-      break;  // Found the part containing this offset
-    }
-    loc.offset_in_part -= part_plain;
-    loc.cumulative_encrypted += parts_len[loc.part_idx];
-    loc.part_idx++;
-  }
-
-  return loc;
-}
-
 int RGWGetObj_BlockDecrypt::handle_data(bufferlist& bl, off_t bl_ofs, off_t bl_len) {
   ldpp_dout(this->dpp, 25) << "Decrypt " << bl_len << " bytes"
       << " (encrypted_block_size=" << encrypted_block_size << ")" << dendl;
index 9d431f5b710d635d8407065969f1e7753ff978fc..386ad462ea0630521b53ef55094c43809ba3805c 100644 (file)
@@ -186,6 +186,39 @@ inline uint64_t aead_plaintext_to_encrypted_offset(uint64_t plaintext_ofs) {
   return chunk_idx * AEAD_ENCRYPTED_CHUNK_SIZE + offset_in_chunk;
 }
 
+/*
+ * Generic cipher geometry helpers (parameterized by block sizes).
+ * CBC: block_size == enc_block_size (identity).
+ * AEAD: enc_block_size > block_size (expansion).
+ */
+
+inline off_t crypt_logical_to_enc_offset(off_t logical_ofs,
+                                          size_t block_size,
+                                          size_t enc_block_size) {
+  if (block_size == enc_block_size) return logical_ofs;
+  uint64_t chunk_idx = logical_ofs / block_size;
+  uint64_t offset_in_chunk = logical_ofs % block_size;
+  return chunk_idx * enc_block_size + offset_in_chunk;
+}
+
+inline off_t crypt_align_enc_block_end(off_t enc_ofs,
+                                        size_t block_size,
+                                        size_t enc_block_size) {
+  if (block_size == enc_block_size) return enc_ofs;
+  return (enc_ofs / enc_block_size) * enc_block_size + (enc_block_size - 1);
+}
+
+inline size_t crypt_enc_to_plaintext_size(size_t encrypted_size,
+                                           size_t block_size,
+                                           size_t enc_block_size) {
+  if (block_size == enc_block_size) return encrypted_size;
+  uint64_t full_chunks = encrypted_size / enc_block_size;
+  uint64_t remainder = encrypted_size % enc_block_size;
+  uint64_t tag_size = enc_block_size - block_size;
+  uint64_t partial = (remainder > tag_size) ? (remainder - tag_size) : 0;
+  return full_chunks * block_size + partial;
+}
+
 bool AES_256_ECB_encrypt(const DoutPrefixProvider* dpp,
                          CephContext* cct,
                          const uint8_t* key,
@@ -246,53 +279,8 @@ class RGWGetObj_BlockDecrypt : public RGWGetObj_Filter {
    */
   int process_part_boundaries(size_t& plain_part_ofs_out);
 
-  /**
-   * Result of finding which part contains a plaintext offset.
-   */
-  struct PartLocation {
-    size_t part_idx;            /**< Index into parts_len */
-    off_t offset_in_part;       /**< Plaintext offset within the part */
-    off_t cumulative_encrypted; /**< Total encrypted bytes before this part */
-  };
-
-  /**
-   * Find which part contains a given plaintext offset.
-   * @param plaintext_ofs  The plaintext offset to locate
-   * @param clamp_to_last  If true, stop at second-to-last part (for end offsets)
-   */
-  PartLocation find_part_for_plaintext_offset(off_t plaintext_ofs, bool clamp_to_last) const;
-
-  /**
-   * Align an encrypted offset up to the end of an encrypted block.
-   * For GCM: ensures we read complete blocks including auth tags.
-   */
-  off_t align_to_encrypted_block_end(off_t enc_ofs) const {
-    if (block_size == encrypted_block_size) {
-      return enc_ofs;  // CBC - no alignment needed
-    }
-    return (enc_ofs / encrypted_block_size) * encrypted_block_size + (encrypted_block_size - 1);
-  }
-
-  /**
-   * Convert a logical (plaintext) offset to encrypted (storage) offset.
-   * For AEAD modes: accounts for auth tag overhead per chunk.
-   */
-  off_t logical_to_encrypted_offset(off_t logical_ofs) const {
-    if (block_size == encrypted_block_size) {
-      return logical_ofs; // Non-AEAD (CBC) - no conversion needed
-    }
-    return aead_plaintext_to_encrypted_offset(logical_ofs);
-  }
-
-  /**
-   * Convert an encrypted size to plaintext size.
-   * For AEAD modes: removes the auth tag overhead per chunk.
-   */
   size_t encrypted_to_plaintext_size(size_t encrypted_size) const {
-    if (block_size == encrypted_block_size) {
-      return encrypted_size;  // Non-AEAD (CBC) - no conversion needed
-    }
-    return aead_encrypted_to_plaintext_size(encrypted_size, dpp);
+    return crypt_enc_to_plaintext_size(encrypted_size, block_size, encrypted_block_size);
   }
 
 public:
diff --git a/src/rgw/rgw_range_projection.h b/src/rgw/rgw_range_projection.h
new file mode 100644 (file)
index 0000000..7ddaff8
--- /dev/null
@@ -0,0 +1,207 @@
+// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*-
+// vim: ts=8 sw=2 sts=2 expandtab ft=cpp
+
+/*
+ * Ceph - scalable distributed file system
+ *
+ * Copyright (C) 2026 Red Hat, Inc.
+ *
+ * This is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License version 2.1, as published by the Free Software
+ * Foundation. See file COPYING.
+ */
+
+#pragma once
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <vector>
+#include <sys/types.h>
+
+#include "rgw_compression_types.h"
+#include "rgw_crypt.h"
+
+// Stateless range projection: plaintext byte range -> on-disk byte range
+struct DiskRange {
+  off_t ofs = 0;        // disk start offset
+  off_t end = -1;       // disk end offset (inclusive); end < ofs means empty
+  off_t skip = 0;       // bytes to skip in first decompressed/decrypted block
+  uint64_t length = 0;  // original plaintext request length
+  size_t enc_begin_skip = 0; // plaintext offset within the starting encryption block
+
+  bool empty() const { return end < ofs; }
+  uint64_t disk_bytes() const { return empty() ? 0 : end - ofs + 1; }
+};
+
+// DiskRange plus block indices for decompression filter init
+struct DecompressRange : DiskRange {
+  size_t first_block_idx = 0;
+  size_t last_block_idx = 0;
+  off_t q_ofs = 0;    // offset within first decompressed block
+  uint64_t q_len = 0; // plaintext bytes requested
+};
+
+// Map plaintext range to compressed on-disk blocks.
+inline DecompressRange project_compress_range(
+    off_t ofs, off_t end,
+    const RGWCompressionInfo& cs_info,
+    bool partial_content)
+{
+  DecompressRange r;
+  r.length = end + 1 - ofs;
+
+  size_t first_idx = 0;
+  size_t last_idx = 0;
+
+  if (partial_content) {
+    // user set a range — find the blocks that bracket it
+    first_idx = 0;
+    last_idx = 0;
+    if (cs_info.blocks.size() > 1) {
+      auto cmp_u = [](off_t o, const compression_block& e) {
+        return (uint64_t)o < e.old_ofs;
+      };
+      auto cmp_l = [](const compression_block& e, off_t o) {
+        return e.old_ofs <= (uint64_t)o;
+      };
+      auto fb = std::upper_bound(cs_info.blocks.begin() + 1,
+                                 cs_info.blocks.end(),
+                                 ofs, cmp_u);
+      first_idx = (fb - 1) - cs_info.blocks.begin();
+
+      auto lb = std::lower_bound(fb,
+                                 cs_info.blocks.end(),
+                                 end, cmp_l);
+      last_idx = (lb - 1) - cs_info.blocks.begin();
+    }
+  } else {
+    // full object — use first and last blocks
+    first_idx = 0;
+    last_idx = cs_info.blocks.size() - 1;
+  }
+
+  const auto& first_block = cs_info.blocks[first_idx];
+  const auto& last_block = cs_info.blocks[last_idx];
+
+  r.first_block_idx = first_idx;
+  r.last_block_idx = last_idx;
+  r.q_ofs = ofs - first_block.old_ofs;
+  r.q_len = end + 1 - ofs;
+  r.skip = r.q_ofs;
+
+  r.ofs = first_block.new_ofs;
+  r.end = last_block.new_ofs + last_block.len - 1;
+
+  return r;
+}
+
+// Find which multipart part contains a given plaintext offset.
+inline void find_part_for_offset(
+    off_t plaintext_ofs,
+    const std::vector<size_t>& parts_len,
+    size_t block_size,
+    size_t enc_block_size,
+    bool clamp_to_last,
+    size_t& part_idx,
+    off_t& offset_in_part,
+    off_t& cumulative_encrypted)
+{
+  part_idx = 0;
+  offset_in_part = plaintext_ofs;
+  cumulative_encrypted = 0;
+
+  size_t limit = (clamp_to_last && parts_len.size() > 0)
+                 ? (parts_len.size() - 1)
+                 : parts_len.size();
+
+  while (part_idx < limit) {
+    size_t part_plain = crypt_enc_to_plaintext_size(
+        parts_len[part_idx], block_size, enc_block_size);
+    if (offset_in_part < (off_t)part_plain) {
+      break;
+    }
+    offset_in_part -= part_plain;
+    cumulative_encrypted += parts_len[part_idx];
+    part_idx++;
+  }
+}
+
+// Map plaintext range to encrypted on-disk bytes.
+inline DiskRange project_encrypt_range(
+    off_t ofs, off_t end,
+    size_t block_size,
+    size_t enc_block_size,
+    uint64_t encrypted_total_size,
+    const std::vector<size_t>& parts_len)
+{
+  DiskRange r;
+  r.length = end + 1 - ofs;
+
+  if (parts_len.size() > 0) {
+    // multipart object
+    size_t start_part_idx;
+    off_t start_offset_in_part;
+    off_t start_cumulative;
+    find_part_for_offset(ofs, parts_len, block_size, enc_block_size,
+                         false, start_part_idx, start_offset_in_part,
+                         start_cumulative);
+
+    size_t end_part_idx;
+    off_t end_offset_in_part;
+    off_t end_cumulative;
+    find_part_for_offset(end, parts_len, block_size, enc_block_size,
+                         true, end_part_idx, end_offset_in_part,
+                         end_cumulative);
+
+    // block-align end within its part (in plaintext space)
+    size_t part_plaintext_end = crypt_enc_to_plaintext_size(
+        parts_len[end_part_idx], block_size, enc_block_size);
+    off_t rounded_end = std::min(
+        (off_t)((end_offset_in_part & ~(block_size - 1)) + (block_size - 1)),
+        (off_t)(part_plaintext_end - 1));
+
+    // enc_begin_skip is offset within the starting block
+    r.enc_begin_skip = start_offset_in_part & (block_size - 1);
+    r.skip = r.enc_begin_skip;
+
+    // convert end offset: plaintext -> encrypted, align to encrypted block
+    off_t enc_end = crypt_align_enc_block_end(
+        crypt_logical_to_enc_offset(rounded_end, block_size, enc_block_size),
+        block_size, enc_block_size);
+    enc_end = std::min(enc_end, (off_t)(parts_len[end_part_idx] - 1));
+    r.end = end_cumulative + enc_end;
+
+    // convert start offset: align in plaintext, then to encrypted
+    off_t aligned_start = std::max((off_t)0,
+        start_offset_in_part - (off_t)r.enc_begin_skip);
+    r.ofs = start_cumulative +
+        crypt_logical_to_enc_offset(aligned_start, block_size, enc_block_size);
+
+    // clamp start to end (handles invalid ranges)
+    r.ofs = std::min(r.ofs, r.end);
+  } else {
+    // simple (non-multipart) object
+    r.enc_begin_skip = ofs & (block_size - 1);
+    r.skip = r.enc_begin_skip;
+
+    // block-align in plaintext space
+    off_t aligned_start = ofs & ~(block_size - 1);
+    off_t aligned_end = (end & ~(block_size - 1)) + (block_size - 1);
+
+    // convert to encrypted offsets
+    r.ofs = crypt_logical_to_enc_offset(aligned_start, block_size, enc_block_size);
+    r.end = crypt_align_enc_block_end(
+        crypt_logical_to_enc_offset(aligned_end, block_size, enc_block_size),
+        block_size, enc_block_size);
+
+    // clamp to actual encrypted object size
+    if (encrypted_total_size > 0 && r.end >= (off_t)encrypted_total_size) {
+      r.end = encrypted_total_size - 1;
+    }
+  }
+
+  return r;
+}
+
index c64e6f22068e621e5721e3a75ddb45ec5beb28bf..c587b5834dcbc539b3cc81bca7045a8e76978a8d 100644 (file)
@@ -230,6 +230,13 @@ target_link_libraries(unittest_rgw_crypto
   ${CRYPTO_LIBS}
   )
 
+add_executable(unittest_rgw_range_projection test_rgw_range_projection.cc)
+add_ceph_unittest(unittest_rgw_range_projection)
+target_link_libraries(unittest_rgw_range_projection
+  ${rgw_libs}
+  ${UNITTEST_LIBS}
+  )
+
 if(WITH_RADOSGW_RADOS)
 set(test_rgw_reshard_srcs test_rgw_reshard.cc)
 add_executable(unittest_rgw_reshard
index 08e262e62698f71e4bf8f810192f285bc96d5590..f7cecb00b305ca5f870b3a0fb7b65a2aadd8c2a2 100644 (file)
@@ -1357,217 +1357,128 @@ TEST(TestRGWCrypto, verify_AES_256_GCM_aad_offset_mismatch)
 
 // Test helper class to expose private methods for unit testing
 // (declared as friend in RGWGetObj_BlockDecrypt)
-class TestableBlockDecrypt : public RGWGetObj_BlockDecrypt {
-public:
-  // Re-export PartLocation struct for test code
-  using PartLocation = RGWGetObj_BlockDecrypt::PartLocation;
-
-  TestableBlockDecrypt(const DoutPrefixProvider* dpp,
-                       CephContext* cct,
-                       RGWGetObj_Filter* next,
-                       std::unique_ptr<BlockCrypt> crypt,
-                       std::vector<size_t> parts_len)
-    : RGWGetObj_BlockDecrypt(dpp, cct, next, std::move(crypt),
-                             std::move(parts_len), null_yield) {}
-
-  // Wrapper to expose private method for testing
-  PartLocation find_part_for_plaintext_offset(off_t plaintext_ofs, bool clamp_to_last) const {
-    return RGWGetObj_BlockDecrypt::find_part_for_plaintext_offset(plaintext_ofs, clamp_to_last);
-  }
-};
+// PartLocation tests use the free function find_part_for_offset()
+// from rgw_range_projection.h instead of the removed class method.
+#include "rgw_range_projection.h"
 
 TEST(TestRGWCrypto, verify_PartLocation_single_part)
 {
-  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
-  ut_get_sink get_sink;
-
   std::vector<size_t> parts = {10 * 1024 * 1024};
-  auto crypt = std::make_unique<BlockCryptNone>(4096);
-  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
-                               std::move(crypt), parts);
-
-  auto loc = decrypt.find_part_for_plaintext_offset(0, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, 0);
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
-
-  loc = decrypt.find_part_for_plaintext_offset(1000, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, 1000);
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
-
-  loc = decrypt.find_part_for_plaintext_offset(10 * 1024 * 1024 - 1, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, 10 * 1024 * 1024 - 1);
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
+  size_t idx; off_t ofs_in_part, cumulative;
+
+  find_part_for_offset(0, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u);
+  ASSERT_EQ(ofs_in_part, 0);
+  ASSERT_EQ(cumulative, 0);
+
+  find_part_for_offset(1000, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u);
+  ASSERT_EQ(ofs_in_part, 1000);
+  ASSERT_EQ(cumulative, 0);
+
+  find_part_for_offset(10 * 1024 * 1024 - 1, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u);
+  ASSERT_EQ(ofs_in_part, 10 * 1024 * 1024 - 1);
+  ASSERT_EQ(cumulative, 0);
 }
 
 TEST(TestRGWCrypto, verify_PartLocation_multipart_cbc)
 {
-  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
-  ut_get_sink get_sink;
+  const size_t ps = 5 * 1024 * 1024;
+  std::vector<size_t> parts = {ps, ps, ps};
+  size_t idx; off_t ofs_in_part, cumulative;
+
+  find_part_for_offset(0, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u); ASSERT_EQ(ofs_in_part, 0); ASSERT_EQ(cumulative, 0);
 
-  const size_t part_size = 5 * 1024 * 1024;
-  std::vector<size_t> parts = {part_size, part_size, part_size};
-  auto crypt = std::make_unique<BlockCryptNone>(4096);
-  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
-                               std::move(crypt), parts);
-
-  auto loc = decrypt.find_part_for_plaintext_offset(0, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, 0);
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
-
-  loc = decrypt.find_part_for_plaintext_offset(part_size - 1, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, (off_t)(part_size - 1));
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
-
-  loc = decrypt.find_part_for_plaintext_offset(part_size, false);
-  ASSERT_EQ(loc.part_idx, 1u);
-  ASSERT_EQ(loc.offset_in_part, 0);
-  ASSERT_EQ(loc.cumulative_encrypted, (off_t)part_size);
-
-  loc = decrypt.find_part_for_plaintext_offset(part_size + 1000, false);
-  ASSERT_EQ(loc.part_idx, 1u);
-  ASSERT_EQ(loc.offset_in_part, 1000);
-  ASSERT_EQ(loc.cumulative_encrypted, (off_t)part_size);
-
-  loc = decrypt.find_part_for_plaintext_offset(2 * part_size, false);
-  ASSERT_EQ(loc.part_idx, 2u);
-  ASSERT_EQ(loc.offset_in_part, 0);
-  ASSERT_EQ(loc.cumulative_encrypted, (off_t)(2 * part_size));
-
-  loc = decrypt.find_part_for_plaintext_offset(3 * part_size - 100, false);
-  ASSERT_EQ(loc.part_idx, 2u);
-  ASSERT_EQ(loc.offset_in_part, (off_t)(part_size - 100));
-  ASSERT_EQ(loc.cumulative_encrypted, (off_t)(2 * part_size));
+  find_part_for_offset(ps - 1, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u); ASSERT_EQ(ofs_in_part, (off_t)(ps - 1)); ASSERT_EQ(cumulative, 0);
+
+  find_part_for_offset(ps, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 1u); ASSERT_EQ(ofs_in_part, 0); ASSERT_EQ(cumulative, (off_t)ps);
+
+  find_part_for_offset(ps + 1000, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 1u); ASSERT_EQ(ofs_in_part, 1000); ASSERT_EQ(cumulative, (off_t)ps);
+
+  find_part_for_offset(2 * ps, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 2u); ASSERT_EQ(ofs_in_part, 0); ASSERT_EQ(cumulative, (off_t)(2 * ps));
+
+  find_part_for_offset(3 * ps - 100, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 2u); ASSERT_EQ(ofs_in_part, (off_t)(ps - 100)); ASSERT_EQ(cumulative, (off_t)(2 * ps));
 }
 
 TEST(TestRGWCrypto, verify_PartLocation_multipart_aead)
 {
-  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
-  ut_get_sink get_sink;
+  const size_t pp = 1024 * AEAD_CHUNK_SIZE;  // 4MB plaintext per part
+  const size_t ep = aead_plaintext_to_encrypted_size(pp);
+  std::vector<size_t> parts = {ep, ep, ep};
+  size_t idx; off_t ofs_in_part, cumulative;
+
+  find_part_for_offset(0, parts, 4096, 4112, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u); ASSERT_EQ(ofs_in_part, 0); ASSERT_EQ(cumulative, 0);
+
+  find_part_for_offset(pp - 1, parts, 4096, 4112, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u); ASSERT_EQ(ofs_in_part, (off_t)(pp - 1)); ASSERT_EQ(cumulative, 0);
+
+  find_part_for_offset(pp, parts, 4096, 4112, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 1u); ASSERT_EQ(ofs_in_part, 0); ASSERT_EQ(cumulative, (off_t)ep);
 
-  // For AEAD, parts_len contains ENCRYPTED sizes (with auth tag overhead)
-  const size_t plaintext_per_part = 1024 * AEAD_CHUNK_SIZE;  // 4MB
-  const size_t encrypted_per_part = aead_plaintext_to_encrypted_size(plaintext_per_part);
-  std::vector<size_t> parts = {encrypted_per_part, encrypted_per_part, encrypted_per_part};
-
-  auto crypt = std::make_unique<BlockCryptNoneAEAD>();
-  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
-                               std::move(crypt), parts);
-
-  auto loc = decrypt.find_part_for_plaintext_offset(0, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, 0);
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
-
-  loc = decrypt.find_part_for_plaintext_offset(plaintext_per_part - 1, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, (off_t)(plaintext_per_part - 1));
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
-
-  loc = decrypt.find_part_for_plaintext_offset(plaintext_per_part, false);
-  ASSERT_EQ(loc.part_idx, 1u);
-  ASSERT_EQ(loc.offset_in_part, 0);
-  ASSERT_EQ(loc.cumulative_encrypted, (off_t)encrypted_per_part);
-
-  loc = decrypt.find_part_for_plaintext_offset(plaintext_per_part + 8192, false);
-  ASSERT_EQ(loc.part_idx, 1u);
-  ASSERT_EQ(loc.offset_in_part, 8192);
-  ASSERT_EQ(loc.cumulative_encrypted, (off_t)encrypted_per_part);
-
-  loc = decrypt.find_part_for_plaintext_offset(2 * plaintext_per_part, false);
-  ASSERT_EQ(loc.part_idx, 2u);
-  ASSERT_EQ(loc.offset_in_part, 0);
-  ASSERT_EQ(loc.cumulative_encrypted, (off_t)(2 * encrypted_per_part));
+  find_part_for_offset(pp + 8192, parts, 4096, 4112, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 1u); ASSERT_EQ(ofs_in_part, 8192); ASSERT_EQ(cumulative, (off_t)ep);
+
+  find_part_for_offset(2 * pp, parts, 4096, 4112, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 2u); ASSERT_EQ(ofs_in_part, 0); ASSERT_EQ(cumulative, (off_t)(2 * ep));
 }
 
 TEST(TestRGWCrypto, verify_PartLocation_clamp_to_last)
 {
-  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
-  ut_get_sink get_sink;
+  const size_t ps = 5 * 1024 * 1024;
+  std::vector<size_t> parts = {ps, ps, ps};
+  size_t idx; off_t ofs_in_part, cumulative;
+
+  find_part_for_offset(2 * ps + 1000, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 2u); ASSERT_EQ(ofs_in_part, 1000);
+
+  find_part_for_offset(2 * ps + 1000, parts, 4096, 4096, true, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 2u); ASSERT_EQ(ofs_in_part, 1000);
 
-  const size_t part_size = 5 * 1024 * 1024;
-  std::vector<size_t> parts = {part_size, part_size, part_size};
-  auto crypt = std::make_unique<BlockCryptNone>(4096);
-  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
-                               std::move(crypt), parts);
-
-  // Offset within object - both modes return the same
-  auto loc = decrypt.find_part_for_plaintext_offset(2 * part_size + 1000, false);
-  ASSERT_EQ(loc.part_idx, 2u);
-  ASSERT_EQ(loc.offset_in_part, 1000);
-
-  loc = decrypt.find_part_for_plaintext_offset(2 * part_size + 1000, true);
-  ASSERT_EQ(loc.part_idx, 2u);
-  ASSERT_EQ(loc.offset_in_part, 1000);
-
-  // Offset beyond all parts - without clamping returns invalid index
-  loc = decrypt.find_part_for_plaintext_offset(3 * part_size + 5000, false);
-  ASSERT_EQ(loc.part_idx, 3u);
-  ASSERT_EQ(loc.offset_in_part, 5000);
-
-  // With clamping - stays at last valid part
-  loc = decrypt.find_part_for_plaintext_offset(3 * part_size + 5000, true);
-  ASSERT_EQ(loc.part_idx, 2u);
-  ASSERT_EQ(loc.offset_in_part, (off_t)(part_size + 5000));
+  find_part_for_offset(3 * ps + 5000, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 3u); ASSERT_EQ(ofs_in_part, 5000);
+
+  find_part_for_offset(3 * ps + 5000, parts, 4096, 4096, true, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 2u); ASSERT_EQ(ofs_in_part, (off_t)(ps + 5000));
 }
 
 TEST(TestRGWCrypto, verify_PartLocation_unequal_parts)
 {
-  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
-  ut_get_sink get_sink;
-
-  // Parts: 1MB, 3MB, 2MB
+  // CBC parts: 1MB, 3MB, 2MB
   std::vector<size_t> parts = {1024 * 1024, 3 * 1024 * 1024, 2 * 1024 * 1024};
-  auto crypt = std::make_unique<BlockCryptNone>(4096);
-  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
-                               std::move(crypt), parts);
-
-  auto loc = decrypt.find_part_for_plaintext_offset(500 * 1024, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, 500 * 1024);
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
-
-  loc = decrypt.find_part_for_plaintext_offset(1536 * 1024, false);  // 1.5MB
-  ASSERT_EQ(loc.part_idx, 1u);
-  ASSERT_EQ(loc.offset_in_part, 512 * 1024);
-  ASSERT_EQ(loc.cumulative_encrypted, 1024 * 1024);
-
-  loc = decrypt.find_part_for_plaintext_offset(4608 * 1024, false);  // 4.5MB
-  ASSERT_EQ(loc.part_idx, 2u);
-  ASSERT_EQ(loc.offset_in_part, 512 * 1024);
-  ASSERT_EQ(loc.cumulative_encrypted, 4 * 1024 * 1024);
+  size_t idx; off_t ofs_in_part, cumulative;
+
+  find_part_for_offset(500 * 1024, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u); ASSERT_EQ(ofs_in_part, 500 * 1024); ASSERT_EQ(cumulative, 0);
+
+  find_part_for_offset(1536 * 1024, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 1u); ASSERT_EQ(ofs_in_part, 512 * 1024); ASSERT_EQ(cumulative, 1024 * 1024);
+
+  find_part_for_offset(4608 * 1024, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 2u); ASSERT_EQ(ofs_in_part, 512 * 1024); ASSERT_EQ(cumulative, 4 * 1024 * 1024);
 }
 
 TEST(TestRGWCrypto, verify_PartLocation_no_manifest)
 {
-  // Single-part objects don't have a multipart manifest, so parts_len is empty.
-  // The code treats this as one logical part starting at offset 0.
-  const NoDoutPrefix no_dpp(g_ceph_context, dout_subsys);
-  ut_get_sink get_sink;
-
+  // Empty parts = single-part object
   std::vector<size_t> parts = {};
-  auto crypt = std::make_unique<BlockCryptNone>(4096);
-  TestableBlockDecrypt decrypt(&no_dpp, g_ceph_context, &get_sink,
-                               std::move(crypt), parts);
-
-  auto loc = decrypt.find_part_for_plaintext_offset(0, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, 0);
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
-
-  loc = decrypt.find_part_for_plaintext_offset(1000, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, 1000);
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
-
-  loc = decrypt.find_part_for_plaintext_offset(10 * 1024 * 1024, false);
-  ASSERT_EQ(loc.part_idx, 0u);
-  ASSERT_EQ(loc.offset_in_part, 10 * 1024 * 1024);
-  ASSERT_EQ(loc.cumulative_encrypted, 0);
+  size_t idx; off_t ofs_in_part, cumulative;
+
+  find_part_for_offset(0, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u); ASSERT_EQ(ofs_in_part, 0); ASSERT_EQ(cumulative, 0);
+
+  find_part_for_offset(1000, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u); ASSERT_EQ(ofs_in_part, 1000); ASSERT_EQ(cumulative, 0);
+
+  find_part_for_offset(10 * 1024 * 1024, parts, 4096, 4096, false, idx, ofs_in_part, cumulative);
+  ASSERT_EQ(idx, 0u); ASSERT_EQ(ofs_in_part, 10 * 1024 * 1024); ASSERT_EQ(cumulative, 0);
 }
 
 
diff --git a/src/test/rgw/test_rgw_range_projection.cc b/src/test/rgw/test_rgw_range_projection.cc
new file mode 100644 (file)
index 0000000..a3215a9
--- /dev/null
@@ -0,0 +1,338 @@
+// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*-
+// vim: ts=8 sw=2 sts=2 expandtab ft=cpp
+
+/*
+ * Ceph - scalable distributed file system
+ *
+ * Copyright (C) 2026 Red Hat, Inc.
+ *
+ * This is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License version 2.1, as published by the Free Software
+ * Foundation. See file COPYING.
+ */
+
+#include <gtest/gtest.h>
+#include "rgw_range_projection.h"
+
+using namespace std;
+
+// helpers to build synthetic compression block maps
+
+static RGWCompressionInfo make_cs_info(
+    const vector<pair<uint64_t, uint64_t>>& blocks,
+    uint64_t orig_size,
+    const string& type = "zlib")
+{
+  /*
+   * Each pair is (uncompressed_size, compressed_size).
+   * We compute old_ofs / new_ofs cumulatively.
+   */
+  RGWCompressionInfo cs;
+  cs.compression_type = type;
+  cs.orig_size = orig_size;
+
+  uint64_t old_ofs = 0;
+  uint64_t new_ofs = 0;
+  for (auto& [plain_len, comp_len] : blocks) {
+    compression_block b;
+    b.old_ofs = old_ofs;
+    b.new_ofs = new_ofs;
+    b.len = comp_len;
+    cs.blocks.push_back(b);
+    old_ofs += plain_len;
+    new_ofs += comp_len;
+  }
+  return cs;
+}
+
+TEST(DiskRange, EmptyWhenEndLessThanOfs)
+{
+  DiskRange r;
+  r.ofs = 10;
+  r.end = 5;
+  EXPECT_TRUE(r.empty());
+  EXPECT_EQ(0u, r.disk_bytes());
+}
+
+TEST(DiskRange, NotEmptyWhenEndEqualsOfs)
+{
+  DiskRange r;
+  r.ofs = 10;
+  r.end = 10;
+  EXPECT_FALSE(r.empty());
+  EXPECT_EQ(1u, r.disk_bytes());
+}
+
+TEST(DiskRange, DiskBytesCorrect)
+{
+  DiskRange r;
+  r.ofs = 100;
+  r.end = 199;
+  EXPECT_EQ(100u, r.disk_bytes());
+}
+
+TEST(DiskRange, DefaultIsEmpty)
+{
+  DiskRange r;
+  // default: ofs=0, end=-1
+  EXPECT_TRUE(r.empty());
+  EXPECT_EQ(0u, r.disk_bytes());
+}
+
+TEST(ProjectDecompress, FullObjectReturnsEntireCompressedRange)
+{
+  // 3 blocks: 4096 plain -> 2048 compressed each
+  auto cs = make_cs_info(
+      {{4096, 2048}, {4096, 2048}, {4096, 2048}},
+      12288);
+
+  auto dr = project_compress_range(0, 12287, cs, false);
+
+  // should span all blocks: disk [0, 6143]
+  EXPECT_EQ(0, dr.ofs);
+  EXPECT_EQ(6143, dr.end);
+  EXPECT_EQ(0u, dr.first_block_idx);
+  EXPECT_EQ(2u, dr.last_block_idx);
+  EXPECT_EQ(0, dr.q_ofs);
+  EXPECT_EQ(12288u, dr.q_len);
+  EXPECT_EQ(6144u, dr.disk_bytes());
+}
+
+TEST(ProjectDecompress, PartialRangeMapsToCorrectBlocks)
+{
+  // 4 blocks, each 4096 plain -> 2000 compressed
+  auto cs = make_cs_info(
+      {{4096, 2000}, {4096, 2000}, {4096, 2000}, {4096, 2000}},
+      16384);
+
+  // request plaintext [4200, 8191] — entirely within block 1
+  auto dr = project_compress_range(4200, 8191, cs, true);
+
+  EXPECT_EQ(1u, dr.first_block_idx);
+  EXPECT_EQ(1u, dr.last_block_idx);
+  EXPECT_EQ(104, dr.q_ofs); // 4200 - 4096
+  EXPECT_EQ(3992u, dr.q_len); // 8191 - 4200 + 1
+  EXPECT_EQ(2000, dr.ofs);
+  EXPECT_EQ(3999, dr.end);
+}
+
+TEST(ProjectDecompress, PartialRangeSpansMultipleBlocks)
+{
+  // 4 blocks, each 4096 plain -> 2000 compressed
+  auto cs = make_cs_info(
+      {{4096, 2000}, {4096, 2000}, {4096, 2000}, {4096, 2000}},
+      16384);
+
+  // request plaintext [100, 8999] — spans blocks 0, 1, and 2
+  auto dr = project_compress_range(100, 8999, cs, true);
+
+  EXPECT_EQ(0u, dr.first_block_idx);
+  EXPECT_EQ(2u, dr.last_block_idx);
+  EXPECT_EQ(100, dr.q_ofs);
+  EXPECT_EQ(8900u, dr.q_len);
+  EXPECT_EQ(0, dr.ofs);
+  EXPECT_EQ(5999, dr.end);
+}
+
+TEST(ProjectDecompress, SingleBlock)
+{
+  auto cs = make_cs_info({{8192, 4000}}, 8192);
+
+  auto dr = project_compress_range(100, 999, cs, true);
+
+  EXPECT_EQ(0u, dr.first_block_idx);
+  EXPECT_EQ(0u, dr.last_block_idx);
+  EXPECT_EQ(100, dr.q_ofs);
+  EXPECT_EQ(900u, dr.q_len);
+  EXPECT_EQ(0, dr.ofs);
+  EXPECT_EQ(3999, dr.end);
+  EXPECT_EQ(4000u, dr.disk_bytes());
+}
+
+TEST(ProjectDecryptCBC, AlignedToBlockBoundary)
+{
+  // [100, 999] with block_size=4096 -> aligned to [0, 4095]
+  auto dr = project_encrypt_range(100, 999, 4096, 4096, 8192, {});
+
+  EXPECT_EQ(0, dr.ofs);
+  EXPECT_EQ(4095, dr.end);
+  EXPECT_EQ(100u, dr.enc_begin_skip);
+  EXPECT_EQ(100u, dr.skip);
+  EXPECT_EQ(4096u, dr.disk_bytes());
+}
+
+TEST(ProjectDecryptCBC, FullObject)
+{
+  // full object [0, 8191]
+  auto dr = project_encrypt_range(0, 8191, 4096, 4096, 8192, {});
+
+  EXPECT_EQ(0, dr.ofs);
+  EXPECT_EQ(8191, dr.end);
+  EXPECT_EQ(0u, dr.enc_begin_skip);
+  EXPECT_EQ(0u, dr.skip);
+  EXPECT_EQ(8192u, dr.disk_bytes());
+}
+
+TEST(ProjectDecryptCBC, NoExpansion)
+{
+  // CBC: enc_block_size == block_size, so no expansion
+  // [4096, 8191] -> [4096, 8191]
+  auto dr = project_encrypt_range(4096, 8191, 4096, 4096, 16384, {});
+
+  EXPECT_EQ(4096, dr.ofs);
+  EXPECT_EQ(8191, dr.end);
+  EXPECT_EQ(0u, dr.enc_begin_skip);
+  EXPECT_EQ(4096u, dr.disk_bytes());
+}
+
+TEST(ProjectDecryptCBC, MultipartCrossesPartBoundary)
+{
+  // 2 parts, each 8192 bytes encrypted (=plaintext for CBC)
+  // request [4000, 12000] crosses parts
+  vector<size_t> parts = {8192, 8192};
+  auto dr = project_encrypt_range(4000, 12000, 4096, 4096, 16384, parts);
+
+  // 4000 < 4096, so start is within first block of part 0
+  EXPECT_EQ(4000u, dr.enc_begin_skip);
+  EXPECT_EQ(0, dr.ofs);
+  // end lands in part 1 at offset 3808, block-aligned to 4095, plus part 0 size
+  EXPECT_EQ(12287, dr.end);
+}
+
+static const size_t GCM_BLOCK = 4096;
+static const size_t GCM_ENC_BLOCK = 4112; // 4096 + 16 byte tag
+
+TEST(ProjectDecryptGCM, SizeExpansion)
+{
+  // plaintext [0, 4095] -> encrypted [0, 4111]
+  // 1 chunk of 4096 plain -> 4112 encrypted
+  auto dr = project_encrypt_range(0, 4095, GCM_BLOCK, GCM_ENC_BLOCK, 4112, {});
+
+  EXPECT_EQ(0, dr.ofs);
+  EXPECT_EQ(4111, dr.end);
+  EXPECT_EQ(0u, dr.enc_begin_skip);
+  EXPECT_EQ(4112u, dr.disk_bytes());
+}
+
+TEST(ProjectDecryptGCM, TailClampedToEncryptedTotalSize)
+{
+  // object with 2 chunks: total encrypted = 2 * 4112 = 8224
+  // request plaintext [0, 8191] -> should clamp to [0, 8223]
+  auto dr = project_encrypt_range(0, 8191, GCM_BLOCK, GCM_ENC_BLOCK, 8224, {});
+
+  EXPECT_EQ(0, dr.ofs);
+  EXPECT_EQ(8223, dr.end);
+  EXPECT_EQ(8224u, dr.disk_bytes());
+}
+
+TEST(ProjectDecryptGCM, MidRangeAlignment)
+{
+  // plaintext [100, 4095] with 2 chunks (total 8224)
+  auto dr = project_encrypt_range(100, 4095, GCM_BLOCK, GCM_ENC_BLOCK, 8224, {});
+
+  EXPECT_EQ(0, dr.ofs);
+  EXPECT_EQ(4111, dr.end);
+  EXPECT_EQ(100u, dr.enc_begin_skip);
+  EXPECT_EQ(4112u, dr.disk_bytes());
+}
+
+TEST(ProjectDecryptGCM, MultipartCumulativeOffsets)
+{
+  // 2 parts: part0 = 4112 bytes (1 chunk), part1 = 8224 bytes (2 chunks)
+  // total encrypted = 12336
+  // request plaintext [0, 4095]: all in part0
+  vector<size_t> parts = {4112, 8224};
+  auto dr = project_encrypt_range(0, 4095, GCM_BLOCK, GCM_ENC_BLOCK, 12336, parts);
+
+  // entirely within part 0
+  EXPECT_EQ(0, dr.ofs);
+  EXPECT_EQ(4111, dr.end);
+  EXPECT_EQ(0u, dr.enc_begin_skip);
+}
+
+TEST(ProjectDecryptGCM, MultipartSecondPart)
+{
+  // 2 parts: part0 = 4112 (1 plain chunk), part1 = 8224 (2 plain chunks)
+  // plaintext sizes: part0=4096, part1=8192
+  // request plaintext [4096, 8191]: starts at part1 offset 0
+  vector<size_t> parts = {4112, 8224};
+  auto dr = project_encrypt_range(4096, 8191, GCM_BLOCK, GCM_ENC_BLOCK, 12336, parts);
+
+  // starts at beginning of part 1 (cumulative enc offset 4112)
+  EXPECT_EQ(4112, dr.ofs);
+  EXPECT_EQ(8223, dr.end); // 4112 + 4111
+  EXPECT_EQ(0u, dr.enc_begin_skip);
+}
+
+TEST(ProjectDecryptGCM, EndBeyondLastPartClamped)
+{
+  // 1 part of 4112 bytes (1 chunk, 4096 plain)
+  // request beyond: [0, 99999]
+  vector<size_t> parts = {4112};
+  auto dr = project_encrypt_range(0, 99999, GCM_BLOCK, GCM_ENC_BLOCK, 4112, parts);
+
+  // end is clamped to the single part's encrypted size
+  EXPECT_EQ(0, dr.ofs);
+  EXPECT_EQ(4111, dr.end);
+}
+
+TEST(ProjectDecryptGCM, InvalidRangeStartClampedToEnd)
+{
+  // start > total plaintext but within part index bounds
+  // For a simple (non-multipart) object: [50000, 50100] with total=4112
+  auto dr = project_encrypt_range(50000, 50100, GCM_BLOCK, GCM_ENC_BLOCK, 4112, {});
+
+  // ofs expands well past encrypted_total_size, so ofs > end -> empty range
+  EXPECT_TRUE(dr.empty());
+  EXPECT_EQ(0u, dr.disk_bytes());
+}
+
+TEST(CryptHelpers, LogicalToEncOffsetCBC)
+{
+  // CBC: identity
+  EXPECT_EQ(1000, crypt_logical_to_enc_offset(1000, 4096, 4096));
+  EXPECT_EQ(0, crypt_logical_to_enc_offset(0, 4096, 4096));
+  EXPECT_EQ(8192, crypt_logical_to_enc_offset(8192, 4096, 4096));
+}
+
+TEST(CryptHelpers, LogicalToEncOffsetGCM)
+{
+  // GCM: chunk 0 stays in [0..4095]
+  EXPECT_EQ(0, crypt_logical_to_enc_offset(0, 4096, 4112));
+  EXPECT_EQ(4095, crypt_logical_to_enc_offset(4095, 4096, 4112));
+  // chunk 1 starts at 4112
+  EXPECT_EQ(4112, crypt_logical_to_enc_offset(4096, 4096, 4112));
+  // chunk 2 starts at 8224
+  EXPECT_EQ(8224, crypt_logical_to_enc_offset(8192, 4096, 4112));
+}
+
+TEST(CryptHelpers, AlignEncBlockEndCBC)
+{
+  // CBC: identity
+  EXPECT_EQ(4095, crypt_align_enc_block_end(4095, 4096, 4096));
+  EXPECT_EQ(100, crypt_align_enc_block_end(100, 4096, 4096));
+}
+
+TEST(CryptHelpers, AlignEncBlockEndGCM)
+{
+  // GCM: rounds up to end of encrypted block
+  EXPECT_EQ(4111, crypt_align_enc_block_end(0, 4096, 4112));
+  EXPECT_EQ(4111, crypt_align_enc_block_end(4095, 4096, 4112));
+  EXPECT_EQ(8223, crypt_align_enc_block_end(4112, 4096, 4112));
+}
+
+TEST(CryptHelpers, EncToPlaintextSize)
+{
+  // CBC: identity
+  EXPECT_EQ(8192u, crypt_enc_to_plaintext_size(8192, 4096, 4096));
+  // GCM: 1 chunk
+  EXPECT_EQ(4096u, crypt_enc_to_plaintext_size(4112, 4096, 4112));
+  // GCM: 2 chunks
+  EXPECT_EQ(8192u, crypt_enc_to_plaintext_size(8224, 4096, 4112));
+  // GCM: partial chunk (4100 - 16 tag = 4084 plaintext)
+  EXPECT_EQ(4084u, crypt_enc_to_plaintext_size(4100, 4096, 4112));
+  // GCM: less than tag size -> 0
+  EXPECT_EQ(0u, crypt_enc_to_plaintext_size(10, 4096, 4112));
+}
+