Merge branch 'main-v2' into fix/presigned-post-bucket

This commit is contained in:
joeanderson
2026-01-18 15:38:28 +00:00
201 changed files with 18859 additions and 5911 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "garage_api_s3"
version = "1.2.0"
version = "2.1.0"
authors = ["Alex Auvolat <alex@adnab.me>"]
edition = "2018"
license = "AGPL-3.0"
@@ -27,10 +27,10 @@ async-compression.workspace = true
base64.workspace = true
bytes.workspace = true
chrono.workspace = true
crc32fast.workspace = true
crc32c.workspace = true
err-derive.workspace = true
crc-fast.workspace = true
thiserror.workspace = true
hex.workspace = true
hmac.workspace = true
tracing.workspace = true
md-5.workspace = true
pin-project.workspace = true
+14 -11
View File
@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::sync::Arc;
use hyper::header;
@@ -117,11 +118,11 @@ impl ApiHandler for S3ApiServer {
return handle_post_object(garage, req, bucket_name.unwrap()).await;
}
if let Endpoint::Options = endpoint {
let options_res = handle_options_api(garage, &req, bucket_name).await?;
let options_res = handle_options_api(garage, &req, bucket_name)?;
return Ok(options_res.map(|_empty_body: EmptyBody| empty_body()));
}
let verified_request = verify_request(&garage, req, "s3").await?;
let verified_request = verify_request(&garage, req, "s3")?;
let req = verified_request.request;
let api_key = verified_request.access_key;
@@ -139,15 +140,11 @@ impl ApiHandler for S3ApiServer {
return handle_create_bucket(&garage, req, &api_key.key_id, bucket_name).await;
}
let bucket_id = garage
.bucket_helper()
.resolve_bucket(&bucket_name, &api_key)
.await
.map_err(pass_helper_error)?;
let bucket = garage
.bucket_helper()
.get_existing_bucket(bucket_id)
.await?;
.resolve_bucket_fast(&bucket_name, &api_key)
.map_err(pass_helper_error)?;
let bucket_id = bucket.id;
let bucket_params = bucket.state.into_option().unwrap();
let allowed = match endpoint.authorization_type() {
@@ -343,11 +340,17 @@ impl ApiHandler for S3ApiServer {
Ok(resp_ok)
}
fn key_id_from_request(&self, req: &Request<IncomingBody>) -> Option<String> {
garage_api_common::signature::payload::Authorization::parse_header(req.headers())
.map(|auth| auth.key_id)
.ok()
}
}
impl ApiEndpoint for S3ApiEndpoint {
fn name(&self) -> &'static str {
self.endpoint.name()
fn name(&self) -> Cow<'static, str> {
Cow::Borrowed(self.endpoint.name())
}
fn add_span_attributes(&self, span: SpanRef<'_>) {
+6 -11
View File
@@ -192,21 +192,16 @@ pub async fn handle_create_bucket(
let api_key = helper.key().get_existing_key(api_key_id).await?;
let key_params = api_key.params().unwrap();
let existing_bucket = if let Some(Some(bucket_id)) = key_params.local_aliases.get(&bucket_name)
{
Some(*bucket_id)
} else {
helper
.bucket()
.resolve_global_bucket_name(&bucket_name)
.await?
};
let existing_bucket = helper
.bucket()
.resolve_bucket(&bucket_name, &api_key.key_id)
.await?;
if let Some(bucket_id) = existing_bucket {
if let Some(bucket) = existing_bucket {
// Check we have write or owner permission on the bucket,
// in that case it's fine, return 200 OK, bucket exists;
// otherwise return a forbidden error.
let kp = api_key.bucket_permissions(&bucket_id);
let kp = api_key.bucket_permissions(&bucket.id);
if !(kp.allow_write || kp.allow_owner) {
return Err(CommonError::BucketAlreadyExists.into());
}
+72 -33
View File
@@ -24,7 +24,7 @@ use garage_api_common::helpers::*;
use garage_api_common::signature::checksum::*;
use crate::api_server::{ReqBody, ResBody};
use crate::encryption::EncryptionParams;
use crate::encryption::{EncryptionParams, OekDerivationInfo};
use crate::error::*;
use crate::get::{check_version_not_deleted, full_object_byte_stream, PreconditionHeaders};
use crate::multipart;
@@ -66,11 +66,37 @@ pub async fn handle_copy(
&ctx.garage,
req.headers(),
&source_version_meta.encryption,
OekDerivationInfo::for_object(&source_object, source_version),
)?;
let dest_encryption = EncryptionParams::new_from_headers(&ctx.garage, req.headers())?;
let dest_uuid = gen_uuid();
let dest_encryption = EncryptionParams::new_from_headers(
&ctx.garage,
req.headers(),
OekDerivationInfo {
bucket_id: ctx.bucket_id,
version_id: dest_uuid,
object_key: dest_key,
},
)?;
let was_multipart = source_version_meta.etag.contains('-') // HACK
|| source_object_meta_inner.checksum_type == Some(ChecksumType::Composite);
// Extract source checksum info before source_object_meta_inner is consumed
let source_checksum = source_object_meta_inner.checksum;
let source_checksum_type = match (source_object_meta_inner.checksum_type, source_checksum) {
(Some(ct), _) => Some(ct),
(None, Some(_)) => {
// Migrated object from garage v1.x or older
// determine checksum type depending if this is a multipart upload or not
if was_multipart {
Some(ChecksumType::Composite)
} else {
Some(ChecksumType::FullObject)
}
}
(None, None) => None,
};
let source_checksum_algorithm = source_checksum.map(|x| x.algorithm());
// If source object has a checksum, the destination object must as well.
@@ -79,7 +105,6 @@ pub async fn handle_copy(
let checksum_algorithm = checksum_algorithm.or(source_checksum_algorithm);
// Determine metadata of destination object
let was_multipart = source_version_meta.etag.contains('-');
let dest_object_meta = ObjectVersionMetaInner {
headers: match req.headers().get("x-amz-metadata-directive") {
Some(v) if v == hyper::header::HeaderValue::from_static("REPLACE") => {
@@ -99,6 +124,7 @@ pub async fn handle_copy(
}
},
checksum: source_checksum,
checksum_type: source_checksum_type,
};
// Do actual object copying
@@ -118,8 +144,8 @@ pub async fn handle_copy(
// See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
let must_recopy = !EncryptionParams::is_same(&source_encryption, &dest_encryption)
|| source_checksum_algorithm != checksum_algorithm
|| (was_multipart && checksum_algorithm.is_some());
|| (checksum_algorithm.is_some()
&& (was_multipart || checksum_algorithm != source_checksum_algorithm));
let res = if !must_recopy {
// In most cases, we can just copy the metadata and link blocks of the
@@ -127,6 +153,7 @@ pub async fn handle_copy(
handle_copy_metaonly(
ctx,
dest_key,
dest_uuid,
dest_object_meta,
dest_encryption,
source_version,
@@ -135,21 +162,25 @@ pub async fn handle_copy(
)
.await?
} else {
let expected_checksum = ExpectedChecksums {
md5: None,
sha256: None,
extra: source_checksum,
};
let checksum_mode = if was_multipart || source_checksum_algorithm != checksum_algorithm {
ChecksumMode::Calculate(checksum_algorithm)
} else {
ChecksumMode::Verify(&expected_checksum)
ChecksumMode::Verify(ExpectedChecksums {
md5: None,
sha256: None,
extra: source_checksum,
})
};
// For multipart uploads that had a composite checksum, set checksum type
// to full object as it will be recalculated.
let dest_object_meta = ObjectVersionMetaInner {
checksum_type: checksum_algorithm.map(|_| ChecksumType::FullObject),
..dest_object_meta
};
// If source and dest encryption use different keys,
// we must decrypt content and re-encrypt, so rewrite all data blocks.
handle_copy_reencrypt(
ctx,
dest_key,
dest_uuid,
dest_object_meta,
dest_encryption,
source_version,
@@ -181,6 +212,7 @@ pub async fn handle_copy(
async fn handle_copy_metaonly(
ctx: ReqCtx,
dest_key: &str,
dest_uuid: Uuid,
dest_object_meta: ObjectVersionMetaInner,
dest_encryption: EncryptionParams,
source_version: &ObjectVersion,
@@ -194,7 +226,6 @@ async fn handle_copy_metaonly(
} = ctx;
// Generate parameters for copied object
let new_uuid = gen_uuid();
let new_timestamp = now_msec();
let new_meta = ObjectVersionMeta {
@@ -204,7 +235,7 @@ async fn handle_copy_metaonly(
};
let res = SaveStreamResult {
version_uuid: new_uuid,
version_uuid: dest_uuid,
version_timestamp: new_timestamp,
etag: new_meta.etag.clone(),
};
@@ -216,7 +247,7 @@ async fn handle_copy_metaonly(
// bytes is either plaintext before&after or encrypted with the
// same keys, so it's ok to just copy it as is
let dest_object_version = ObjectVersion {
uuid: new_uuid,
uuid: dest_uuid,
timestamp: new_timestamp,
state: ObjectVersionState::Complete(ObjectVersionData::Inline(
new_meta,
@@ -243,7 +274,7 @@ async fn handle_copy_metaonly(
// This holds a reference to the object in the Version table
// so that it won't be deleted, e.g. by repair_versions.
let tmp_dest_object_version = ObjectVersion {
uuid: new_uuid,
uuid: dest_uuid,
timestamp: new_timestamp,
state: ObjectVersionState::Uploading {
encryption: new_meta.encryption.clone(),
@@ -263,7 +294,7 @@ async fn handle_copy_metaonly(
// marked as deleted (they are marked as deleted only if the Version
// doesn't exist or is marked as deleted).
let mut dest_version = Version::new(
new_uuid,
dest_uuid,
VersionBacklink::Object {
bucket_id: dest_bucket_id,
key: dest_key.to_string(),
@@ -282,7 +313,7 @@ async fn handle_copy_metaonly(
.iter()
.map(|b| BlockRef {
block: b.1.hash,
version: new_uuid,
version: dest_uuid,
deleted: false.into(),
})
.collect::<Vec<_>>();
@@ -298,7 +329,7 @@ async fn handle_copy_metaonly(
// with the stuff before, the block's reference counts could be decremented before
// they are incremented again for the new version, leading to data being deleted.
let dest_object_version = ObjectVersion {
uuid: new_uuid,
uuid: dest_uuid,
timestamp: new_timestamp,
state: ObjectVersionState::Complete(ObjectVersionData::FirstBlock(
new_meta,
@@ -320,12 +351,13 @@ async fn handle_copy_metaonly(
async fn handle_copy_reencrypt(
ctx: ReqCtx,
dest_key: &str,
dest_uuid: Uuid,
dest_object_meta: ObjectVersionMetaInner,
dest_encryption: EncryptionParams,
source_version: &ObjectVersion,
source_version_data: &ObjectVersionData,
source_encryption: EncryptionParams,
checksum_mode: ChecksumMode<'_>,
checksum_mode: ChecksumMode,
) -> Result<SaveStreamResult, Error> {
// basically we will read the source data (decrypt if necessary)
// and save that in a new object (encrypt if necessary),
@@ -339,6 +371,7 @@ async fn handle_copy_reencrypt(
save_stream(
&ctx,
dest_uuid,
dest_object_meta,
dest_encryption,
source_stream.map_err(|e| Error::from(GarageError::from(e))),
@@ -362,7 +395,7 @@ pub async fn handle_upload_part_copy(
let dest_upload_id = multipart::decode_upload_id(upload_id)?;
let dest_key = dest_key.to_string();
let (source_object, (_, dest_version, mut dest_mpu)) = futures::try_join!(
let (source_object, (dest_object, dest_version, mut dest_mpu)) = futures::try_join!(
get_copy_source(&ctx, req),
multipart::get_upload(&ctx, &dest_key, &dest_upload_id)
)?;
@@ -380,7 +413,10 @@ pub async fn handle_upload_part_copy(
&garage,
req.headers(),
&source_version_meta.encryption,
OekDerivationInfo::for_object(&source_object, source_object_version),
)?;
let dest_oek_params = OekDerivationInfo::for_object(&dest_object, &dest_version);
let (dest_object_encryption, dest_object_checksum_algorithm) = match dest_version.state {
ObjectVersionState::Uploading {
encryption,
@@ -389,8 +425,12 @@ pub async fn handle_upload_part_copy(
} => (encryption, checksum_algorithm),
_ => unreachable!(),
};
let (dest_encryption, _) =
EncryptionParams::check_decrypt(&garage, req.headers(), &dest_object_encryption)?;
let (dest_encryption, _) = EncryptionParams::check_decrypt(
&garage,
req.headers(),
&dest_object_encryption,
dest_oek_params,
)?;
let same_encryption = EncryptionParams::is_same(&source_encryption, &dest_encryption);
// Check source range is valid
@@ -505,7 +545,7 @@ pub async fn handle_upload_part_copy(
// Now, actually copy the blocks
let mut checksummer = Checksummer::init(&Default::default(), !dest_encryption.is_encrypted())
.add(dest_object_checksum_algorithm);
.add(dest_object_checksum_algorithm.map(|(algo, _)| algo));
// First, create a stream that is able to read the source blocks
// and extract the subrange if necessary.
@@ -655,7 +695,7 @@ pub async fn handle_upload_part_copy(
let checksums = checksummer.finalize();
let etag = dest_encryption.etag_from_md5(&checksums.md5);
let checksum = checksums.extract(dest_object_checksum_algorithm);
let checksum = checksums.extract(dest_object_checksum_algorithm.map(|(algo, _)| algo));
// Put the part's ETag in the Versiontable
dest_mpu.parts.put(
@@ -695,16 +735,15 @@ async fn get_copy_source(ctx: &ReqCtx, req: &Request<ReqBody>) -> Result<Object,
let copy_source = percent_encoding::percent_decode_str(copy_source).decode_utf8()?;
let (source_bucket, source_key) = parse_bucket_key(&copy_source, None)?;
let source_bucket_id = garage
let source_bucket = garage
.bucket_helper()
.resolve_bucket(&source_bucket.to_string(), api_key)
.await
.resolve_bucket_fast(&source_bucket.to_string(), api_key)
.map_err(pass_helper_error)?;
if !api_key.allow_read(&source_bucket_id) {
if !api_key.allow_read(&source_bucket.id) {
return Err(Error::forbidden(format!(
"Reading from bucket {} not allowed for this key",
source_bucket
"Reading from bucket {:?} not allowed for this key",
source_bucket.id
)));
}
@@ -712,7 +751,7 @@ async fn get_copy_source(ctx: &ReqCtx, req: &Request<ReqBody>) -> Result<Object,
let source_object = garage
.object_table
.get(&source_bucket_id, &source_key.to_string())
.get(&source_bucket.id, &source_key.to_string())
.await?
.ok_or(Error::NoSuchKey)?;
+1 -1
View File
@@ -29,7 +29,7 @@ pub async fn handle_get_cors(ctx: ReqCtx) -> Result<Response<ResBody>, Error> {
.body(string_body(xml))?)
} else {
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.status(StatusCode::NOT_FOUND)
.body(empty_body())?)
}
}
+105 -25
View File
@@ -11,6 +11,7 @@ use aes_gcm::{
};
use base64::prelude::*;
use bytes::Bytes;
use sha2::Sha256;
use futures::stream::Stream;
use futures::task;
@@ -21,12 +22,12 @@ use http::header::{HeaderMap, HeaderName, HeaderValue};
use garage_net::bytes_buf::BytesBuf;
use garage_net::stream::{stream_asyncread, ByteStream};
use garage_rpc::rpc_helper::OrderTag;
use garage_util::data::Hash;
use garage_util::data::{Hash, Uuid};
use garage_util::error::Error as GarageError;
use garage_util::migrate::Migrate;
use garage_model::garage::Garage;
use garage_model::s3::object_table::{ObjectVersionEncryption, ObjectVersionMetaInner};
use garage_model::s3::object_table::*;
use garage_api_common::common_error::*;
use garage_api_common::signature::checksum::Md5Checksum;
@@ -64,32 +65,45 @@ const STREAM_ENC_CYPER_CHUNK_SIZE: usize = STREAM_ENC_PLAIN_CHUNK_SIZE + 16;
pub enum EncryptionParams {
Plaintext,
SseC {
/// the value of x-amz-server-side-encryption-customer-key
client_key: Key<Aes256Gcm>,
/// the value of x-amz-server-side-encryption-customer-key-md5
client_key_md5: Md5Output,
/// the object encryption key, for uploads created in garage v2+
object_key: Option<Key<Aes256Gcm>>,
/// the compression level used for compressing data blocks
compression_level: Option<i32>,
},
}
#[derive(Clone, Copy)]
pub struct OekDerivationInfo<'a> {
pub bucket_id: Uuid,
pub version_id: Uuid,
pub object_key: &'a str,
}
impl EncryptionParams {
pub fn is_encrypted(&self) -> bool {
!matches!(self, Self::Plaintext)
}
pub fn is_same(a: &Self, b: &Self) -> bool {
let relevant_info = |x: &Self| match x {
Self::Plaintext => None,
Self::SseC {
client_key,
compression_level,
..
} => Some((*client_key, compression_level.is_some())),
};
relevant_info(a) == relevant_info(b)
// This function is used in CopyObject and UploadPartCopy to determine
// whether the object must be re-encrypted. If this returns true,
// data blocks are reused as-is. Since Garage v2, we are using
// object-specific encryption keys, so we know that if both source
// and destination are encrypted, it can't be with the same key.
match (a, b) {
(Self::Plaintext, Self::Plaintext) => true,
_ => false,
}
}
pub fn new_from_headers(
garage: &Garage,
headers: &HeaderMap,
oek_info: OekDerivationInfo<'_>,
) -> Result<EncryptionParams, Error> {
let key = parse_request_headers(
headers,
@@ -101,6 +115,7 @@ impl EncryptionParams {
Some((client_key, client_key_md5)) => Ok(EncryptionParams::SseC {
client_key,
client_key_md5,
object_key: Some(oek_info.derive_oek(&client_key)),
compression_level: garage.config.compression_level,
}),
None => Ok(EncryptionParams::Plaintext),
@@ -126,6 +141,7 @@ impl EncryptionParams {
garage: &Garage,
headers: &HeaderMap,
obj_enc: &'a ObjectVersionEncryption,
oek_info: OekDerivationInfo<'_>,
) -> Result<(Self, Cow<'a, ObjectVersionMetaInner>), Error> {
let key = parse_request_headers(
headers,
@@ -133,13 +149,14 @@ impl EncryptionParams {
&X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY,
&X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5,
)?;
Self::check_decrypt_common(garage, key, obj_enc)
Self::check_decrypt_common(garage, key, obj_enc, oek_info)
}
pub fn check_decrypt_for_copy_source<'a>(
garage: &Garage,
headers: &HeaderMap,
obj_enc: &'a ObjectVersionEncryption,
oek_info: OekDerivationInfo<'_>,
) -> Result<(Self, Cow<'a, ObjectVersionMetaInner>), Error> {
let key = parse_request_headers(
headers,
@@ -147,22 +164,32 @@ impl EncryptionParams {
&X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY,
&X_AMZ_COPY_SOURCE_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5,
)?;
Self::check_decrypt_common(garage, key, obj_enc)
Self::check_decrypt_common(garage, key, obj_enc, oek_info)
}
fn check_decrypt_common<'a>(
garage: &Garage,
key: Option<(Key<Aes256Gcm>, Md5Output)>,
obj_enc: &'a ObjectVersionEncryption,
oek_info: OekDerivationInfo<'_>,
) -> Result<(Self, Cow<'a, ObjectVersionMetaInner>), Error> {
match (key, &obj_enc) {
(
Some((client_key, client_key_md5)),
ObjectVersionEncryption::SseC { inner, compressed },
ObjectVersionEncryption::SseC {
inner,
compressed,
use_oek,
},
) => {
let enc = Self::SseC {
client_key,
client_key_md5,
object_key: if *use_oek {
Some(oek_info.derive_oek(&client_key))
} else {
None
},
compression_level: if *compressed {
Some(garage.config.compression_level.unwrap_or(1))
} else {
@@ -193,13 +220,16 @@ impl EncryptionParams {
) -> Result<ObjectVersionEncryption, Error> {
match self {
Self::SseC {
compression_level, ..
compression_level,
object_key,
..
} => {
let plaintext = meta.encode().map_err(GarageError::from)?;
let ciphertext = self.encrypt_blob(&plaintext)?;
Ok(ObjectVersionEncryption::SseC {
inner: ciphertext.into_owned(),
compressed: compression_level.is_some(),
use_oek: object_key.is_some(),
})
}
Self::Plaintext => Ok(ObjectVersionEncryption::Plaintext { inner: meta }),
@@ -228,24 +258,37 @@ impl EncryptionParams {
// This is used for encrypting object metadata and inlined data for small objects.
// This does not compress anything.
pub fn encrypt_blob<'a>(&self, blob: &'a [u8]) -> Result<Cow<'a, [u8]>, Error> {
fn cipher(&self) -> Option<Aes256Gcm> {
match self {
Self::SseC { client_key, .. } => {
let cipher = Aes256Gcm::new(&client_key);
Self::SseC {
object_key: Some(oek),
..
} => Some(Aes256Gcm::new(&oek)),
Self::SseC {
client_key,
object_key: None,
..
} => Some(Aes256Gcm::new(&client_key)),
Self::Plaintext => None,
}
}
pub fn encrypt_blob<'a>(&self, blob: &'a [u8]) -> Result<Cow<'a, [u8]>, Error> {
match self.cipher() {
Some(cipher) => {
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, blob)
.ok_or_internal_error("Encryption failed")?;
Ok(Cow::Owned([nonce.to_vec(), ciphertext].concat()))
}
Self::Plaintext => Ok(Cow::Borrowed(blob)),
None => Ok(Cow::Borrowed(blob)),
}
}
pub fn decrypt_blob<'a>(&self, blob: &'a [u8]) -> Result<Cow<'a, [u8]>, Error> {
match self {
Self::SseC { client_key, .. } => {
let cipher = Aes256Gcm::new(&client_key);
match self.cipher() {
Some(cipher) => {
let nonce_size = <Aes256Gcm as AeadCore>::NonceSize::to_usize();
let nonce = Nonce::from_slice(
blob.get(..nonce_size)
@@ -258,7 +301,7 @@ impl EncryptionParams {
)?;
Ok(Cow::Owned(plaintext))
}
Self::Plaintext => Ok(Cow::Borrowed(blob)),
None => Ok(Cow::Borrowed(blob)),
}
}
@@ -284,10 +327,12 @@ impl EncryptionParams {
Self::Plaintext => stream,
Self::SseC {
client_key,
object_key,
compression_level,
..
} => {
let plaintext = DecryptStream::new(stream, *client_key);
let key = object_key.as_ref().unwrap_or(client_key);
let plaintext = DecryptStream::new(stream, *key);
if compression_level.is_some() {
let reader = stream_asyncread(Box::pin(plaintext));
let reader = BufReader::new(reader);
@@ -307,9 +352,12 @@ impl EncryptionParams {
Self::Plaintext => Ok(block),
Self::SseC {
client_key,
object_key,
compression_level,
..
} => {
let key = object_key.as_ref().unwrap_or(client_key);
let block = if let Some(level) = compression_level {
Cow::Owned(
garage_block::zstd_encode(block.as_ref(), *level)
@@ -325,7 +373,7 @@ impl EncryptionParams {
OsRng.fill_bytes(&mut nonce);
ret.extend_from_slice(nonce.as_slice());
let mut cipher = EncryptorLE31::<Aes256Gcm>::new(&client_key, &nonce);
let mut cipher = EncryptorLE31::<Aes256Gcm>::new(key, &nonce);
let mut iter = block.chunks(STREAM_ENC_PLAIN_CHUNK_SIZE).peekable();
if iter.peek().is_none() {
@@ -361,6 +409,13 @@ impl EncryptionParams {
}
}
pub fn has_encryption_header(headers: &HeaderMap) -> bool {
match headers.get(X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM) {
Some(h) => h.as_bytes() == CUSTOMER_ALGORITHM_AES256,
None => false,
}
}
fn parse_request_headers(
headers: &HeaderMap,
alg_header: &HeaderName,
@@ -420,6 +475,30 @@ fn parse_request_headers(
}
}
impl<'a> OekDerivationInfo<'a> {
pub fn for_object<'b>(object: &'a Object, version: &'b ObjectVersion) -> Self {
Self {
bucket_id: object.bucket_id,
version_id: version.uuid,
object_key: &object.key,
}
}
fn derive_oek(&self, client_key: &Key<Aes256Gcm>) -> Key<Aes256Gcm> {
use hmac::{Hmac, Mac};
// info = bucket_id + object_name + version_uuid + "garage-object-encryption-key"
// oek = hmac_sha256(ssec_key, info)
let mut hmac = <Hmac<Sha256> as Mac>::new_from_slice(client_key.as_slice())
.expect("create hmac-sha256");
hmac.update(b"garage-object-encryption-key");
hmac.update(self.bucket_id.as_slice());
hmac.update(self.version_id.as_slice());
hmac.update(self.object_key.as_bytes());
hmac.finalize().into_bytes()
}
}
// ---- encrypt & decrypt streams ----
#[pin_project::pin_project]
@@ -569,6 +648,7 @@ mod tests {
let enc = EncryptionParams::SseC {
client_key: Aes256Gcm::generate_key(&mut OsRng),
client_key_md5: Default::default(), // not needed
object_key: Some(Aes256Gcm::generate_key(&mut OsRng)),
compression_level,
};
+27 -20
View File
@@ -1,8 +1,8 @@
use std::convert::TryInto;
use err_derive::Error;
use hyper::header::HeaderValue;
use hyper::{HeaderMap, StatusCode};
use thiserror::Error;
use garage_model::helper::error::Error as HelperError;
@@ -25,67 +25,67 @@ use crate::xml as s3_xml;
/// Errors of this crate
#[derive(Debug, Error)]
pub enum Error {
#[error(display = "{}", _0)]
#[error("{0}")]
/// Error from common error
Common(#[error(source)] CommonError),
Common(#[from] CommonError),
// Category: cannot process
/// Authorization Header Malformed
#[error(display = "Authorization header malformed, unexpected scope: {}", _0)]
#[error("Authorization header malformed, unexpected scope: {0}")]
AuthorizationHeaderMalformed(String),
/// The object requested don't exists
#[error(display = "Key not found")]
#[error("Key not found")]
NoSuchKey,
/// The multipart upload requested don't exists
#[error(display = "Upload not found")]
#[error("Upload not found")]
NoSuchUpload,
/// Precondition failed (e.g. x-amz-copy-source-if-match)
#[error(display = "At least one of the preconditions you specified did not hold")]
#[error("At least one of the preconditions you specified did not hold")]
PreconditionFailed,
/// Parts specified in CMU request do not match parts actually uploaded
#[error(display = "Parts given to CompleteMultipartUpload do not match uploaded parts")]
#[error("Parts given to CompleteMultipartUpload do not match uploaded parts")]
InvalidPart,
/// Parts given to CompleteMultipartUpload were not in ascending order
#[error(display = "Parts given to CompleteMultipartUpload were not in ascending order")]
#[error("Parts given to CompleteMultipartUpload were not in ascending order")]
InvalidPartOrder,
/// In CompleteMultipartUpload: not enough data
/// (here we are more lenient than AWS S3)
#[error(display = "Proposed upload is smaller than the minimum allowed object size")]
#[error("Proposed upload is smaller than the minimum allowed object size")]
EntityTooSmall,
// Category: bad request
/// The request contained an invalid UTF-8 sequence in its path or in other parameters
#[error(display = "Invalid UTF-8: {}", _0)]
InvalidUtf8Str(#[error(source)] std::str::Utf8Error),
#[error("Invalid UTF-8: {0}")]
InvalidUtf8Str(#[from] std::str::Utf8Error),
/// The request used an invalid path
#[error(display = "Invalid UTF-8: {}", _0)]
InvalidUtf8String(#[error(source)] std::string::FromUtf8Error),
#[error("Invalid UTF-8: {0}")]
InvalidUtf8String(#[from] std::string::FromUtf8Error),
/// The client sent invalid XML data
#[error(display = "Invalid XML: {}", _0)]
#[error("Invalid XML: {0}")]
InvalidXml(String),
/// The client sent a range header with invalid value
#[error(display = "Invalid HTTP range: {:?}", _0)]
InvalidRange(#[error(from)] (http_range::HttpRangeParseError, u64)),
#[error("Invalid HTTP range: {0:?}")]
InvalidRange((http_range::HttpRangeParseError, u64)),
/// The client sent a range header with invalid value
#[error(display = "Invalid encryption algorithm: {:?}, should be AES256", _0)]
#[error("Invalid encryption algorithm: {0:?}, should be AES256")]
InvalidEncryptionAlgorithm(String),
/// The provided digest (checksum) value was invalid
#[error(display = "Invalid digest: {}", _0)]
#[error("Invalid digest: {0}")]
InvalidDigest(String),
/// The client sent a request for an action not supported by garage
#[error(display = "Unimplemented action: {}", _0)]
#[error("Unimplemented action: {0}")]
NotImplemented(String),
}
@@ -99,6 +99,12 @@ impl From<HelperError> for Error {
}
}
impl From<(http_range::HttpRangeParseError, u64)> for Error {
fn from(err: (http_range::HttpRangeParseError, u64)) -> Error {
Error::InvalidRange(err)
}
}
impl From<roxmltree::Error> for Error {
fn from(err: roxmltree::Error) -> Self {
Self::InvalidXml(format!("{}", err))
@@ -176,6 +182,7 @@ impl ApiError for Error {
use hyper::header;
header_map.append(header::CONTENT_TYPE, "application/xml".parse().unwrap());
header_map.append(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap());
#[allow(clippy::single_match)]
match self {
+13 -5
View File
@@ -31,7 +31,7 @@ use garage_api_common::signature::checksum::{add_checksum_response_headers, X_AM
use crate::api_server::ResBody;
use crate::copy::*;
use crate::encryption::EncryptionParams;
use crate::encryption::{EncryptionParams, OekDerivationInfo};
use crate::error::*;
const X_AMZ_MP_PARTS_COUNT: HeaderName = HeaderName::from_static("x-amz-mp-parts-count");
@@ -182,8 +182,12 @@ pub async fn handle_head_without_ctx(
return Ok(res);
}
let (encryption, headers) =
EncryptionParams::check_decrypt(&garage, req.headers(), &version_meta.encryption)?;
let (encryption, headers) = EncryptionParams::check_decrypt(
&garage,
req.headers(),
&version_meta.encryption,
OekDerivationInfo::for_object(&object, object_version),
)?;
let checksum_mode = checksum_mode(&req);
@@ -305,8 +309,12 @@ pub async fn handle_get_without_ctx(
return Ok(res);
}
let (enc, headers) =
EncryptionParams::check_decrypt(&garage, req.headers(), &last_v_meta.encryption)?;
let (enc, headers) = EncryptionParams::check_decrypt(
&garage,
req.headers(),
&last_v_meta.encryption,
OekDerivationInfo::for_object(&object, last_v),
)?;
let checksum_mode = checksum_mode(&req);
+18 -3
View File
@@ -17,7 +17,7 @@ use garage_api_common::encoding::*;
use garage_api_common::helpers::*;
use crate::api_server::{ReqBody, ResBody};
use crate::encryption::EncryptionParams;
use crate::encryption::{EncryptionParams, OekDerivationInfo};
use crate::error::*;
use crate::multipart as s3_multipart;
use crate::xml as s3_xml;
@@ -285,8 +285,16 @@ pub async fn handle_list_parts(
ObjectVersionState::Uploading { encryption, .. } => encryption,
_ => unreachable!(),
};
let encryption_res =
EncryptionParams::check_decrypt(&ctx.garage, req.headers(), &object_encryption);
let encryption_res = EncryptionParams::check_decrypt(
&ctx.garage,
req.headers(),
&object_encryption,
OekDerivationInfo {
bucket_id: ctx.bucket_id,
version_id: upload_id,
object_key: &query.key,
},
);
let (info, next) = fetch_part_info(query, &mpu)?;
@@ -326,6 +334,12 @@ pub async fn handle_list_parts(
}
_ => None,
},
checksum_crc64nvme: match &checksum {
Some(ChecksumValue::Crc64Nvme(x)) => {
Some(s3_xml::Value(BASE64_STANDARD.encode(&x)))
}
_ => None,
},
checksum_sha1: match &checksum {
Some(ChecksumValue::Sha1(x)) => {
Some(s3_xml::Value(BASE64_STANDARD.encode(&x)))
@@ -988,6 +1002,7 @@ mod tests {
inner: ObjectVersionMetaInner {
headers: vec![],
checksum: None,
checksum_type: None,
},
},
checksum_algorithm: None,
+266 -118
View File
@@ -1,13 +1,12 @@
use std::collections::HashMap;
use std::convert::{TryFrom, TryInto};
use std::hash::Hasher;
use std::convert::TryInto;
use std::sync::Arc;
use base64::prelude::*;
use crc32c::Crc32cHasher as Crc32c;
use crc32fast::Hasher as Crc32;
use crc_fast::{CrcAlgorithm, Digest as CrcDigest};
use futures::prelude::*;
use hyper::{Request, Response};
use http::StatusCode;
use hyper::{header::HeaderValue, HeaderMap, Request, Response};
use md5::{Digest, Md5};
use sha1::Sha1;
use sha2::Sha256;
@@ -26,7 +25,7 @@ use garage_api_common::helpers::*;
use garage_api_common::signature::checksum::*;
use crate::api_server::{ReqBody, ResBody};
use crate::encryption::EncryptionParams;
use crate::encryption::{has_encryption_header, EncryptionParams, OekDerivationInfo};
use crate::error::*;
use crate::put::*;
use crate::xml as s3_xml;
@@ -53,13 +52,25 @@ pub async fn handle_create_multipart_upload(
let meta = ObjectVersionMetaInner {
headers,
checksum: None,
checksum_type: None,
};
// Determine whether object should be encrypted, and if so the key
let encryption = EncryptionParams::new_from_headers(&garage, req.headers())?;
let encryption = EncryptionParams::new_from_headers(
&garage,
req.headers(),
OekDerivationInfo {
bucket_id: *bucket_id,
version_id: upload_id,
object_key: &key,
},
)?;
let object_encryption = encryption.encrypt_meta(meta)?;
let checksum_algorithm = request_checksum_algorithm(req.headers())?;
let checksum_algorithm = request_checksum_algorithm_and_type(
req.headers(),
request_checksum_algorithm(req.headers())?,
)?;
// Create object in object table
let object_version = ObjectVersion {
@@ -120,8 +131,7 @@ pub async fn handle_put_part(
// Before we stream the body, configure the needed checksums.
req_body.add_expected_checksums(expected_checksums.clone());
// TODO: avoid parsing encryption headers twice...
if !EncryptionParams::new_from_headers(&garage, &req_head.headers)?.is_encrypted() {
if !has_encryption_header(&req_head.headers) {
// For non-encrypted objects, we need to compute the md5sum in all cases
// (even if content-md5 is not set), because it is used as an etag of the
// part, which is in turn used in the etag computation of the whole object
@@ -134,10 +144,11 @@ pub async fn handle_put_part(
let mut chunker = StreamChunker::new(stream, garage.config.block_size);
// Read first chuck, and at the same time try to get object to see if it exists
let ((_, object_version, mut mpu), first_block) =
let ((object, object_version, mut mpu), first_block) =
futures::try_join!(get_upload(&ctx, &key, &upload_id), chunker.next(),)?;
// Check encryption params
let oek_params = OekDerivationInfo::for_object(&object, &object_version);
let (object_encryption, checksum_algorithm) = match object_version.state {
ObjectVersionState::Uploading {
encryption,
@@ -146,8 +157,12 @@ pub async fn handle_put_part(
} => (encryption, checksum_algorithm),
_ => unreachable!(),
};
let (encryption, _) =
EncryptionParams::check_decrypt(&garage, &req_head.headers, &object_encryption)?;
let (encryption, _) = EncryptionParams::check_decrypt(
&garage,
&req_head.headers,
&object_encryption,
oek_params,
)?;
// Check object is valid and part can be accepted
let first_block = first_block.ok_or_bad_request("Empty body")?;
@@ -214,7 +229,7 @@ pub async fn handle_put_part(
MpuPart {
version: version_uuid,
etag: Some(etag.clone()),
checksum: checksums.extract(checksum_algorithm),
checksum: checksums.extract(checksum_algorithm.map(|(algo, _)| algo)),
size: Some(total_size),
},
);
@@ -276,12 +291,23 @@ pub async fn handle_complete_multipart_upload(
let (req_head, req_body) = req.into_parts();
let expected_checksum = request_checksum_value(&req_head.headers)?;
let req_checksum_algorithm = request_checksum_algorithm_and_type(
&req_head.headers,
expected_checksum.map(|x| x.algorithm()),
)?;
debug!(
"CompleteMultipartUpload expected checksum: {:?}, request checksum type: {:?}",
expected_checksum, req_checksum_algorithm
);
let body = req_body.collect().await?;
let body_xml = roxmltree::Document::parse(std::str::from_utf8(&body)?)?;
let body_list_of_parts = parse_complete_multipart_upload_body(&body_xml)
.ok_or_bad_request("Invalid CompleteMultipartUpload XML")?;
let body_list_of_parts =
parse_complete_multipart_upload_body(&body_xml).ok_or_bad_request(format!(
"Invalid CompleteMultipartUpload XML:\n{}",
String::from_utf8_lossy(&body)
))?;
debug!(
"CompleteMultipartUpload list of parts: {:?}",
body_list_of_parts
@@ -297,6 +323,7 @@ pub async fn handle_complete_multipart_upload(
return Err(Error::bad_request("No data was uploaded"));
}
let oek_params = OekDerivationInfo::for_object(&object, &object_version);
let (object_encryption, checksum_algorithm) = match object_version.state {
ObjectVersionState::Uploading {
encryption,
@@ -305,6 +332,17 @@ pub async fn handle_complete_multipart_upload(
} => (encryption, checksum_algorithm),
_ => unreachable!(),
};
debug!(
"CompleteMultipartUpload object checksum_algorithm: {:?}",
checksum_algorithm
);
if req_checksum_algorithm.is_some() && req_checksum_algorithm != checksum_algorithm {
return Err(Error::InvalidDigest(format!(
"checksum algorithm {:?} does not correspond to algorithm specified in CreateMultipartUpload {:?}",
req_checksum_algorithm,
checksum_algorithm
)));
}
// Check that part numbers are an increasing sequence.
// (it doesn't need to start at 1 nor to be a continuous sequence,
@@ -330,8 +368,7 @@ pub async fn handle_complete_multipart_upload(
for req_part in body_list_of_parts.iter() {
match have_parts.get(&req_part.part_number) {
Some(part) if part.etag.as_ref() == Some(&req_part.etag) && part.size.is_some() => {
// alternative version: if req_part.checksum.is_some() && part.checksum != req_part.checksum {
if part.checksum != req_part.checksum {
if req_part.checksum.is_some() && part.checksum != req_part.checksum {
return Err(Error::InvalidDigest(format!(
"Invalid checksum for part {}: in request = {:?}, uploaded part = {:?}",
req_part.part_number, req_part.checksum, part.checksum
@@ -390,7 +427,11 @@ pub async fn handle_complete_multipart_upload(
// https://teppen.io/2018/06/23/aws_s3_etags/
let mut checksummer = MultipartChecksummer::init(checksum_algorithm);
for part in parts.iter() {
checksummer.update(part.etag.as_ref().unwrap(), part.checksum)?;
checksummer.update(
part.etag.as_ref().unwrap(),
part.checksum,
part.size.unwrap(),
)?;
}
let (checksum_md5, checksum_extra) = checksummer.finalize();
@@ -417,11 +458,16 @@ pub async fn handle_complete_multipart_upload(
let object_encryption = match checksum_algorithm {
None => object_encryption,
Some(_) => {
let (encryption, meta) =
EncryptionParams::check_decrypt(&garage, &req_head.headers, &object_encryption)?;
let (encryption, meta) = EncryptionParams::check_decrypt(
&garage,
&req_head.headers,
&object_encryption,
oek_params,
)?;
let new_meta = ObjectVersionMetaInner {
headers: meta.into_owned().headers,
checksum: checksum_extra,
checksum_type: checksum_algorithm.map(|(_, ty)| ty),
};
encryption.encrypt_meta(new_meta)?
}
@@ -464,6 +510,10 @@ pub async fn handle_complete_multipart_upload(
Some(ChecksumValue::Crc32c(x)) => Some(s3_xml::Value(BASE64_STANDARD.encode(&x))),
_ => None,
},
checksum_crc64nvme: match &checksum_extra {
Some(ChecksumValue::Crc64Nvme(x)) => Some(s3_xml::Value(BASE64_STANDARD.encode(&x))),
_ => None,
},
checksum_sha1: match &checksum_extra {
Some(ChecksumValue::Sha1(x)) => Some(s3_xml::Value(BASE64_STANDARD.encode(&x))),
_ => None,
@@ -472,6 +522,11 @@ pub async fn handle_complete_multipart_upload(
Some(ChecksumValue::Sha256(x)) => Some(s3_xml::Value(BASE64_STANDARD.encode(&x))),
_ => None,
},
checksum_type: match checksum_algorithm {
Some((_, ChecksumType::Composite)) => Some(s3_xml::Value(COMPOSITE.into())),
Some((_, ChecksumType::FullObject)) => Some(s3_xml::Value(FULL_OBJECT.into())),
None => None,
},
};
let xml = s3_xml::to_xml_with_header(&result)?;
@@ -497,7 +552,9 @@ pub async fn handle_abort_multipart_upload(
let final_object = Object::new(*bucket_id, key.to_string(), vec![object_version]);
garage.object_table.insert(&final_object).await?;
Ok(Response::new(empty_body()))
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(empty_body())?)
}
// ======== helpers ============
@@ -549,6 +606,32 @@ struct CompleteMultipartUploadPart {
checksum: Option<ChecksumValue>,
}
macro_rules! extract_checksum_from {
($node:ident { $($name:expr => $variant:ident),* $(,)? }) => {
if false { None }
$(
else if let Some(node) = $node.children().find(|e| e.has_tag_name($name)) {
match node.last_child().map(|x| x.text()) {
// Child is text but empty post-trim, ignore it.
Some(Some(text)) if text.trim().is_empty() => None,
// Child is non-empty text, parse it.
Some(Some(text)) => Some(ChecksumValue::$variant(
BASE64_STANDARD.decode(text).ok()?[..].try_into().ok()?
)),
// Child is not text, reject it.
Some(None) => return None,
// No child, ignore it.
None => None,
}
}
)*
else { None }
}
}
fn parse_complete_multipart_upload_body(
xml: &roxmltree::Document,
) -> Option<Vec<CompleteMultipartUploadPart>> {
@@ -572,37 +655,15 @@ fn parse_complete_multipart_upload_body(
.children()
.find(|e| e.has_tag_name("PartNumber"))?
.text()?;
let checksum = if let Some(crc32) =
item.children().find(|e| e.has_tag_name("ChecksumCRC32"))
{
Some(ChecksumValue::Crc32(
BASE64_STANDARD.decode(crc32.text()?).ok()?[..]
.try_into()
.ok()?,
))
} else if let Some(crc32c) = item.children().find(|e| e.has_tag_name("ChecksumCRC32C"))
{
Some(ChecksumValue::Crc32c(
BASE64_STANDARD.decode(crc32c.text()?).ok()?[..]
.try_into()
.ok()?,
))
} else if let Some(sha1) = item.children().find(|e| e.has_tag_name("ChecksumSHA1")) {
Some(ChecksumValue::Sha1(
BASE64_STANDARD.decode(sha1.text()?).ok()?[..]
.try_into()
.ok()?,
))
} else if let Some(sha256) = item.children().find(|e| e.has_tag_name("ChecksumSHA256"))
{
Some(ChecksumValue::Sha256(
BASE64_STANDARD.decode(sha256.text()?).ok()?[..]
.try_into()
.ok()?,
))
} else {
None
};
let checksum = extract_checksum_from!(item {
"ChecksumCRC32" => Crc32,
"ChecksumCRC32C" => Crc32c,
"ChecksumCRC64NVME" => Crc64Nvme,
"ChecksumSHA1" => Sha1,
"ChecksumSHA256" => Sha256,
});
parts.push(CompleteMultipartUploadPart {
etag: etag.trim_matches('"').to_string(),
part_number: part_number.parse().ok()?,
@@ -618,36 +679,52 @@ fn parse_complete_multipart_upload_body(
// ====== checksummer ====
pub fn request_checksum_algorithm_and_type(
headers: &HeaderMap<HeaderValue>,
algo: Option<ChecksumAlgorithm>,
) -> Result<Option<(ChecksumAlgorithm, ChecksumType)>, Error> {
match (headers.get(X_AMZ_CHECKSUM_TYPE), algo) {
(None, None) => Ok(None),
(None, Some(algo)) => {
let ty = match algo {
ChecksumAlgorithm::Crc64Nvme => ChecksumType::FullObject,
_ => ChecksumType::Composite,
};
Ok(Some((algo, ty)))
}
(Some(_), None) => Err(Error::bad_request(
"Cannot specify x-amz-checksum-type when no checksum algorithm is in use.",
)),
(Some(x), Some(algo)) => {
let checksum_type = match x.as_bytes() {
x if x == COMPOSITE.as_bytes() => ChecksumType::Composite,
x if x == FULL_OBJECT.as_bytes() => ChecksumType::FullObject,
_ => return Err(Error::bad_request("Invalid x-amz-checksum-type value")),
};
match (checksum_type, algo) {
(ChecksumType::Composite, ChecksumAlgorithm::Crc64Nvme)
| (ChecksumType::FullObject, ChecksumAlgorithm::Sha1)
| (ChecksumType::FullObject, ChecksumAlgorithm::Sha256) => Err(Error::bad_request(format!(
"checksum type {:?} is not supported for algorithm {:?}",
checksum_type, algo
))),
(ty, algo) => Ok(Some((algo, ty))),
}
}
}
}
#[derive(Default)]
pub(crate) struct MultipartChecksummer {
pub md5: Md5,
pub extra: Option<MultipartExtraChecksummer>,
}
pub(crate) enum MultipartExtraChecksummer {
Crc32(Crc32),
Crc32c(Crc32c),
Sha1(Sha1),
Sha256(Sha256),
}
impl MultipartChecksummer {
pub(crate) fn init(algo: Option<ChecksumAlgorithm>) -> Self {
pub(crate) fn init(algo: Option<(ChecksumAlgorithm, ChecksumType)>) -> Self {
Self {
md5: Md5::new(),
extra: match algo {
None => None,
Some(ChecksumAlgorithm::Crc32) => {
Some(MultipartExtraChecksummer::Crc32(Crc32::new()))
}
Some(ChecksumAlgorithm::Crc32c) => {
Some(MultipartExtraChecksummer::Crc32c(Crc32c::default()))
}
Some(ChecksumAlgorithm::Sha1) => Some(MultipartExtraChecksummer::Sha1(Sha1::new())),
Some(ChecksumAlgorithm::Sha256) => {
Some(MultipartExtraChecksummer::Sha256(Sha256::new()))
}
},
extra: algo.map(|(algo, cktype)| MultipartExtraChecksummer::init(algo, cktype)),
}
}
@@ -655,59 +732,130 @@ impl MultipartChecksummer {
&mut self,
etag: &str,
checksum: Option<ChecksumValue>,
part_len: u64,
) -> Result<(), Error> {
self.md5
.update(&hex::decode(&etag).ok_or_message("invalid etag hex")?);
match (&mut self.extra, checksum) {
(None, _) => (),
(
Some(MultipartExtraChecksummer::Crc32(ref mut crc32)),
Some(ChecksumValue::Crc32(x)),
) => {
crc32.update(&x);
}
(
Some(MultipartExtraChecksummer::Crc32c(ref mut crc32c)),
Some(ChecksumValue::Crc32c(x)),
) => {
crc32c.write(&x);
}
(Some(MultipartExtraChecksummer::Sha1(ref mut sha1)), Some(ChecksumValue::Sha1(x))) => {
sha1.update(&x);
}
(
Some(MultipartExtraChecksummer::Sha256(ref mut sha256)),
Some(ChecksumValue::Sha256(x)),
) => {
sha256.update(&x);
}
(Some(_), b) => {
return Err(Error::internal_error(format!(
"part checksum was not computed correctly, got: {:?}",
b
)))
}
if let Some(extra) = &mut self.extra {
extra.update(checksum, part_len)?;
}
Ok(())
}
pub(crate) fn finalize(self) -> (Md5Checksum, Option<ChecksumValue>) {
let md5 = self.md5.finalize()[..].try_into().unwrap();
let extra = match self.extra {
None => None,
Some(MultipartExtraChecksummer::Crc32(crc32)) => {
Some(ChecksumValue::Crc32(u32::to_be_bytes(crc32.finalize())))
}
Some(MultipartExtraChecksummer::Crc32c(crc32c)) => Some(ChecksumValue::Crc32c(
u32::to_be_bytes(u32::try_from(crc32c.finish()).unwrap()),
)),
Some(MultipartExtraChecksummer::Sha1(sha1)) => {
Some(ChecksumValue::Sha1(sha1.finalize()[..].try_into().unwrap()))
}
Some(MultipartExtraChecksummer::Sha256(sha256)) => Some(ChecksumValue::Sha256(
sha256.finalize()[..].try_into().unwrap(),
)),
};
let extra = self.extra.map(|c| c.finalize());
(md5, extra)
}
}
pub(crate) enum MultipartExtraChecksummer {
FullObjectCrc(CrcAlgorithm, Option<u64>),
CompositeCrc(ChecksumAlgorithm, CrcDigest),
CompositeSha1(Sha1),
CompositeSha256(Sha256),
}
impl MultipartExtraChecksummer {
fn init(algo: ChecksumAlgorithm, cktype: ChecksumType) -> Self {
match (algo, cktype) {
(algo, ChecksumType::FullObject) => {
let crc_type = match algo {
ChecksumAlgorithm::Crc32 => CrcAlgorithm::Crc32IsoHdlc,
ChecksumAlgorithm::Crc32c => CrcAlgorithm::Crc32Iscsi,
ChecksumAlgorithm::Crc64Nvme => CrcAlgorithm::Crc64Nvme,
_ => unreachable!(),
};
Self::FullObjectCrc(crc_type, None)
}
(ChecksumAlgorithm::Crc32, ChecksumType::Composite) => {
Self::CompositeCrc(ChecksumAlgorithm::Crc32, new_crc32())
}
(ChecksumAlgorithm::Crc32c, ChecksumType::Composite) => {
Self::CompositeCrc(ChecksumAlgorithm::Crc32c, new_crc32c())
}
(ChecksumAlgorithm::Sha1, ChecksumType::Composite) => Self::CompositeSha1(Sha1::new()),
(ChecksumAlgorithm::Sha256, ChecksumType::Composite) => {
Self::CompositeSha256(Sha256::new())
}
_ => unreachable!(),
}
}
fn update(&mut self, checksum: Option<ChecksumValue>, part_len: u64) -> Result<(), Error> {
match (self, checksum) {
(Self::FullObjectCrc(crc_algo, crc_value), Some(ck)) => {
let ck_u64 = match ck {
ChecksumValue::Crc32(x) => u32::from_be_bytes(x) as u64,
ChecksumValue::Crc32c(x) => u32::from_be_bytes(x) as u64,
ChecksumValue::Crc64Nvme(x) => u64::from_be_bytes(x),
_ => {
return Err(Error::internal_error(format!(
"part checksum was not computed correctly, got: {:?}",
ck
)))
}
};
*crc_value = match *crc_value {
None => Some(ck_u64),
Some(prev) => Some(crc_fast::checksum_combine(
*crc_algo, prev, ck_u64, part_len,
)),
};
}
(Self::CompositeCrc(_, digest), Some(ck)) => match ck {
ChecksumValue::Crc32(x) => digest.update(&x),
ChecksumValue::Crc32c(x) => digest.update(&x),
ChecksumValue::Crc64Nvme(x) => digest.update(&x),
_ => {
return Err(Error::internal_error(format!(
"part checksum was not computed correctly, got: {:?}",
ck
)))
}
},
(Self::CompositeSha1(sha1), Some(ChecksumValue::Sha1(x))) => {
sha1.update(&x);
}
(Self::CompositeSha256(sha256), Some(ChecksumValue::Sha256(x))) => {
sha256.update(&x);
}
_ => {
return Err(Error::internal_error(format!(
"part checksum was not computed correctly, got: {:?}",
checksum
)))
}
}
Ok(())
}
fn finalize(self) -> ChecksumValue {
match self {
Self::FullObjectCrc(algo, value) => match (algo, value) {
(CrcAlgorithm::Crc32IsoHdlc, Some(v)) => {
ChecksumValue::Crc32(u32::to_be_bytes(v as u32))
}
(CrcAlgorithm::Crc32Iscsi, Some(v)) => {
ChecksumValue::Crc32c(u32::to_be_bytes(v as u32))
}
(CrcAlgorithm::Crc64Nvme, Some(v)) => ChecksumValue::Crc64Nvme(u64::to_be_bytes(v)),
_ => unreachable!(),
},
Self::CompositeCrc(algo, crc) => match algo {
ChecksumAlgorithm::Crc32 => {
ChecksumValue::Crc32(u32::to_be_bytes(crc.finalize() as u32))
}
ChecksumAlgorithm::Crc32c => {
ChecksumValue::Crc32c(u32::to_be_bytes(crc.finalize() as u32))
}
_ => unreachable!(),
},
Self::CompositeSha1(sha1) => {
ChecksumValue::Sha1(sha1.finalize()[..].try_into().unwrap())
}
Self::CompositeSha256(sha256) => {
ChecksumValue::Sha256(sha256.finalize()[..].try_into().unwrap())
}
}
}
}
+20 -11
View File
@@ -15,6 +15,7 @@ use serde::Deserialize;
use garage_model::garage::Garage;
use garage_model::s3::object_table::*;
use garage_util::data::gen_uuid;
use garage_api_common::cors::*;
use garage_api_common::helpers::*;
@@ -22,7 +23,7 @@ use garage_api_common::signature::checksum::*;
use garage_api_common::signature::payload::{verify_v4, Authorization};
use crate::api_server::ResBody;
use crate::encryption::EncryptionParams;
use crate::encryption::{EncryptionParams, OekDerivationInfo};
use crate::error::*;
use crate::put::{extract_metadata_headers, save_stream, ChecksumMode};
use crate::xml as s3_xml;
@@ -103,22 +104,18 @@ pub async fn handle_post_object(
key.to_owned()
};
let api_key = verify_v4(&garage, "s3", &authorization, policy.as_bytes()).await?;
let api_key = verify_v4(&garage, "s3", &authorization, policy.as_bytes())?;
let bucket_id = garage
let bucket = garage
.bucket_helper()
.resolve_bucket(&bucket_name, &api_key)
.await
.resolve_bucket_fast(&bucket_name, &api_key)
.map_err(pass_helper_error)?;
let bucket_id = bucket.id;
if !api_key.allow_write(&bucket_id) {
return Err(Error::forbidden("Operation is not allowed for this key."));
}
let bucket = garage
.bucket_helper()
.get_existing_bucket(bucket_id)
.await?;
let bucket_params = bucket.state.into_option().unwrap();
let matching_cors_rule = find_matching_cors_rule(
&bucket_params,
@@ -247,12 +244,23 @@ pub async fn handle_post_object(
.transpose()?,
};
let version_uuid = gen_uuid();
let meta = ObjectVersionMetaInner {
headers,
checksum: expected_checksums.extra,
checksum_type: expected_checksums.extra.map(|_| ChecksumType::FullObject),
};
let encryption = EncryptionParams::new_from_headers(&garage, &params)?;
let encryption = EncryptionParams::new_from_headers(
&garage,
&params,
OekDerivationInfo {
bucket_id,
version_id: version_uuid,
object_key: &key,
},
)?;
let stream = file_field.map(|r| r.map_err(Into::into));
let ctx = ReqCtx {
@@ -265,11 +273,12 @@ pub async fn handle_post_object(
let res = save_stream(
&ctx,
version_uuid,
meta,
encryption,
StreamLimiter::new(stream, conditions.content_length),
&key,
ChecksumMode::Verify(&expected_checksums),
ChecksumMode::Verify(expected_checksums),
)
.await?;
+20 -6
View File
@@ -35,7 +35,7 @@ use garage_api_common::signature::body::StreamingChecksumReceiver;
use garage_api_common::signature::checksum::*;
use crate::api_server::{ReqBody, ResBody};
use crate::encryption::EncryptionParams;
use crate::encryption::{EncryptionParams, OekDerivationInfo};
use crate::error::*;
use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION;
@@ -48,8 +48,8 @@ pub(crate) struct SaveStreamResult {
pub(crate) etag: String,
}
pub(crate) enum ChecksumMode<'a> {
Verify(&'a ExpectedChecksums),
pub(crate) enum ChecksumMode {
Verify(ExpectedChecksums),
VerifyFrom {
checksummer: StreamingChecksumReceiver,
trailer_algo: Option<ChecksumAlgorithm>,
@@ -62,6 +62,10 @@ pub async fn handle_put(
req: Request<ReqBody>,
key: &String,
) -> Result<Response<ResBody>, Error> {
// Generate version uuid now, because it is necessary to compute SSE-C
// encryption parameters
let version_uuid = gen_uuid();
// Retrieve interesting headers from request
let headers = extract_metadata_headers(req.headers())?;
debug!("Object headers: {:?}", headers);
@@ -79,10 +83,19 @@ pub async fn handle_put(
let meta = ObjectVersionMetaInner {
headers,
checksum: expected_checksums.extra,
checksum_type: expected_checksums.extra.map(|_| ChecksumType::FullObject),
};
// Determine whether object should be encrypted, and if so the key
let encryption = EncryptionParams::new_from_headers(&ctx.garage, req.headers())?;
let encryption = EncryptionParams::new_from_headers(
&ctx.garage,
req.headers(),
OekDerivationInfo {
bucket_id: ctx.bucket_id,
version_id: version_uuid,
object_key: &key,
},
)?;
// The request body is a special ReqBody object (see garage_api_common::signature::body)
// which supports calculating checksums while streaming the data.
@@ -100,6 +113,7 @@ pub async fn handle_put(
let res = save_stream(
&ctx,
version_uuid,
meta,
encryption,
stream,
@@ -121,11 +135,12 @@ pub async fn handle_put(
pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
ctx: &ReqCtx,
version_uuid: Uuid,
mut meta: ObjectVersionMetaInner,
encryption: EncryptionParams,
body: S,
key: &String,
checksum_mode: ChecksumMode<'_>,
checksum_mode: ChecksumMode,
) -> Result<SaveStreamResult, Error> {
let ReqCtx {
garage, bucket_id, ..
@@ -140,7 +155,6 @@ pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
let first_block = first_block_opt.unwrap_or_default();
// Generate identity of new version
let version_uuid = gen_uuid();
let version_timestamp = next_timestamp(existing_object.as_ref());
let mut checksummer = match &checksum_mode {
+172 -54
View File
@@ -3,7 +3,7 @@ use quick_xml::de::from_reader;
use hyper::{header::HeaderName, Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use garage_model::bucket_table::*;
use garage_model::bucket_table::{self, *};
use garage_api_common::helpers::*;
@@ -26,7 +26,28 @@ pub async fn handle_get_website(ctx: ReqCtx) -> Result<Response<ResBody>, Error>
suffix: Value(website.index_document.to_string()),
}),
redirect_all_requests_to: None,
routing_rules: None,
routing_rules: RoutingRules {
rules: website
.routing_rules
.clone()
.into_iter()
.map(|rule| RoutingRule {
condition: rule.condition.map(|cond| Condition {
http_error_code: cond.http_error_code.map(|c| IntValue(c as i64)),
prefix: cond.prefix.map(Value),
}),
redirect: Redirect {
hostname: rule.redirect.hostname.map(Value),
http_redirect_code: Some(IntValue(
rule.redirect.http_redirect_code as i64,
)),
protocol: rule.redirect.protocol.map(Value),
replace_full: rule.redirect.replace_key.map(Value),
replace_prefix: rule.redirect.replace_key_prefix.map(Value),
},
})
.collect(),
},
};
let xml = to_xml_with_header(&wc)?;
Ok(Response::builder()
@@ -97,18 +118,28 @@ pub struct WebsiteConfiguration {
pub index_document: Option<Suffix>,
#[serde(rename = "RedirectAllRequestsTo")]
pub redirect_all_requests_to: Option<Target>,
#[serde(rename = "RoutingRules")]
pub routing_rules: Option<Vec<RoutingRule>>,
#[serde(
rename = "RoutingRules",
default,
skip_serializing_if = "RoutingRules::is_empty"
)]
pub routing_rules: RoutingRules,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct RoutingRules {
#[serde(rename = "RoutingRule")]
pub rules: Vec<RoutingRule>,
}
impl RoutingRules {
fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct RoutingRule {
#[serde(rename = "RoutingRule")]
pub inner: RoutingRuleInner,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct RoutingRuleInner {
#[serde(rename = "Condition")]
pub condition: Option<Condition>,
#[serde(rename = "Redirect")]
@@ -162,7 +193,7 @@ impl WebsiteConfiguration {
if self.redirect_all_requests_to.is_some()
&& (self.error_document.is_some()
|| self.index_document.is_some()
|| self.routing_rules.is_some())
|| !self.routing_rules.is_empty())
{
return Err(Error::bad_request(
"Bad XML: can't have RedirectAllRequestsTo and other fields",
@@ -177,10 +208,15 @@ impl WebsiteConfiguration {
if let Some(ref rart) = self.redirect_all_requests_to {
rart.validate()?;
}
if let Some(ref rrs) = self.routing_rules {
for rr in rrs {
rr.inner.validate()?;
}
for rr in &self.routing_rules.rules {
rr.validate()?;
}
if self.routing_rules.rules.len() > 1000 {
// we will do linear scans, best to avoid overly long configuration. The
// limit was choosen arbitrarily
return Err(Error::bad_request(
"Bad XML: RoutingRules can't have more than 1000 child elements",
));
}
Ok(())
@@ -189,11 +225,7 @@ impl WebsiteConfiguration {
pub fn into_garage_website_config(self) -> Result<WebsiteConfig, Error> {
if self.redirect_all_requests_to.is_some() {
Err(Error::NotImplemented(
"S3 website redirects are not currently implemented in Garage.".into(),
))
} else if self.routing_rules.map(|x| !x.is_empty()).unwrap_or(false) {
Err(Error::NotImplemented(
"S3 routing rules are not currently implemented in Garage.".into(),
"RedirectAllRequestsTo is not currently implemented in Garage, however its effect can be emulated using a single inconditional RoutingRule.".into(),
))
} else {
Ok(WebsiteConfig {
@@ -202,6 +234,36 @@ impl WebsiteConfiguration {
.map(|x| x.suffix.0)
.unwrap_or_else(|| "index.html".to_string()),
error_document: self.error_document.map(|x| x.key.0),
redirect_all: None,
routing_rules: self
.routing_rules
.rules
.into_iter()
.map(|rule| {
bucket_table::RoutingRule {
condition: rule.condition.map(|condition| {
bucket_table::RedirectCondition {
http_error_code: condition.http_error_code.map(|c| c.0 as u16),
prefix: condition.prefix.map(|p| p.0),
}
}),
redirect: bucket_table::Redirect {
hostname: rule.redirect.hostname.map(|h| h.0),
protocol: rule.redirect.protocol.map(|p| p.0),
// aws default to 301, which i find punitive in case of
// missconfiguration (can be permanently cached on the
// user agent)
http_redirect_code: rule
.redirect
.http_redirect_code
.map(|c| c.0 as u16)
.unwrap_or(302),
replace_key_prefix: rule.redirect.replace_prefix.map(|k| k.0),
replace_key: rule.redirect.replace_full.map(|k| k.0),
},
}
})
.collect(),
})
}
}
@@ -242,37 +304,69 @@ impl Target {
}
}
impl RoutingRuleInner {
impl RoutingRule {
pub fn validate(&self) -> Result<(), Error> {
let has_prefix = self
.condition
.as_ref()
.and_then(|c| c.prefix.as_ref())
.is_some();
self.redirect.validate(has_prefix)
if let Some(condition) = &self.condition {
condition.validate()?;
}
self.redirect.validate()
}
}
impl Condition {
pub fn validate(&self) -> Result<bool, Error> {
if let Some(ref error_code) = self.http_error_code {
// TODO do other error codes make sense? Aws only allows 4xx and 5xx
if error_code.0 != 404 {
return Err(Error::bad_request(
"Bad XML: HttpErrorCodeReturnedEquals must be 404 or absent",
));
}
}
Ok(self.prefix.is_some())
}
}
impl Redirect {
pub fn validate(&self, has_prefix: bool) -> Result<(), Error> {
if self.replace_prefix.is_some() {
if self.replace_full.is_some() {
return Err(Error::bad_request(
"Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set",
));
}
if !has_prefix {
return Err(Error::bad_request(
"Bad XML: ReplaceKeyPrefixWith is set, but KeyPrefixEquals isn't",
));
}
pub fn validate(&self) -> Result<(), Error> {
if self.replace_prefix.is_some() && self.replace_full.is_some() {
return Err(Error::bad_request(
"Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set",
));
}
if let Some(ref protocol) = self.protocol {
if protocol.0 != "http" && protocol.0 != "https" {
return Err(Error::bad_request("Bad XML: invalid protocol"));
}
}
// TODO there are probably more invalid cases, but which ones?
if let Some(ref http_redirect_code) = self.http_redirect_code {
match http_redirect_code.0 {
// aws allows all 3xx except 300, but some are non-sensical (not modified,
// use proxy...)
301 | 302 | 303 | 307 | 308 => {
if self.hostname.is_none() && self.protocol.is_some() {
return Err(Error::bad_request(
"Bad XML: HostName must be set if Protocol is set",
));
}
}
// aws doesn't allow these codes, but netlify does, and it seems like a
// cool feature (change the page seen without changing the url shown by the
// user agent)
200 | 404 => {
if self.hostname.is_some() || self.protocol.is_some() {
// hostname would mean different bucket, protocol doesn't make
// sense
return Err(Error::bad_request(
"Bad XML: an HttpRedirectCode of 200 is not acceptable alongside HostName or Protocol",
));
}
}
_ => {
return Err(Error::bad_request("Bad XML: invalid HttpRedirectCode"));
}
}
}
Ok(())
}
}
@@ -311,6 +405,15 @@ mod tests {
<ReplaceKeyWith>fullkey</ReplaceKeyWith>
</Redirect>
</RoutingRule>
<RoutingRule>
<Condition>
<KeyPrefixEquals></KeyPrefixEquals>
</Condition>
<Redirect>
<HttpRedirectCode>404</HttpRedirectCode>
<ReplaceKeyWith>missing</ReplaceKeyWith>
</Redirect>
</RoutingRule>
</RoutingRules>
</WebsiteConfiguration>"#;
let conf: WebsiteConfiguration = from_str(message).unwrap();
@@ -326,21 +429,36 @@ mod tests {
hostname: Value("garage.tld".to_owned()),
protocol: Some(Value("https".to_owned())),
}),
routing_rules: Some(vec![RoutingRule {
inner: RoutingRuleInner {
condition: Some(Condition {
http_error_code: Some(IntValue(404)),
prefix: Some(Value("prefix1".to_owned())),
}),
redirect: Redirect {
hostname: Some(Value("gara.ge".to_owned())),
protocol: Some(Value("http".to_owned())),
http_redirect_code: Some(IntValue(303)),
replace_prefix: Some(Value("prefix2".to_owned())),
replace_full: Some(Value("fullkey".to_owned())),
routing_rules: RoutingRules {
rules: vec![
RoutingRule {
condition: Some(Condition {
http_error_code: Some(IntValue(404)),
prefix: Some(Value("prefix1".to_owned())),
}),
redirect: Redirect {
hostname: Some(Value("gara.ge".to_owned())),
protocol: Some(Value("http".to_owned())),
http_redirect_code: Some(IntValue(303)),
replace_prefix: Some(Value("prefix2".to_owned())),
replace_full: Some(Value("fullkey".to_owned())),
},
},
},
}]),
RoutingRule {
condition: Some(Condition {
http_error_code: None,
prefix: Some(Value("".to_owned())),
}),
redirect: Redirect {
hostname: None,
protocol: None,
http_redirect_code: Some(IntValue(404)),
replace_prefix: None,
replace_full: Some(Value("missing".to_owned())),
},
},
],
},
};
assert_eq! {
ref_value,
+12
View File
@@ -139,10 +139,14 @@ pub struct CompleteMultipartUploadResult {
pub checksum_crc32: Option<Value>,
#[serde(rename = "ChecksumCRC32C")]
pub checksum_crc32c: Option<Value>,
#[serde(rename = "ChecksumCR64NVME")]
pub checksum_crc64nvme: Option<Value>,
#[serde(rename = "ChecksumSHA1")]
pub checksum_sha1: Option<Value>,
#[serde(rename = "ChecksumSHA256")]
pub checksum_sha256: Option<Value>,
#[serde(rename = "ChecksumType")]
pub checksum_type: Option<Value>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
@@ -213,6 +217,8 @@ pub struct PartItem {
pub checksum_crc32: Option<Value>,
#[serde(rename = "ChecksumCRC32C")]
pub checksum_crc32c: Option<Value>,
#[serde(rename = "ChecksumCRC64NVME")]
pub checksum_crc64nvme: Option<Value>,
#[serde(rename = "ChecksumSHA1")]
pub checksum_sha1: Option<Value>,
#[serde(rename = "ChecksumSHA256")]
@@ -587,6 +593,7 @@ mod tests {
#[test]
fn complete_multipart_upload_result() -> Result<(), ApiError> {
use garage_api_common::signature::checksum::COMPOSITE;
let result = CompleteMultipartUploadResult {
xmlns: (),
location: Some(Value("https://garage.tld/mybucket/a/plop".to_string())),
@@ -595,8 +602,10 @@ mod tests {
etag: Value("\"3858f62230ac3c915f300c664312c11f-9\"".to_string()),
checksum_crc32: None,
checksum_crc32c: None,
checksum_crc64nvme: None,
checksum_sha1: Some(Value("ZJAnHyG8PeKz9tI8UTcHrJos39A=".into())),
checksum_sha256: None,
checksum_type: Some(Value(COMPOSITE.into())),
};
assert_eq!(
to_xml_with_header(&result)?,
@@ -607,6 +616,7 @@ mod tests {
<Key>a/plop</Key>\
<ETag>&quot;3858f62230ac3c915f300c664312c11f-9&quot;</ETag>\
<ChecksumSHA1>ZJAnHyG8PeKz9tI8UTcHrJos39A=</ChecksumSHA1>\
<ChecksumType>COMPOSITE</ChecksumType>\
</CompleteMultipartUploadResult>"
);
Ok(())
@@ -880,6 +890,7 @@ mod tests {
size: IntValue(10485760),
checksum_crc32: None,
checksum_crc32c: None,
checksum_crc64nvme: None,
checksum_sha256: Some(Value(
"5RQ3A5uk0w7ojNjvegohch4JRBBGN/cLhsNrPzfv/hA=".into(),
)),
@@ -893,6 +904,7 @@ mod tests {
checksum_sha256: None,
checksum_crc32c: None,
checksum_crc32: Some(Value("ZJAnHyG8=".into())),
checksum_crc64nvme: None,
checksum_sha1: None,
},
],