Files
rustfs/crates/e2e_test/src/kms/encrypted_range_get_test.rs
T
唐小鸭 9118a6e344 feat(ecstore): closed-form range seek for single-part v2 encrypted objects (#6601)
Single-part encrypted objects in the legacy format could not serve range
reads without decrypting from byte 0: v1 frames are emitted per upstream
read, so no closed-form plaintext-to-ciphertext mapping exists. The v2
layout fixed the frame length (8218 ciphertext bytes per 8 KiB plaintext
frame), making the mapping closed-form.

Consume it:
- Single-part PUTs that encrypt locally under the v2 write switch stamp
  the frame-layout marker, valued with the object's data_dir token -
  ciphertext passthrough, data movement and copies mint a new data_dir
  or strip the marker, so a re-homed marker disqualifies itself.
- The encrypted read plan seeks marked, uncompressed single-part objects
  to frame_index * 8218 and decrypts from that frame: the frame index
  rides the plan's sequence-number slot into DecryptReader::new_at_block,
  whose nonce and AAD bind absolute indices. New metric path label
  frame_seek.
- A lying marker fails closed: v2 authentication rejects bytes at a fake
  frame boundary; plaintext is never served from the wrong offset.

Compressed objects and multipart sub-part seeks keep the conservative
paths (follow-up work); reading needs no switch - seekability follows
the marker.
2026-08-26 09:43:34 +08:00

141 lines
5.2 KiB
Rust

// Copyright 2024 RustFS Team
//
// 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.
//! Ranged GETs over encrypted single-part objects.
//!
//! Byte-exactness must hold on every frame layout the server can write:
//! legacy v1 (variable frames, conservative full read) and, when
//! `RUSTFS_ENCRYPTION_FRAME_V2=true` reaches the server under test, the
//! fixed-frame v2 layout whose marker enables the closed-form frame seek.
//! The matrix crosses frame boundaries, starts mid-frame, and ends inside
//! the final short frame, so a mispositioned seek cannot pass.
use super::common::LocalKMSTestEnvironment;
use crate::common::{TEST_BUCKET, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::ServerSideEncryption;
use tracing::info;
const FRAME_PLAINTEXT: usize = 8 * 1024;
#[tokio::test]
async fn sse_s3_single_part_ranged_gets_are_byte_exact() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Testing ranged GETs over an SSE-S3 single-part object");
let mut kms_env = LocalKMSTestEnvironment::new().await?;
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
kms_env.wait_for_kms_ready().await?;
let s3_client = kms_env.base_env.create_s3_client();
kms_env.base_env.create_test_bucket(TEST_BUCKET).await?;
let test_key = "encrypted-range-get";
let body: Vec<u8> = (0..3 * FRAME_PLAINTEXT + 500).map(|i| (i % 251) as u8).collect();
let put = s3_client
.put_object()
.bucket(TEST_BUCKET)
.key(test_key)
.server_side_encryption(ServerSideEncryption::Aes256)
.body(ByteStream::from(body.clone()))
.send()
.await?;
assert_eq!(
put.server_side_encryption(),
Some(&ServerSideEncryption::Aes256),
"the object under test must actually be encrypted"
);
let cases: &[(usize, usize)] = &[
// Head range inside frame 0.
(0, 99),
// Crossing the first frame boundary.
(FRAME_PLAINTEXT - 1, FRAME_PLAINTEXT),
// Starting exactly on a frame boundary.
(FRAME_PLAINTEXT, FRAME_PLAINTEXT + 9),
// Mid-object, mid-frame on both ends.
(2 * FRAME_PLAINTEXT + 5, 3 * FRAME_PLAINTEXT + 100),
// Tail range ending inside the final short frame.
(3 * FRAME_PLAINTEXT + 100, 3 * FRAME_PLAINTEXT + 499),
];
for &(start, end) in cases {
let response = s3_client
.get_object()
.bucket(TEST_BUCKET)
.key(test_key)
.range(format!("bytes={start}-{end}"))
.send()
.await?;
assert_eq!(
response.content_length(),
Some((end - start + 1) as i64),
"range {start}-{end} content length"
);
let data = response.body.collect().await?.into_bytes();
assert_eq!(data.as_ref(), &body[start..=end], "range {start}-{end} must be byte-exact");
}
// A suffix range exercises the offset resolution path as well.
let response = s3_client
.get_object()
.bucket(TEST_BUCKET)
.key(test_key)
.range("bytes=-123")
.send()
.await?;
let data = response.body.collect().await?.into_bytes();
assert_eq!(data.as_ref(), &body[body.len() - 123..], "suffix range must be byte-exact");
// The unranged body still round-trips.
let response = s3_client.get_object().bucket(TEST_BUCKET).key(test_key).send().await?;
let data = response.body.collect().await?.into_bytes();
assert_eq!(data.as_ref(), body.as_slice(), "full body must round-trip");
// A block-aligned object ends in an empty authenticated final frame under
// the v2 layout; tail ranges touching the last plaintext byte must not be
// misread as truncation.
let aligned_key = "encrypted-range-get-aligned";
let aligned_body: Vec<u8> = (0..3 * FRAME_PLAINTEXT).map(|i| ((i + 3) % 251) as u8).collect();
s3_client
.put_object()
.bucket(TEST_BUCKET)
.key(aligned_key)
.server_side_encryption(ServerSideEncryption::Aes256)
.body(ByteStream::from(aligned_body.clone()))
.send()
.await?;
for (start, end) in [
(2 * FRAME_PLAINTEXT + 10, 3 * FRAME_PLAINTEXT - 1),
(3 * FRAME_PLAINTEXT - 1, 3 * FRAME_PLAINTEXT - 1),
] {
let response = s3_client
.get_object()
.bucket(TEST_BUCKET)
.key(aligned_key)
.range(format!("bytes={start}-{end}"))
.send()
.await?;
let data = response.body.collect().await?.into_bytes();
assert_eq!(
data.as_ref(),
&aligned_body[start..=end],
"aligned range {start}-{end} must be byte-exact"
);
}
Ok(())
}