@ -1118,6 +1118,87 @@ enum FileUploadType {
// Azure = 1, // only used upstream
// Azure = 1, // only used upstream
}
}
#[ derive(Debug, Clone, Copy, PartialEq, Eq) ]
enum AttachmentQuotaError {
Disabled ,
Overflow ,
LimitReached ,
}
/// Computes remaining attachment quota in bytes.
///
/// `limit_kb` is the configured limit in kilobytes (`None` = unlimited).
/// `size_adjust` credits bytes already reserved in the database (v2 metadata row).
fn attachment_quota_remaining (
limit_kb : Option < i64 > ,
already_used : i64 ,
size_adjust : i64 ,
) -> Result < Option < i64 > , AttachmentQuotaError > {
match limit_kb {
Some ( 0 ) = > Err ( AttachmentQuotaError ::Disabled ) ,
Some ( limit_kb ) = > {
let Some ( left ) = limit_kb
. checked_mul ( 1024 )
. and_then ( | l | l . checked_sub ( already_used ) )
. and_then ( | l | l . checked_add ( size_adjust ) )
else {
return Err ( AttachmentQuotaError ::Overflow ) ;
} ;
if left < = 0 {
Err ( AttachmentQuotaError ::LimitReached )
} else {
Ok ( Some ( left ) )
}
}
None = > Ok ( None ) ,
}
}
fn proposed_exceeds_attachment_quota ( remaining : Option < i64 > , proposed_size : i64 ) -> bool {
remaining . is_some_and ( | left | proposed_size > left )
}
/// Returns the remaining attachment storage in bytes for the cipher's owner, or `None` if unlimited.
///
/// `size_adjust` credits bytes already reserved in the database (v2 metadata row) so they are not
/// double-counted against the limit.
async fn attachment_size_limit (
cipher : & Cipher ,
size_adjust : i64 ,
conn : & DbConn ,
) -> Result < Option < i64 > , crate ::error ::Error > {
if let Some ( ref user_id ) = cipher . user_uuid {
match attachment_quota_remaining (
CONFIG . user_attachment_limit ( ) ,
Attachment ::size_by_user ( user_id , conn ) . await ,
size_adjust ,
) {
Ok ( remaining ) = > Ok ( remaining ) ,
Err ( AttachmentQuotaError ::Disabled ) = > err ! ( "Attachments are disabled" ) ,
Err ( AttachmentQuotaError ::Overflow ) = > err ! ( "Attachment size overflow" ) ,
Err ( AttachmentQuotaError ::LimitReached ) = > {
err ! ( "Attachment storage limit reached! Delete some attachments to free up space" )
}
}
} else if let Some ( ref org_id ) = cipher . organization_uuid {
match attachment_quota_remaining (
CONFIG . org_attachment_limit ( ) ,
Attachment ::size_by_org ( org_id , conn ) . await ,
size_adjust ,
) {
Ok ( remaining ) = > Ok ( remaining ) ,
Err ( AttachmentQuotaError ::Disabled ) = > err ! ( "Attachments are disabled" ) ,
Err ( AttachmentQuotaError ::Overflow ) = > err ! ( "Attachment size overflow" ) ,
Err ( AttachmentQuotaError ::LimitReached ) = > {
err ! ( "Attachment storage limit reached! Delete some attachments to free up space" )
}
}
} else {
err ! ( "Cipher is neither owned by a user nor an organization" ) ;
}
}
/// v2 API for creating an attachment associated with a cipher.
/// v2 API for creating an attachment associated with a cipher.
/// This redirects the client to the API it should use to upload the attachment.
/// This redirects the client to the API it should use to upload the attachment.
/// For upstream's cloud-hosted service, it's an Azure object storage API.
/// For upstream's cloud-hosted service, it's an Azure object storage API.
@ -1143,6 +1224,11 @@ async fn post_attachment_v2(
if file_size < 0 {
if file_size < 0 {
err ! ( "Attachment size can't be negative" )
err ! ( "Attachment size can't be negative" )
}
}
if proposed_exceeds_attachment_quota ( attachment_size_limit ( & cipher , 0 , & conn ) . await ? , file_size ) {
err ! ( "Attachment storage limit exceeded with this file" ) ;
}
let attachment_id = crypto ::generate_attachment_id ( ) ;
let attachment_id = crypto ::generate_attachment_id ( ) ;
let attachment =
let attachment =
Attachment ::new ( attachment_id . clone ( ) , cipher . uuid . clone ( ) , data . file_name , file_size , Some ( data . key ) ) ;
Attachment ::new ( attachment_id . clone ( ) , cipher . uuid . clone ( ) , data . file_name , file_size , Some ( data . key ) ) ;
@ -1209,57 +1295,9 @@ async fn save_attachment(
Some ( a ) = > a . file_size , // v2 API
Some ( a ) = > a . file_size , // v2 API
} ;
} ;
let size_limit = if let Some ( ref user_id ) = cipher . user_uuid {
let size_limit = attachment_size_limit ( & cipher , size_adjust , & conn ) . await ? ;
match CONFIG . user_attachment_limit ( ) {
Some ( 0 ) = > err ! ( "Attachments are disabled" ) ,
Some ( limit_kb ) = > {
let already_used = Attachment ::size_by_user ( user_id , & conn ) . await ;
let left = limit_kb
. checked_mul ( 1024 )
. and_then ( | l | l . checked_sub ( already_used ) )
. and_then ( | l | l . checked_add ( size_adjust ) ) ;
let Some ( left ) = left else {
err ! ( "Attachment size overflow" ) ;
} ;
if left < = 0 {
err ! ( "Attachment storage limit reached! Delete some attachments to free up space" )
}
Some ( left )
}
None = > None ,
}
} else if let Some ( ref org_id ) = cipher . organization_uuid {
match CONFIG . org_attachment_limit ( ) {
Some ( 0 ) = > err ! ( "Attachments are disabled" ) ,
Some ( limit_kb ) = > {
let already_used = Attachment ::size_by_org ( org_id , & conn ) . await ;
let left = limit_kb
. checked_mul ( 1024 )
. and_then ( | l | l . checked_sub ( already_used ) )
. and_then ( | l | l . checked_add ( size_adjust ) ) ;
let Some ( left ) = left else {
err ! ( "Attachment size overflow" ) ;
} ;
if left < = 0 {
err ! ( "Attachment storage limit reached! Delete some attachments to free up space" )
}
Some ( left )
}
None = > None ,
}
} else {
err ! ( "Cipher is neither owned by a user nor an organization" ) ;
} ;
if let Some ( size_limit ) = size_limit
if proposed_exceeds_attachment_quota ( size_limit , size ) {
& & size > size_limit
{
err ! ( "Attachment storage limit exceeded with this file" ) ;
err ! ( "Attachment storage limit exceeded with this file" ) ;
}
}
@ -2212,3 +2250,62 @@ impl CipherSyncData {
}
}
}
}
}
}
#[ cfg(test) ]
mod attachment_quota_tests {
use super ::{ AttachmentQuotaError , attachment_quota_remaining , proposed_exceeds_attachment_quota } ;
#[ test ]
fn unlimited_when_limit_unset ( ) {
assert_eq ! ( attachment_quota_remaining ( None , 0 , 0 ) , Ok ( None ) ) ;
assert_eq ! ( attachment_quota_remaining ( None , 1_000_000 , 0 ) , Ok ( None ) ) ;
}
#[ test ]
fn disabled_when_limit_zero ( ) {
assert_eq ! ( attachment_quota_remaining ( Some ( 0 ) , 0 , 0 ) , Err ( AttachmentQuotaError ::Disabled ) ) ;
}
#[ test ]
fn remaining_bytes_from_limit_and_usage ( ) {
// 1 KiB limit, nothing used
assert_eq ! ( attachment_quota_remaining ( Some ( 1 ) , 0 , 0 ) , Ok ( Some ( 1024 ) ) ) ;
// 1 KiB limit, 512 bytes used
assert_eq ! ( attachment_quota_remaining ( Some ( 1 ) , 512 , 0 ) , Ok ( Some ( 512 ) ) ) ;
}
#[ test ]
fn limit_reached_when_quota_exhausted ( ) {
assert_eq ! ( attachment_quota_remaining ( Some ( 1 ) , 1024 , 0 ) , Err ( AttachmentQuotaError ::LimitReached ) ) ;
assert_eq ! ( attachment_quota_remaining ( Some ( 1 ) , 2048 , 0 ) , Err ( AttachmentQuotaError ::LimitReached ) ) ;
}
#[ test ]
fn size_adjust_credits_v2_reservation ( ) {
// 1 KiB quota fully used in DB, but upload path credits the reserved row.
assert_eq ! ( attachment_quota_remaining ( Some ( 1 ) , 1024 , 512 ) , Ok ( Some ( 512 ) ) ) ;
// v2 create path uses size_adjust = 0, so a full quota blocks new reservations.
assert_eq ! ( attachment_quota_remaining ( Some ( 1 ) , 1024 , 0 ) , Err ( AttachmentQuotaError ::LimitReached ) ) ;
}
#[ test ]
fn overflow_on_arithmetic_wrap ( ) {
assert_eq ! ( attachment_quota_remaining ( Some ( i64 ::MAX ) , i64 ::MIN , 0 ) , Err ( AttachmentQuotaError ::Overflow ) ) ;
}
#[ test ]
fn v2_metadata_reservation_rejects_over_quota_declared_size ( ) {
let remaining = attachment_quota_remaining ( Some ( 100 ) , 0 , 0 ) . expect ( "quota available" ) ;
let remaining = remaining . expect ( "limited quota" ) ;
assert ! ( ! proposed_exceeds_attachment_quota ( Some ( remaining ) , remaining ) ) ;
assert ! ( proposed_exceeds_attachment_quota ( Some ( remaining ) , remaining + 1 ) ) ;
}
#[ test ]
fn unlimited_quota_never_rejects_declared_size ( ) {
assert ! ( ! proposed_exceeds_attachment_quota ( None , i64 ::MAX ) ) ;
}
}