Allow recovery of poisoned objects via PUT/DELETE/DeleteObjects while preserving object-lock; implement race-free GetObject/HeadObject ?partNumber=N with correct zero-length-part response
This commit is contained in:
@@ -164,32 +164,37 @@ async fn ensure_object_lock_allows_write(
|
|||||||
key: &str,
|
key: &str,
|
||||||
headers: Option<&HeaderMap>,
|
headers: Option<&HeaderMap>,
|
||||||
) -> Result<(), Response> {
|
) -> Result<(), Response> {
|
||||||
match state.storage.head_object(bucket, key).await {
|
let head_res = state.storage.head_object(bucket, key).await;
|
||||||
Ok(_) => {
|
let needs_lock_check = match &head_res {
|
||||||
let metadata = match state.storage.get_object_metadata(bucket, key).await {
|
Ok(_) => true,
|
||||||
Ok(metadata) => metadata,
|
Err(myfsio_storage::error::StorageError::ObjectCorrupted { .. }) => true,
|
||||||
Err(err) => return Err(storage_err_response(err)),
|
Err(myfsio_storage::error::StorageError::ObjectNotFound { .. }) => return Ok(()),
|
||||||
};
|
Err(myfsio_storage::error::StorageError::DeleteMarker { .. }) => return Ok(()),
|
||||||
let bypass_governance = headers
|
Err(_) => false,
|
||||||
.and_then(|headers| {
|
};
|
||||||
headers
|
if !needs_lock_check {
|
||||||
.get("x-amz-bypass-governance-retention")
|
return Err(storage_err_response(head_res.err().unwrap()));
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
})
|
|
||||||
.map(|value| value.eq_ignore_ascii_case("true"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
if let Err(message) = object_lock::can_delete_object(&metadata, bypass_governance) {
|
|
||||||
return Err(s3_error_response(S3Error::new(
|
|
||||||
S3ErrorCode::AccessDenied,
|
|
||||||
message,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(myfsio_storage::error::StorageError::ObjectNotFound { .. }) => Ok(()),
|
|
||||||
Err(myfsio_storage::error::StorageError::DeleteMarker { .. }) => Ok(()),
|
|
||||||
Err(err) => Err(storage_err_response(err)),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let metadata = match state.storage.get_object_metadata(bucket, key).await {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(err) => return Err(storage_err_response(err)),
|
||||||
|
};
|
||||||
|
let bypass_governance = headers
|
||||||
|
.and_then(|headers| {
|
||||||
|
headers
|
||||||
|
.get("x-amz-bypass-governance-retention")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
})
|
||||||
|
.map(|value| value.eq_ignore_ascii_case("true"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if let Err(message) = object_lock::can_delete_object(&metadata, bypass_governance) {
|
||||||
|
return Err(s3_error_response(S3Error::new(
|
||||||
|
S3ErrorCode::AccessDenied,
|
||||||
|
message,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_object_version_lock_allows_delete(
|
async fn ensure_object_version_lock_allows_delete(
|
||||||
@@ -1609,6 +1614,13 @@ pub async fn get_object(
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
if range_header.is_some() && query.part_number.is_some() {
|
||||||
|
return s3_error_response(S3Error::new(
|
||||||
|
S3ErrorCode::InvalidRequest,
|
||||||
|
"Cannot specify both Range and partNumber on the same request",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref range_str) = range_header {
|
if let Some(ref range_str) = range_header {
|
||||||
return range_get_handler(&state, &bucket, &key, range_str, &query, &headers).await;
|
return range_get_handler(&state, &bucket, &key, range_str, &query, &headers).await;
|
||||||
}
|
}
|
||||||
@@ -1643,6 +1655,40 @@ pub async fn get_object(
|
|||||||
Err(e) => return storage_err_response(e),
|
Err(e) => return storage_err_response(e),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(part_number) = query.part_number {
|
||||||
|
match resolve_part_view(&snap_meta, part_number) {
|
||||||
|
Ok(view) if view.multipart => {
|
||||||
|
if view.length == 0 {
|
||||||
|
if let Some(resp) = evaluate_get_preconditions(&headers, &snap_meta) {
|
||||||
|
let _ = tokio::fs::remove_file(&snap_link).await;
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
let _ = tokio::fs::remove_file(&snap_link).await;
|
||||||
|
let mut h =
|
||||||
|
build_part_response_headers(&key, &snap_meta, &view, &query);
|
||||||
|
apply_user_metadata(&mut h, &snap_meta.metadata);
|
||||||
|
return (StatusCode::PARTIAL_CONTENT, h).into_response();
|
||||||
|
}
|
||||||
|
let range_str = format!("bytes={}-{}", view.start, view.start + view.length - 1);
|
||||||
|
return serve_range_from_snapshot(
|
||||||
|
&state,
|
||||||
|
snap_link,
|
||||||
|
snap_meta,
|
||||||
|
&range_str,
|
||||||
|
&query,
|
||||||
|
&headers,
|
||||||
|
Some(view.parts_count),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(resp) => {
|
||||||
|
let _ = tokio::fs::remove_file(&snap_link).await;
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Evaluate preconditions against the served snapshot's metadata. A HEAD
|
// Evaluate preconditions against the served snapshot's metadata. A HEAD
|
||||||
// taken earlier could disagree with the snapshot if a concurrent PUT
|
// taken earlier could disagree with the snapshot if a concurrent PUT
|
||||||
// landed in between, causing us to serve a body that doesn't satisfy
|
// landed in between, causing us to serve a body that doesn't satisfy
|
||||||
@@ -1870,6 +1916,21 @@ pub async fn head_object(
|
|||||||
if let Some(resp) = evaluate_get_preconditions(&headers, &meta) {
|
if let Some(resp) = evaluate_get_preconditions(&headers, &meta) {
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let part_view = match query.part_number {
|
||||||
|
Some(n) => match resolve_part_view(&meta, n) {
|
||||||
|
Ok(v) => Some(v),
|
||||||
|
Err(resp) => return resp,
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(view) = part_view.as_ref().filter(|v| v.multipart) {
|
||||||
|
let mut headers = build_part_response_headers(&key, &meta, view, &query);
|
||||||
|
apply_user_metadata(&mut headers, &meta.metadata);
|
||||||
|
return (StatusCode::PARTIAL_CONTENT, headers).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert("content-length", meta.size.to_string().parse().unwrap());
|
headers.insert("content-length", meta.size.to_string().parse().unwrap());
|
||||||
if let Some(ref etag) = meta.etag {
|
if let Some(ref etag) = meta.etag {
|
||||||
@@ -1905,6 +1966,134 @@ pub async fn head_object(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PartView {
|
||||||
|
start: u64,
|
||||||
|
length: u64,
|
||||||
|
parts_count: u32,
|
||||||
|
multipart: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_part_response_headers(
|
||||||
|
key: &str,
|
||||||
|
meta: &myfsio_common::types::ObjectMeta,
|
||||||
|
view: &PartView,
|
||||||
|
query: &ObjectQuery,
|
||||||
|
) -> HeaderMap {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("content-length", view.length.to_string().parse().unwrap());
|
||||||
|
if view.length > 0 {
|
||||||
|
headers.insert(
|
||||||
|
"content-range",
|
||||||
|
format!(
|
||||||
|
"bytes {}-{}/{}",
|
||||||
|
view.start,
|
||||||
|
view.start + view.length - 1,
|
||||||
|
meta.size
|
||||||
|
)
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(ref etag) = meta.etag {
|
||||||
|
headers.insert("etag", format!("\"{}\"", etag).parse().unwrap());
|
||||||
|
}
|
||||||
|
insert_content_type(&mut headers, key, meta.content_type.as_deref());
|
||||||
|
headers.insert(
|
||||||
|
"last-modified",
|
||||||
|
meta.last_modified
|
||||||
|
.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||||
|
.to_string()
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert("accept-ranges", "bytes".parse().unwrap());
|
||||||
|
apply_stored_response_headers(&mut headers, &meta.internal_metadata);
|
||||||
|
if let Some(ref requested_version) = query.version_id {
|
||||||
|
if let Ok(value) = requested_version.parse() {
|
||||||
|
headers.insert("x-amz-version-id", value);
|
||||||
|
}
|
||||||
|
} else if let Some(ref vid) = meta.version_id {
|
||||||
|
if let Ok(value) = vid.parse() {
|
||||||
|
headers.insert("x-amz-version-id", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers.insert(
|
||||||
|
"x-amz-mp-parts-count",
|
||||||
|
view.parts_count.to_string().parse().unwrap(),
|
||||||
|
);
|
||||||
|
apply_response_overrides(&mut headers, query);
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_part_view(
|
||||||
|
meta: &myfsio_common::types::ObjectMeta,
|
||||||
|
part_number: u32,
|
||||||
|
) -> Result<PartView, Response> {
|
||||||
|
if part_number < 1 {
|
||||||
|
return Err(s3_error_response(S3Error::new(
|
||||||
|
S3ErrorCode::InvalidArgument,
|
||||||
|
"partNumber must be >= 1",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let etag = meta.etag.as_deref().unwrap_or("");
|
||||||
|
let is_multipart = myfsio_storage::fs_backend::is_multipart_etag(etag);
|
||||||
|
|
||||||
|
if !is_multipart {
|
||||||
|
if part_number == 1 {
|
||||||
|
return Ok(PartView {
|
||||||
|
start: 0,
|
||||||
|
length: meta.size,
|
||||||
|
parts_count: 1,
|
||||||
|
multipart: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(s3_error_response(S3Error::new(
|
||||||
|
S3ErrorCode::InvalidPart,
|
||||||
|
format!(
|
||||||
|
"partNumber {} is out of range for a non-multipart object",
|
||||||
|
part_number
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let part_sizes = match meta
|
||||||
|
.internal_metadata
|
||||||
|
.get(myfsio_storage::fs_backend::META_KEY_PART_SIZES)
|
||||||
|
.and_then(|raw| myfsio_storage::fs_backend::parse_part_sizes(raw))
|
||||||
|
{
|
||||||
|
Some(sizes) => sizes,
|
||||||
|
None => {
|
||||||
|
return Err(s3_error_response(S3Error::new(
|
||||||
|
S3ErrorCode::InvalidRequest,
|
||||||
|
"Object is multipart but has no recorded part-size manifest; \
|
||||||
|
partNumber addressing is unavailable",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let idx = (part_number as usize).saturating_sub(1);
|
||||||
|
if idx >= part_sizes.len() {
|
||||||
|
return Err(s3_error_response(S3Error::new(
|
||||||
|
S3ErrorCode::InvalidPart,
|
||||||
|
format!(
|
||||||
|
"partNumber {} exceeds the {} parts in this object",
|
||||||
|
part_number,
|
||||||
|
part_sizes.len()
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let start: u64 = part_sizes.iter().take(idx).sum();
|
||||||
|
let length = part_sizes[idx];
|
||||||
|
Ok(PartView {
|
||||||
|
start,
|
||||||
|
length,
|
||||||
|
parts_count: part_sizes.len() as u32,
|
||||||
|
multipart: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn initiate_multipart_handler(state: &AppState, bucket: &str, key: &str) -> Response {
|
async fn initiate_multipart_handler(state: &AppState, bucket: &str, key: &str) -> Response {
|
||||||
match state.storage.initiate_multipart(bucket, key, None).await {
|
match state.storage.initiate_multipart(bucket, key, None).await {
|
||||||
Ok(upload_id) => {
|
Ok(upload_id) => {
|
||||||
@@ -2486,34 +2675,37 @@ async fn delete_objects_handler(state: &AppState, bucket: &str, body: Body) -> R
|
|||||||
async move {
|
async move {
|
||||||
let key = obj.key.clone();
|
let key = obj.key.clone();
|
||||||
let requested_vid = obj.version_id.clone();
|
let requested_vid = obj.version_id.clone();
|
||||||
|
let to_err = |err: myfsio_storage::error::StorageError| -> (String, String) {
|
||||||
|
let s3err = S3Error::from(err);
|
||||||
|
(s3err.code.as_str().to_string(), s3err.message)
|
||||||
|
};
|
||||||
|
let run_can_delete =
|
||||||
|
|metadata: &HashMap<String, String>| -> Result<(), (String, String)> {
|
||||||
|
object_lock::can_delete_object(metadata, false)
|
||||||
|
.map_err(|m| (S3ErrorCode::AccessDenied.as_str().to_string(), m))
|
||||||
|
};
|
||||||
let lock_check: Result<(), (String, String)> = match obj.version_id.as_deref() {
|
let lock_check: Result<(), (String, String)> = match obj.version_id.as_deref() {
|
||||||
Some(version_id) if version_id != "null" => match state
|
Some(version_id) if version_id != "null" => {
|
||||||
.storage
|
match state
|
||||||
.get_object_version_metadata(&bucket, &obj.key, version_id)
|
.storage
|
||||||
.await
|
.get_object_version_metadata(&bucket, &obj.key, version_id)
|
||||||
{
|
.await
|
||||||
Ok(metadata) => object_lock::can_delete_object(&metadata, false)
|
{
|
||||||
.map_err(|m| (S3ErrorCode::AccessDenied.as_str().to_string(), m)),
|
Ok(metadata) => run_can_delete(&metadata),
|
||||||
Err(err) => {
|
Err(err) => Err(to_err(err)),
|
||||||
let s3err = S3Error::from(err);
|
|
||||||
Err((s3err.code.as_str().to_string(), s3err.message))
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
_ => match state.storage.head_object(&bucket, &obj.key).await {
|
_ => match state.storage.head_object(&bucket, &obj.key).await {
|
||||||
Ok(_) => match state.storage.get_object_metadata(&bucket, &obj.key).await {
|
Ok(_)
|
||||||
Ok(metadata) => object_lock::can_delete_object(&metadata, false)
|
| Err(myfsio_storage::error::StorageError::ObjectCorrupted { .. }) => {
|
||||||
.map_err(|m| (S3ErrorCode::AccessDenied.as_str().to_string(), m)),
|
match state.storage.get_object_metadata(&bucket, &obj.key).await {
|
||||||
Err(err) => {
|
Ok(metadata) => run_can_delete(&metadata),
|
||||||
let s3err = S3Error::from(err);
|
Err(err) => Err(to_err(err)),
|
||||||
Err((s3err.code.as_str().to_string(), s3err.message))
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Err(myfsio_storage::error::StorageError::ObjectNotFound { .. }) => Ok(()),
|
Err(myfsio_storage::error::StorageError::ObjectNotFound { .. }) => Ok(()),
|
||||||
Err(myfsio_storage::error::StorageError::DeleteMarker { .. }) => Ok(()),
|
Err(myfsio_storage::error::StorageError::DeleteMarker { .. }) => Ok(()),
|
||||||
Err(err) => {
|
Err(err) => Err(to_err(err)),
|
||||||
let s3err = S3Error::from(err);
|
|
||||||
Err((s3err.code.as_str().to_string(), s3err.message))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2578,6 +2770,18 @@ async fn range_get_handler(
|
|||||||
range_str: &str,
|
range_str: &str,
|
||||||
query: &ObjectQuery,
|
query: &ObjectQuery,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
range_get_handler_inner(state, bucket, key, range_str, query, headers, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn range_get_handler_inner(
|
||||||
|
state: &AppState,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
range_str: &str,
|
||||||
|
query: &ObjectQuery,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
parts_count: Option<u32>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let version_id = query
|
let version_id = query
|
||||||
.version_id
|
.version_id
|
||||||
@@ -2607,6 +2811,21 @@ async fn range_get_handler(
|
|||||||
Err(e) => return storage_err_response(e),
|
Err(e) => return storage_err_response(e),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
serve_range_from_snapshot(state, snap_link, meta, range_str, query, headers, parts_count).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_range_from_snapshot(
|
||||||
|
state: &AppState,
|
||||||
|
snap_link: std::path::PathBuf,
|
||||||
|
meta: myfsio_common::types::ObjectMeta,
|
||||||
|
range_str: &str,
|
||||||
|
query: &ObjectQuery,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
parts_count: Option<u32>,
|
||||||
|
) -> Response {
|
||||||
|
let key = meta.key.as_str();
|
||||||
|
let tmp_dir = state.config.storage_root.join(".myfsio.sys").join("tmp");
|
||||||
|
|
||||||
if let Some(resp) = evaluate_get_preconditions(headers, &meta) {
|
if let Some(resp) = evaluate_get_preconditions(headers, &meta) {
|
||||||
let _ = tokio::fs::remove_file(&snap_link).await;
|
let _ = tokio::fs::remove_file(&snap_link).await;
|
||||||
return resp;
|
return resp;
|
||||||
@@ -2666,6 +2885,7 @@ async fn range_get_handler(
|
|||||||
query,
|
query,
|
||||||
Some(enc_info.algorithm.as_str()),
|
Some(enc_info.algorithm.as_str()),
|
||||||
/* already_trimmed */ true,
|
/* already_trimmed */ true,
|
||||||
|
parts_count,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -2720,6 +2940,7 @@ async fn range_get_handler(
|
|||||||
query,
|
query,
|
||||||
enc_header,
|
enc_header,
|
||||||
/* already_trimmed */ false,
|
/* already_trimmed */ false,
|
||||||
|
parts_count,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -2735,6 +2956,7 @@ async fn stream_partial_content(
|
|||||||
query: &ObjectQuery,
|
query: &ObjectQuery,
|
||||||
enc_header: Option<&str>,
|
enc_header: Option<&str>,
|
||||||
already_trimmed: bool,
|
already_trimmed: bool,
|
||||||
|
parts_count: Option<u32>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let length = end - start + 1;
|
let length = end - start + 1;
|
||||||
|
|
||||||
@@ -2789,6 +3011,10 @@ async fn stream_partial_content(
|
|||||||
|
|
||||||
apply_response_overrides(&mut headers, query);
|
apply_response_overrides(&mut headers, query);
|
||||||
|
|
||||||
|
if let Some(count) = parts_count {
|
||||||
|
headers.insert("x-amz-mp-parts-count", count.to_string().parse().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
(StatusCode::PARTIAL_CONTENT, headers, body).into_response()
|
(StatusCode::PARTIAL_CONTENT, headers, body).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,34 @@ pub const META_KEY_CORRUPTED: &str = "__corrupted__";
|
|||||||
pub const META_KEY_CORRUPTED_AT: &str = "__corrupted_at__";
|
pub const META_KEY_CORRUPTED_AT: &str = "__corrupted_at__";
|
||||||
pub const META_KEY_CORRUPTION_DETAIL: &str = "__corruption_detail__";
|
pub const META_KEY_CORRUPTION_DETAIL: &str = "__corruption_detail__";
|
||||||
pub const META_KEY_QUARANTINE_PATH: &str = "__quarantine_path__";
|
pub const META_KEY_QUARANTINE_PATH: &str = "__quarantine_path__";
|
||||||
|
pub const META_KEY_PART_SIZES: &str = "__part_sizes__";
|
||||||
|
|
||||||
|
pub fn encode_part_sizes(sizes: &[u64]) -> String {
|
||||||
|
let mut out = String::with_capacity(sizes.len() * 8);
|
||||||
|
for (i, s) in sizes.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push(',');
|
||||||
|
}
|
||||||
|
out.push_str(&s.to_string());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_part_sizes(raw: &str) -> Option<Vec<u64>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for tok in raw.split(',') {
|
||||||
|
let tok = tok.trim();
|
||||||
|
if tok.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
out.push(tok.parse::<u64>().ok()?);
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn metadata_is_corrupted(meta: &HashMap<String, String>) -> bool {
|
pub fn metadata_is_corrupted(meta: &HashMap<String, String>) -> bool {
|
||||||
meta.get(META_KEY_CORRUPTED)
|
meta.get(META_KEY_CORRUPTED)
|
||||||
@@ -2829,6 +2857,12 @@ impl crate::traits::StorageEngine for FsStorageBackend {
|
|||||||
self.delete_metadata_sync(bucket, key)
|
self.delete_metadata_sync(bucket, key)
|
||||||
.map_err(StorageError::Io)?;
|
.map_err(StorageError::Io)?;
|
||||||
Self::cleanup_empty_parents(&path, &bucket_path);
|
Self::cleanup_empty_parents(&path, &bucket_path);
|
||||||
|
} else {
|
||||||
|
let stored_meta = self.read_metadata_sync(bucket, key);
|
||||||
|
if !stored_meta.is_empty() {
|
||||||
|
self.delete_metadata_sync(bucket, key)
|
||||||
|
.map_err(StorageError::Io)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let dm_version_id = self
|
let dm_version_id = self
|
||||||
.write_delete_marker_sync(bucket, key)
|
.write_delete_marker_sync(bucket, key)
|
||||||
@@ -2842,6 +2876,17 @@ impl crate::traits::StorageEngine for FsStorageBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
let stored_meta = self.read_metadata_sync(bucket, key);
|
||||||
|
if !stored_meta.is_empty() {
|
||||||
|
self.delete_metadata_sync(bucket, key)
|
||||||
|
.map_err(StorageError::Io)?;
|
||||||
|
self.invalidate_bucket_caches(bucket);
|
||||||
|
return Ok(DeleteOutcome {
|
||||||
|
version_id: None,
|
||||||
|
is_delete_marker: false,
|
||||||
|
existed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
return Ok(DeleteOutcome::default());
|
return Ok(DeleteOutcome::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3353,47 +3398,52 @@ impl crate::traits::StorageEngine for FsStorageBackend {
|
|||||||
|
|
||||||
// Assemble parts on a blocking thread using std::fs, large buffers,
|
// Assemble parts on a blocking thread using std::fs, large buffers,
|
||||||
// and a single writer flush — no per-chunk runtime crossings.
|
// and a single writer flush — no per-chunk runtime crossings.
|
||||||
let assemble_res = tokio::task::spawn_blocking(move || -> StorageResult<(String, u64)> {
|
let assemble_res =
|
||||||
use std::io::{BufReader, BufWriter, Read, Write};
|
tokio::task::spawn_blocking(move || -> StorageResult<(String, u64, Vec<u64>)> {
|
||||||
let out_raw = std::fs::File::create(&tmp_path_owned).map_err(StorageError::Io)?;
|
use std::io::{BufReader, BufWriter, Read, Write};
|
||||||
let mut out_file = BufWriter::with_capacity(chunk_size * 4, out_raw);
|
let out_raw = std::fs::File::create(&tmp_path_owned).map_err(StorageError::Io)?;
|
||||||
let mut md5_digest_concat = Vec::with_capacity(part_infos.len() * 16);
|
let mut out_file = BufWriter::with_capacity(chunk_size * 4, out_raw);
|
||||||
let mut total_size: u64 = 0;
|
let mut md5_digest_concat = Vec::with_capacity(part_infos.len() * 16);
|
||||||
let mut buf = vec![0u8; chunk_size];
|
let mut total_size: u64 = 0;
|
||||||
|
let mut part_sizes: Vec<u64> = Vec::with_capacity(part_infos.len());
|
||||||
|
let mut buf = vec![0u8; chunk_size];
|
||||||
|
|
||||||
for part_info in &part_infos {
|
for part_info in &part_infos {
|
||||||
let part_file =
|
let part_file =
|
||||||
upload_dir_owned.join(format!("part-{:05}.part", part_info.part_number));
|
upload_dir_owned.join(format!("part-{:05}.part", part_info.part_number));
|
||||||
if !part_file.exists() {
|
if !part_file.exists() {
|
||||||
return Err(StorageError::InvalidObjectKey(format!(
|
return Err(StorageError::InvalidObjectKey(format!(
|
||||||
"Part {} not found",
|
"Part {} not found",
|
||||||
part_info.part_number
|
part_info.part_number
|
||||||
)));
|
)));
|
||||||
}
|
|
||||||
let reader = std::fs::File::open(&part_file).map_err(StorageError::Io)?;
|
|
||||||
let mut reader = BufReader::with_capacity(chunk_size, reader);
|
|
||||||
let mut part_hasher = Md5::new();
|
|
||||||
loop {
|
|
||||||
let n = reader.read(&mut buf).map_err(StorageError::Io)?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
part_hasher.update(&buf[..n]);
|
let reader = std::fs::File::open(&part_file).map_err(StorageError::Io)?;
|
||||||
out_file.write_all(&buf[..n]).map_err(StorageError::Io)?;
|
let mut reader = BufReader::with_capacity(chunk_size, reader);
|
||||||
total_size += n as u64;
|
let mut part_hasher = Md5::new();
|
||||||
|
let mut part_bytes: u64 = 0;
|
||||||
|
loop {
|
||||||
|
let n = reader.read(&mut buf).map_err(StorageError::Io)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
part_hasher.update(&buf[..n]);
|
||||||
|
out_file.write_all(&buf[..n]).map_err(StorageError::Io)?;
|
||||||
|
total_size += n as u64;
|
||||||
|
part_bytes += n as u64;
|
||||||
|
}
|
||||||
|
md5_digest_concat.extend_from_slice(&part_hasher.finalize());
|
||||||
|
part_sizes.push(part_bytes);
|
||||||
}
|
}
|
||||||
md5_digest_concat.extend_from_slice(&part_hasher.finalize());
|
|
||||||
}
|
|
||||||
|
|
||||||
out_file.flush().map_err(StorageError::Io)?;
|
out_file.flush().map_err(StorageError::Io)?;
|
||||||
let mut composite_hasher = Md5::new();
|
let mut composite_hasher = Md5::new();
|
||||||
composite_hasher.update(&md5_digest_concat);
|
composite_hasher.update(&md5_digest_concat);
|
||||||
let etag = format!("{:x}-{}", composite_hasher.finalize(), part_infos.len());
|
let etag = format!("{:x}-{}", composite_hasher.finalize(), part_infos.len());
|
||||||
Ok((etag, total_size))
|
Ok((etag, total_size, part_sizes))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let (etag, total_size) = match assemble_res {
|
let (etag, total_size, part_sizes) = match assemble_res {
|
||||||
Ok(Ok(v)) => v,
|
Ok(Ok(v)) => v,
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
let _ = std::fs::remove_file(&tmp_path);
|
let _ = std::fs::remove_file(&tmp_path);
|
||||||
@@ -3408,6 +3458,9 @@ impl crate::traits::StorageEngine for FsStorageBackend {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut metadata = metadata;
|
||||||
|
metadata.insert(META_KEY_PART_SIZES.to_string(), encode_part_sizes(&part_sizes));
|
||||||
|
|
||||||
// Commit to the destination key atomically under its write lock.
|
// Commit to the destination key atomically under its write lock.
|
||||||
// Lock acquisition happens inside run_blocking so the wait runs under
|
// Lock acquisition happens inside run_blocking so the wait runs under
|
||||||
// block_in_place rather than parking the async worker.
|
// block_in_place rather than parking the async worker.
|
||||||
@@ -4033,6 +4086,117 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_part_sizes_roundtrip() {
|
||||||
|
let sizes = vec![5_242_880, 5_242_880, 5_242_880, 12_345];
|
||||||
|
let encoded = encode_part_sizes(&sizes);
|
||||||
|
assert_eq!(encoded, "5242880,5242880,5242880,12345");
|
||||||
|
let parsed = parse_part_sizes(&encoded).unwrap();
|
||||||
|
assert_eq!(parsed, sizes);
|
||||||
|
assert!(parse_part_sizes("").is_none());
|
||||||
|
assert!(parse_part_sizes(",,,").is_none());
|
||||||
|
assert!(parse_part_sizes("abc").is_none());
|
||||||
|
assert!(parse_part_sizes("123,abc").is_none());
|
||||||
|
assert!(parse_part_sizes(" ").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_delete_object_clears_poisoned_metadata() {
|
||||||
|
let (_dir, backend) = create_test_backend();
|
||||||
|
backend.create_bucket("test-bucket").await.unwrap();
|
||||||
|
|
||||||
|
let data: AsyncReadStream = Box::pin(std::io::Cursor::new(b"will rot".to_vec()));
|
||||||
|
backend
|
||||||
|
.put_object("test-bucket", "rot.txt", data, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut meta = backend
|
||||||
|
.get_object_metadata("test-bucket", "rot.txt")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
meta.insert(META_KEY_CORRUPTED.to_string(), "true".to_string());
|
||||||
|
backend
|
||||||
|
.put_object_metadata("test-bucket", "rot.txt", &meta)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let live_path = backend
|
||||||
|
.get_object_path("test-bucket", "rot.txt")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
std::fs::remove_file(&live_path).unwrap();
|
||||||
|
|
||||||
|
backend
|
||||||
|
.delete_object("test-bucket", "rot.txt")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match backend.head_object("test-bucket", "rot.txt").await {
|
||||||
|
Err(StorageError::ObjectNotFound { .. }) => {}
|
||||||
|
other => panic!(
|
||||||
|
"after DELETE on a poisoned/quarantined object, HEAD should be ObjectNotFound, got {:?}",
|
||||||
|
other
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
let leftover = backend
|
||||||
|
.get_object_metadata("test-bucket", "rot.txt")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
leftover.is_empty(),
|
||||||
|
"metadata sidecar must be cleared after DELETE on poisoned object"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_complete_multipart_persists_part_sizes() {
|
||||||
|
let (_dir, backend) = create_test_backend();
|
||||||
|
backend.create_bucket("mp-bucket").await.unwrap();
|
||||||
|
|
||||||
|
let upload_id = backend
|
||||||
|
.initiate_multipart("mp-bucket", "obj.bin", None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let part1: AsyncReadStream = Box::pin(std::io::Cursor::new(vec![b'A'; 1024]));
|
||||||
|
backend
|
||||||
|
.upload_part("mp-bucket", &upload_id, 1, part1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let part2: AsyncReadStream = Box::pin(std::io::Cursor::new(vec![b'B'; 512]));
|
||||||
|
backend
|
||||||
|
.upload_part("mp-bucket", &upload_id, 2, part2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let parts = vec![
|
||||||
|
PartInfo {
|
||||||
|
part_number: 1,
|
||||||
|
etag: String::new(),
|
||||||
|
},
|
||||||
|
PartInfo {
|
||||||
|
part_number: 2,
|
||||||
|
etag: String::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let obj = backend
|
||||||
|
.complete_multipart("mp-bucket", &upload_id, &parts)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(obj.size, 1536);
|
||||||
|
|
||||||
|
let stored = backend
|
||||||
|
.get_object_metadata("mp-bucket", "obj.bin")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let raw = stored
|
||||||
|
.get(META_KEY_PART_SIZES)
|
||||||
|
.expect("part sizes must be persisted on completion");
|
||||||
|
assert_eq!(parse_part_sizes(raw).unwrap(), vec![1024u64, 512u64]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_put_clears_poison_flag() {
|
async fn test_put_clears_poison_flag() {
|
||||||
let (_dir, backend) = create_test_backend();
|
let (_dir, backend) = create_test_backend();
|
||||||
|
|||||||
Reference in New Issue
Block a user