use std::collections::BTreeSet;
pub use trust_tasks_rs::specs::external::credentials::issue::v0_2::{PresignBatch, PresignRequest};
use crate::model::{Binding, ScopeCeiling};
use crate::scope::{validate_object_key, validate_prefix};
pub const MAX_OPERATIONS: usize = 128;
pub const MAX_OPERATIONS_0_3: usize = 64;
pub fn operation_name(op: &PresignRequest) -> &'static str {
match op {
PresignRequest::Put { .. } => "put",
PresignRequest::Get => "get",
PresignRequest::Head => "head",
PresignRequest::Delete => "delete",
PresignRequest::CreateMultipart => "create-multipart",
PresignRequest::UploadPart { .. } => "upload-part",
PresignRequest::ListParts { .. } => "list-parts",
PresignRequest::CompleteMultipart { .. } => "complete-multipart",
PresignRequest::AbortMultipart => "abort-multipart",
_ => "unknown",
}
}
pub fn ceiling_action(op: &PresignRequest) -> Option<&'static str> {
match operation_name(op) {
"put" | "create-multipart" | "upload-part" | "list-parts" | "complete-multipart" => {
Some("put")
}
"get" | "head" => Some("get"),
"delete" | "abort-multipart" => Some("delete"),
_ => None,
}
}
pub fn needs_upload_id(op: &PresignRequest) -> bool {
matches!(
operation_name(op),
"upload-part" | "list-parts" | "complete-multipart" | "abort-multipart"
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchRefusal {
Invalid(String),
OutsideCeiling(String),
TtlTooLong { requested: u32, max: u32 },
}
impl std::fmt::Display for BatchRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid(m) | Self::OutsideCeiling(m) => f.write_str(m),
Self::TtlTooLong { requested, max } => write!(
f,
"ttlSeconds {requested} exceeds the binding's maxTtlSeconds {max}"
),
}
}
}
pub fn validate_checksum(c: &str) -> bool {
c.len() == 44
&& c.ends_with('=')
&& c[..43]
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '/')
}
pub fn validate_upload_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 1024
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._~+/=-".contains(c))
}
pub fn check_batch(
binding: &Binding,
batch: &PresignBatch,
ttl_seconds: u32,
) -> Result<(), BatchRefusal> {
let invalid = |why: String| Err(BatchRefusal::Invalid(why));
let prefix = batch.prefix.as_str();
let object = batch.object_key.as_str();
validate_prefix(prefix).map_err(|e| BatchRefusal::Invalid(e.to_string()))?;
validate_object_key(object).map_err(|e| BatchRefusal::Invalid(e.to_string()))?;
if batch.requests.is_empty() || batch.requests.len() > MAX_OPERATIONS {
return invalid(format!(
"a batch holds 1 to {MAX_OPERATIONS} operations, not {}",
batch.requests.len()
));
}
let upload_id = batch.upload_id.as_ref().map(|u| u.as_str());
if let Some(id) = upload_id
&& !validate_upload_id(id)
{
return invalid("uploadId is not one storage could have returned".into());
}
let any_needs = batch.requests.iter().any(needs_upload_id);
let creates = batch
.requests
.iter()
.any(|op| matches!(op, PresignRequest::CreateMultipart));
if any_needs && upload_id.is_none() {
return invalid("a multipart operation needs the batch's uploadId".into());
}
if upload_id.is_some() && creates {
return invalid("a batch that creates a multipart upload names no uploadId".into());
}
if upload_id.is_some() && !any_needs {
return invalid("uploadId is given, but no operation in the batch uses it".into());
}
let mut seen = BTreeSet::new();
let mut parts = BTreeSet::new();
for op in &batch.requests {
if ceiling_action(op).is_none() {
return invalid(format!("unknown operation {}", operation_name(op)));
}
let canonical = serde_json::to_string(op).unwrap_or_default();
if !seen.insert(canonical) {
return invalid(format!(
"the batch names the same {} operation twice",
operation_name(op)
));
}
match op {
PresignRequest::Put {
checksum_sha256,
content_length,
} => {
if !validate_checksum(checksum_sha256.as_str()) {
return invalid("a put's checksumSha256 is not a SHA-256".into());
}
if !(0..=5 * 1024 * 1024 * 1024).contains(content_length) {
return invalid("a put's contentLength is outside 0 to 5 GiB".into());
}
}
PresignRequest::UploadPart {
checksum_sha256,
content_length,
part_number,
} => {
if !validate_checksum(checksum_sha256.as_str()) {
return invalid("an upload-part's checksumSha256 is not a SHA-256".into());
}
if part_number.get() > 10_000 || content_length.get() > 5 * 1024 * 1024 * 1024 {
return invalid("an upload-part is outside S3's part bounds".into());
}
if !parts.insert(part_number.get()) {
return invalid(format!(
"two upload-part operations name part {part_number}"
));
}
}
PresignRequest::ListParts { part_number_marker }
if part_number_marker.is_some_and(|m| !(0..=10_000).contains(&m)) =>
{
return invalid("partNumberMarker is outside 0 to 10000".into());
}
PresignRequest::CompleteMultipart { content_length }
if !(0..=5 * 1024 * 1024 * 1024 * 1024).contains(content_length) =>
{
return invalid("complete-multipart's contentLength is outside 0 to 5 TiB".into());
}
_ => {}
}
}
let empty = ScopeCeiling::default();
let ceiling = binding.scope_ceiling.as_ref().unwrap_or(&empty);
if !ceiling
.prefixes
.iter()
.any(|p| prefix.starts_with(p.as_str()))
{
return Err(BatchRefusal::OutsideCeiling(format!(
"prefix {prefix:?} is under none of the binding's prefixes"
)));
}
for op in &batch.requests {
let action = ceiling_action(op).expect("checked above");
if !ceiling.actions.iter().any(|a| a == action) {
return Err(BatchRefusal::OutsideCeiling(format!(
"{} needs the ceiling action {action:?}, which the binding does not grant",
operation_name(op)
)));
}
}
if ttl_seconds > binding.max_ttl_seconds {
return Err(BatchRefusal::TtlTooLong {
requested: ttl_seconds,
max: binding.max_ttl_seconds,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn binding(actions: &[&str]) -> Binding {
Binding {
consumer: "did:key:z6Mkc".into(),
scope_ceiling: Some(ScopeCeiling {
prefixes: vec!["rooms/".into()],
actions: actions.iter().map(|a| a.to_string()).collect(),
scopes: vec![],
}),
max_ttl_seconds: 300,
rate_per_minute: 10,
source_cidrs: vec![],
granted_at: None,
}
}
fn batch(v: serde_json::Value) -> PresignBatch {
serde_json::from_value(v).unwrap()
}
const SUM: &str = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
#[test]
fn upload_id_is_present_exactly_when_an_operation_needs_it() {
let b = binding(&["put", "get", "delete"]);
let part = json!({"operation": "upload-part", "partNumber": 1, "contentLength": 5, "checksumSha256": SUM});
let no_id = batch(json!({"prefix": "rooms/x/", "objectKey": "o", "requests": [part]}));
assert!(matches!(
check_batch(&b, &no_id, 300),
Err(BatchRefusal::Invalid(_))
));
let create_with_id = batch(
json!({"prefix": "rooms/x/", "objectKey": "o", "uploadId": "u1",
"requests": [{"operation": "create-multipart"}]}),
);
assert!(matches!(
check_batch(&b, &create_with_id, 300),
Err(BatchRefusal::Invalid(_))
));
let unused_id = batch(
json!({"prefix": "rooms/x/", "objectKey": "o", "uploadId": "u1",
"requests": [{"operation": "head"}]}),
);
assert!(check_batch(&b, &unused_id, 300).is_err());
let ok = batch(
json!({"prefix": "rooms/x/", "objectKey": "o", "uploadId": "u1",
"requests": [part, {"operation": "list-parts"}, {"operation": "abort-multipart"}]}),
);
check_batch(&b, &ok, 300).unwrap();
}
#[test]
fn duplicates_and_repeated_parts_are_invalid() {
let b = binding(&["put", "get", "delete"]);
let twice = batch(json!({"prefix": "rooms/x/", "objectKey": "o",
"requests": [{"operation": "head"}, {"operation": "head"}]}));
assert!(matches!(
check_batch(&b, &twice, 300),
Err(BatchRefusal::Invalid(_))
));
let same_part = batch(
json!({"prefix": "rooms/x/", "objectKey": "o", "uploadId": "u",
"requests": [
{"operation": "upload-part", "partNumber": 2, "contentLength": 5, "checksumSha256": SUM},
{"operation": "upload-part", "partNumber": 2, "contentLength": 6, "checksumSha256": SUM},
]}),
);
assert!(matches!(
check_batch(&b, &same_part, 300),
Err(BatchRefusal::Invalid(_))
));
}
#[test]
fn each_operation_needs_its_ceiling_action() {
let put_only = binding(&["put"]);
let abort = batch(
json!({"prefix": "rooms/x/", "objectKey": "o", "uploadId": "u",
"requests": [{"operation": "abort-multipart"}]}),
);
assert!(matches!(
check_batch(&put_only, &abort, 300),
Err(BatchRefusal::OutsideCeiling(_))
));
let head = batch(
json!({"prefix": "rooms/x/", "objectKey": "o", "requests": [{"operation": "head"}]}),
);
assert!(matches!(
check_batch(&put_only, &head, 300),
Err(BatchRefusal::OutsideCeiling(_))
));
let create = batch(
json!({"prefix": "rooms/x/", "objectKey": "o", "requests": [{"operation": "create-multipart"}]}),
);
check_batch(&put_only, &create, 300).unwrap();
let elsewhere = batch(
json!({"prefix": "other/", "objectKey": "o", "requests": [{"operation": "create-multipart"}]}),
);
assert!(matches!(
check_batch(&put_only, &elsewhere, 300),
Err(BatchRefusal::OutsideCeiling(_))
));
assert!(matches!(
check_batch(&put_only, &create, 301),
Err(BatchRefusal::TtlTooLong { .. })
));
}
}