use std::num::NonZeroU32;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::data_directory::quota_config::BandwidthQuota;
use crate::data_directory::DefaultQuotasToml;
use crate::shared::webdav::WebDavPath;
pub const MAX_RATE_COLUMN_LEN: usize = 32;
const DB_UNLIMITED_SENTINEL: i32 = -1;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum QuotaOverride<T> {
#[default]
Default,
Unlimited,
Value(T),
}
impl<T> QuotaOverride<T> {
pub fn is_default(&self) -> bool {
matches!(self, QuotaOverride::Default)
}
#[cfg(test)]
pub fn is_unlimited(&self) -> bool {
matches!(self, QuotaOverride::Unlimited)
}
#[cfg(test)]
pub fn as_value(&self) -> Option<&T> {
match self {
QuotaOverride::Value(v) => Some(v),
_ => None,
}
}
}
impl<T: Serialize> Serialize for QuotaOverride<T> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
QuotaOverride::Default => serializer.serialize_none(),
QuotaOverride::Unlimited => serializer.serialize_str("unlimited"),
QuotaOverride::Value(v) => v.serialize(serializer),
}
}
}
impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for QuotaOverride<T> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
match &value {
serde_json::Value::Null => Ok(QuotaOverride::Default),
serde_json::Value::String(s) if s == "unlimited" => Ok(QuotaOverride::Unlimited),
_ => serde_json::from_value(value)
.map(QuotaOverride::Value)
.map_err(serde::de::Error::custom),
}
}
}
impl QuotaOverride<u64> {
pub fn to_db_int(&self) -> Option<i32> {
match self {
QuotaOverride::Default => None,
QuotaOverride::Unlimited => Some(DB_UNLIMITED_SENTINEL),
QuotaOverride::Value(v) => Some(i32::try_from(*v).unwrap_or(i32::MAX)),
}
}
pub fn from_db_int(column: &str, val: Option<i32>) -> Self {
match val {
None => QuotaOverride::Default,
Some(DB_UNLIMITED_SENTINEL) => QuotaOverride::Unlimited,
Some(v) if v >= 0 => QuotaOverride::Value(v as u64),
Some(v) => {
tracing::warn!("Unexpected {column} ({v}) in DB; treating as Default");
QuotaOverride::Default
}
}
}
pub fn resolve_with_default(&self, system_default: Option<u64>) -> Option<u64> {
match self {
QuotaOverride::Default => system_default,
QuotaOverride::Unlimited => None,
QuotaOverride::Value(v) => Some(*v),
}
}
}
impl QuotaOverride<BandwidthQuota> {
pub fn resolve_with_default(
&self,
system_default: Option<&BandwidthQuota>,
) -> Option<BandwidthQuota> {
match self {
QuotaOverride::Default => system_default.cloned(),
QuotaOverride::Unlimited => None,
QuotaOverride::Value(v) => Some(v.clone()),
}
}
pub fn to_db_varchar(&self) -> Option<String> {
match self {
QuotaOverride::Default => None,
QuotaOverride::Unlimited => Some("unlimited".to_string()),
QuotaOverride::Value(v) => Some(v.to_string()),
}
}
pub fn from_db_varchar(column: &str, val: Option<String>) -> Self {
match val {
None => QuotaOverride::Default,
Some(s) if s == "unlimited" => QuotaOverride::Unlimited,
Some(s) => match s.parse() {
Ok(rate) => QuotaOverride::Value(rate),
Err(e) => {
tracing::warn!("Invalid {column} \"{s}\" in DB: {e}; treating as Default");
QuotaOverride::Default
}
},
}
}
}
fn burst_to_i32(label: &str, value: Option<NonZeroU32>) -> Option<i32> {
value.map(|v| {
let v = v.get();
i32::try_from(v).unwrap_or_else(|_| {
tracing::warn!("{label} {v} exceeds i32::MAX, truncating to {}", i32::MAX);
i32::MAX
})
})
}
fn validate_rate_value(label: &str, field: &QuotaOverride<BandwidthQuota>) -> Result<(), String> {
if let QuotaOverride::Value(ref b) = field {
let s = b.to_string();
if s.len() > MAX_RATE_COLUMN_LEN {
return Err(format!(
"{label} string \"{s}\" exceeds DB column limit of {MAX_RATE_COLUMN_LEN} characters"
));
}
s.parse::<BandwidthQuota>()
.map_err(|e| format!("{label} roundtrip validation failed for \"{s}\": {e}"))?;
}
Ok(())
}
fn validate_burst_value(label: &str, burst: Option<NonZeroU32>) -> Result<(), String> {
if let Some(b) = burst {
if b.get() > i32::MAX as u32 {
return Err(format!("{label} value {b} exceeds maximum ({})", i32::MAX));
}
}
Ok(())
}
fn validate_burst(
label: &str,
burst: Option<NonZeroU32>,
rate: &QuotaOverride<BandwidthQuota>,
) -> Result<(), String> {
if burst.is_some() && !matches!(rate, QuotaOverride::Value(_)) {
return Err(format!(
"{label} requires the corresponding rate to be set to a value"
));
}
validate_burst_value(label, burst)
}
fn validate_allowed_write_paths(paths: &Option<Vec<WebDavPath>>) -> Result<(), String> {
if let Some(ref entries) = paths {
let mut seen = std::collections::HashSet::new();
for (i, p) in entries.iter().enumerate() {
if p.as_str() == "/" {
return Err(format!(
"allowed_write_paths[{i}] must not be '/'; use null for unrestricted access"
));
}
if !seen.insert(p) {
return Err(format!("allowed_write_paths[{i}] is a duplicate: \"{p}\""));
}
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct UserQuota {
#[serde(default, skip_serializing_if = "QuotaOverride::is_default")]
pub storage_quota_mb: QuotaOverride<u64>,
#[serde(default, skip_serializing_if = "QuotaOverride::is_default")]
pub rate_read: QuotaOverride<BandwidthQuota>,
#[serde(default, skip_serializing_if = "QuotaOverride::is_default")]
pub rate_write: QuotaOverride<BandwidthQuota>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_read_burst: Option<NonZeroU32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_write_burst: Option<NonZeroU32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_write_paths: Option<Vec<WebDavPath>>,
}
impl UserQuota {
pub fn from_nullable_columns(
storage_quota_mb: Option<i32>,
rate_read: Option<String>,
rate_write: Option<String>,
rate_read_burst: Option<i32>,
rate_write_burst: Option<i32>,
allowed_write_paths: Option<String>,
) -> Self {
let allowed_write_paths = allowed_write_paths.map(|s| {
serde_json::from_str::<Vec<WebDavPath>>(&s).unwrap_or_else(|e| {
tracing::error!(
"Invalid allowed_write_paths JSON in DB: {e}; falling back to read-only"
);
vec![]
})
});
Self {
storage_quota_mb: QuotaOverride::<u64>::from_db_int(
"quota_storage_mb",
storage_quota_mb,
),
rate_read: QuotaOverride::<BandwidthQuota>::from_db_varchar("rate_read", rate_read),
rate_write: QuotaOverride::<BandwidthQuota>::from_db_varchar("rate_write", rate_write),
rate_read_burst: rate_read_burst.and_then(|v| u32::try_from(v).ok()?.try_into().ok()),
rate_write_burst: rate_write_burst.and_then(|v| u32::try_from(v).ok()?.try_into().ok()),
allowed_write_paths,
}
}
pub fn storage_quota_mb_i32(&self) -> Option<i32> {
self.storage_quota_mb.to_db_int()
}
pub fn rate_read_str(&self) -> Option<String> {
self.rate_read.to_db_varchar()
}
pub fn rate_write_str(&self) -> Option<String> {
self.rate_write.to_db_varchar()
}
pub fn rate_read_burst_i32(&self) -> Option<i32> {
burst_to_i32("rate_read_burst", self.rate_read_burst)
}
pub fn rate_write_burst_i32(&self) -> Option<i32> {
burst_to_i32("rate_write_burst", self.rate_write_burst)
}
pub fn allowed_write_paths_db(&self) -> Result<Option<String>, serde_json::Error> {
self.allowed_write_paths
.as_ref()
.map(serde_json::to_string)
.transpose()
}
pub fn is_write_path_allowed(&self, path: &str) -> bool {
match &self.allowed_write_paths {
None => true,
Some(entries) => entries.iter().any(|entry| {
if entry.is_directory() {
path.starts_with(entry.as_str())
} else {
path == entry.as_str()
}
}),
}
}
pub fn resolve_with_defaults(
&self,
default_storage_mb: Option<u64>,
default_quotas: &DefaultQuotasToml,
) -> Self {
fn resolve_u64(field: &QuotaOverride<u64>, default: Option<u64>) -> QuotaOverride<u64> {
match field {
QuotaOverride::Default => match default {
Some(v) => QuotaOverride::Value(v),
None => QuotaOverride::Unlimited,
},
other => other.clone(),
}
}
fn resolve_bw(
field: &QuotaOverride<BandwidthQuota>,
default: Option<&BandwidthQuota>,
) -> QuotaOverride<BandwidthQuota> {
match field {
QuotaOverride::Default => match default {
Some(v) => QuotaOverride::Value(v.clone()),
None => QuotaOverride::Unlimited,
},
other => other.clone(),
}
}
Self {
storage_quota_mb: resolve_u64(&self.storage_quota_mb, default_storage_mb),
rate_read: resolve_bw(&self.rate_read, default_quotas.rate_read.as_ref()),
rate_write: resolve_bw(&self.rate_write, default_quotas.rate_write.as_ref()),
rate_read_burst: self.rate_read_burst.or(if self.rate_read.is_default() {
default_quotas.rate_read_burst
} else {
None
}),
rate_write_burst: self.rate_write_burst.or(if self.rate_write.is_default() {
default_quotas.rate_write_burst
} else {
None
}),
allowed_write_paths: self.allowed_write_paths.clone(),
}
}
pub fn validate(&self) -> Result<(), String> {
validate_rate_value("rate_read", &self.rate_read)?;
validate_rate_value("rate_write", &self.rate_write)?;
validate_burst("rate_read_burst", self.rate_read_burst, &self.rate_read)?;
validate_burst("rate_write_burst", self.rate_write_burst, &self.rate_write)?;
validate_allowed_write_paths(&self.allowed_write_paths)?;
Ok(())
}
pub fn merge(&mut self, patch: &UserQuotaPatch) {
if let Some(ref v) = patch.storage_quota_mb {
self.storage_quota_mb = v.clone();
}
if let Some(ref v) = patch.rate_read {
self.rate_read = v.clone();
}
if let Some(ref v) = patch.rate_write {
self.rate_write = v.clone();
}
if let Some(v) = patch.rate_read_burst {
self.rate_read_burst = v;
}
if let Some(v) = patch.rate_write_burst {
self.rate_write_burst = v;
}
if let Some(ref v) = patch.allowed_write_paths {
self.allowed_write_paths = v.clone();
}
}
}
fn deserialize_patch_override<'de, T, D>(d: D) -> Result<Option<QuotaOverride<T>>, D::Error>
where
T: serde::de::DeserializeOwned,
D: Deserializer<'de>,
{
QuotaOverride::<T>::deserialize(d).map(Some)
}
fn deserialize_patch_allowed_write_paths<'de, D>(
d: D,
) -> Result<Option<Option<Vec<WebDavPath>>>, D::Error>
where
D: Deserializer<'de>,
{
Option::<Vec<WebDavPath>>::deserialize(d).map(Some)
}
fn deserialize_patch_option_non_zero_u32<'de, D>(
d: D,
) -> Result<Option<Option<NonZeroU32>>, D::Error>
where
D: Deserializer<'de>,
{
Option::<NonZeroU32>::deserialize(d).map(Some)
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct UserQuotaPatch {
#[serde(default, deserialize_with = "deserialize_patch_override")]
pub storage_quota_mb: Option<QuotaOverride<u64>>,
#[serde(default, deserialize_with = "deserialize_patch_override")]
pub rate_read: Option<QuotaOverride<BandwidthQuota>>,
#[serde(default, deserialize_with = "deserialize_patch_override")]
pub rate_write: Option<QuotaOverride<BandwidthQuota>>,
#[serde(default, deserialize_with = "deserialize_patch_option_non_zero_u32")]
pub rate_read_burst: Option<Option<NonZeroU32>>,
#[serde(default, deserialize_with = "deserialize_patch_option_non_zero_u32")]
pub rate_write_burst: Option<Option<NonZeroU32>>,
#[serde(default, deserialize_with = "deserialize_patch_allowed_write_paths")]
pub allowed_write_paths: Option<Option<Vec<WebDavPath>>>,
}
impl UserQuotaPatch {
pub fn validate(&self) -> Result<(), String> {
if let Some(ref field) = self.rate_read {
validate_rate_value("rate_read", field)?;
}
if let Some(ref field) = self.rate_write {
validate_rate_value("rate_write", field)?;
}
validate_burst_value("rate_read_burst", self.rate_read_burst.flatten())?;
validate_burst_value("rate_write_burst", self.rate_write_burst.flatten())?;
if let Some(ref inner) = self.allowed_write_paths {
validate_allowed_write_paths(inner)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
fn nz(value: u32) -> NonZeroU32 {
NonZeroU32::new(value).unwrap()
}
#[test]
fn test_quota_field_default() {
let field: QuotaOverride<BandwidthQuota> = QuotaOverride::default();
assert!(field.is_default());
assert!(!field.is_unlimited());
assert_eq!(field.as_value(), None);
}
#[test]
fn test_quota_field_unlimited() {
let field: QuotaOverride<BandwidthQuota> = QuotaOverride::Unlimited;
assert!(!field.is_default());
assert!(field.is_unlimited());
assert_eq!(field.as_value(), None);
}
#[test]
fn test_quota_field_value() {
let rate = BandwidthQuota::from_str("100mb/m").unwrap();
let field = QuotaOverride::Value(rate.clone());
assert!(!field.is_default());
assert!(!field.is_unlimited());
assert_eq!(field.as_value(), Some(&rate));
}
#[test]
fn test_varchar_roundtrip() {
assert_eq!(
QuotaOverride::<BandwidthQuota>::from_db_varchar("rate_read", None),
QuotaOverride::Default
);
assert_eq!(
QuotaOverride::<BandwidthQuota>::from_db_varchar(
"rate_read",
Some("unlimited".to_string())
),
QuotaOverride::Unlimited
);
assert_eq!(
QuotaOverride::<BandwidthQuota>::from_db_varchar(
"rate_read",
Some("100mb/m".to_string())
),
QuotaOverride::Value(BandwidthQuota::from_str("100mb/m").unwrap())
);
assert_eq!(
QuotaOverride::<BandwidthQuota>::from_db_varchar(
"rate_read",
Some("rubbish".to_string())
),
QuotaOverride::Default
);
assert_eq!(
QuotaOverride::<BandwidthQuota>::Default.to_db_varchar(),
None
);
assert_eq!(
QuotaOverride::<BandwidthQuota>::Unlimited.to_db_varchar(),
Some("unlimited".to_string())
);
assert_eq!(
QuotaOverride::Value(BandwidthQuota::from_str("100mb/m").unwrap()).to_db_varchar(),
Some("100mb/m".to_string())
);
}
#[test]
fn test_bigint_roundtrip() {
assert_eq!(
QuotaOverride::<u64>::from_db_int("quota_storage_mb", None),
QuotaOverride::Default
);
assert_eq!(
QuotaOverride::<u64>::from_db_int("quota_storage_mb", Some(-1)),
QuotaOverride::Unlimited
);
assert_eq!(
QuotaOverride::<u64>::from_db_int("quota_storage_mb", Some(500)),
QuotaOverride::Value(500)
);
assert_eq!(
QuotaOverride::<u64>::from_db_int("quota_storage_mb", Some(0)),
QuotaOverride::Value(0)
);
assert_eq!(
QuotaOverride::<u64>::from_db_int("quota_storage_mb", Some(-5)),
QuotaOverride::Default
);
assert_eq!(QuotaOverride::<u64>::Default.to_db_int(), None);
assert_eq!(QuotaOverride::<u64>::Unlimited.to_db_int(), Some(-1));
assert_eq!(QuotaOverride::Value(500u64).to_db_int(), Some(500));
}
#[test]
fn test_resolve_with_default() {
assert_eq!(
QuotaOverride::<u64>::Default.resolve_with_default(Some(500)),
Some(500)
);
assert_eq!(
QuotaOverride::<u64>::Default.resolve_with_default(None),
None
);
assert_eq!(
QuotaOverride::<u64>::Unlimited.resolve_with_default(Some(500)),
None
);
assert_eq!(
QuotaOverride::Value(200u64).resolve_with_default(Some(500)),
Some(200)
);
}
#[test]
fn test_from_nullable_columns_all_null() {
let q = UserQuota::from_nullable_columns(None, None, None, None, None, None);
assert_eq!(q, UserQuota::default());
}
#[test]
fn test_from_nullable_columns_with_values() {
let q = UserQuota::from_nullable_columns(
Some(500),
Some("100mb/m".to_string()),
None,
None,
None,
None,
);
assert_eq!(q.storage_quota_mb, QuotaOverride::Value(500));
assert_eq!(
q.rate_read,
QuotaOverride::Value(BandwidthQuota::from_str("100mb/m").unwrap())
);
assert_eq!(q.rate_write, QuotaOverride::Default);
}
#[test]
fn test_from_nullable_columns_unlimited_values() {
let q = UserQuota::from_nullable_columns(
Some(-1),
Some("unlimited".to_string()),
Some("unlimited".to_string()),
None,
None,
None,
);
assert_eq!(q.storage_quota_mb, QuotaOverride::Unlimited);
assert_eq!(q.rate_read, QuotaOverride::Unlimited);
assert_eq!(q.rate_write, QuotaOverride::Unlimited);
}
#[test]
fn test_from_nullable_columns_mixed() {
let q = UserQuota::from_nullable_columns(
None,
Some("10mb/s".to_string()),
None,
None,
None,
None,
);
assert_eq!(q.storage_quota_mb, QuotaOverride::Default);
assert_eq!(
q.rate_read,
QuotaOverride::Value(BandwidthQuota::from_str("10mb/s").unwrap())
);
assert_eq!(q.rate_write, QuotaOverride::Default);
}
#[test]
fn test_from_nullable_columns_invalid_rate_string() {
let q = UserQuota::from_nullable_columns(
None,
Some("rubbish".to_string()),
Some("100mb/m".to_string()),
None,
None,
None,
);
assert_eq!(q.rate_read, QuotaOverride::Default);
assert_eq!(
q.rate_write,
QuotaOverride::Value(BandwidthQuota::from_str("100mb/m").unwrap())
);
}
#[test]
fn test_from_nullable_columns_legacy_request_units() {
let q = UserQuota::from_nullable_columns(
None,
Some("100r/m".to_string()),
Some("50r/s".to_string()),
None,
None,
None,
);
assert_eq!(q.rate_read, QuotaOverride::Default);
assert_eq!(q.rate_write, QuotaOverride::Default);
}
#[test]
fn test_serde_roundtrip() {
let q = UserQuota {
storage_quota_mb: QuotaOverride::Value(500),
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("100mb/m").unwrap()),
rate_write: QuotaOverride::Unlimited,
..Default::default()
};
let json = serde_json::to_string(&q).unwrap();
let deserialized: UserQuota = serde_json::from_str(&json).unwrap();
assert_eq!(q, deserialized);
}
#[test]
fn test_serde_default_fields_omitted() {
let q = UserQuota {
storage_quota_mb: QuotaOverride::Value(500),
..Default::default()
};
let json = serde_json::to_string(&q).unwrap();
assert!(json.contains("storage_quota_mb"));
assert!(!json.contains("rate_read"));
assert!(!json.contains("rate_write"));
}
#[test]
fn test_serde_empty_json_is_all_default() {
let q: UserQuota = serde_json::from_str("{}").unwrap();
assert_eq!(q, UserQuota::default());
}
#[test]
fn test_serde_null_is_default_for_all() {
let json = r#"{"storage_quota_mb": null, "rate_read": null, "rate_write": null}"#;
let q: UserQuota = serde_json::from_str(json).unwrap();
assert_eq!(q, UserQuota::default());
}
#[test]
fn test_serde_unlimited_string() {
let json = r#"{"storage_quota_mb": "unlimited", "rate_read": "unlimited", "rate_write": "unlimited"}"#;
let q: UserQuota = serde_json::from_str(json).unwrap();
assert_eq!(q.storage_quota_mb, QuotaOverride::Unlimited);
assert_eq!(q.rate_read, QuotaOverride::Unlimited);
assert_eq!(q.rate_write, QuotaOverride::Unlimited);
}
#[test]
fn test_serde_unlimited_serializes_as_string() {
let q = UserQuota {
storage_quota_mb: QuotaOverride::Unlimited,
rate_read: QuotaOverride::Unlimited,
..Default::default()
};
let json = serde_json::to_string(&q).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["storage_quota_mb"], "unlimited");
assert_eq!(v["rate_read"], "unlimited");
}
#[test]
fn test_serde_absent_is_default() {
let json = r#"{"storage_quota_mb": 500}"#;
let q: UserQuota = serde_json::from_str(json).unwrap();
assert_eq!(q.storage_quota_mb, QuotaOverride::Value(500));
assert_eq!(q.rate_read, QuotaOverride::Default);
}
#[test]
fn test_serde_none_fields_omitted() {
let q = UserQuota::default();
let json = serde_json::to_string(&q).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn test_serde_rejects_invalid_rate_string() {
let json = r#"{"rate_read": "rubbish"}"#;
let result: Result<UserQuota, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"Invalid rate string should fail deserialization"
);
}
#[test]
fn test_validate_valid_rates() {
let budgets = ["100mb/m", "1gb/d", "500kb/s", "10mb/h", "999gb/d", "1kb/s"];
for s in budgets {
let q = UserQuota {
rate_read: QuotaOverride::Value(BandwidthQuota::from_str(s).unwrap()),
rate_write: QuotaOverride::Value(BandwidthQuota::from_str(s).unwrap()),
..Default::default()
};
q.validate().unwrap_or_else(|e| {
panic!("Budget \"{s}\" should pass validation but got: {e}");
});
}
}
#[test]
fn test_validate_skips_non_value() {
let q = UserQuota {
rate_read: QuotaOverride::Default,
rate_write: QuotaOverride::Unlimited,
..Default::default()
};
assert!(q.validate().is_ok());
}
#[test]
fn test_patch_empty_body_changes_nothing() {
let patch: UserQuotaPatch = serde_json::from_str("{}").unwrap();
assert!(patch.storage_quota_mb.is_none());
assert!(patch.rate_read.is_none());
assert!(patch.rate_write.is_none());
}
#[test]
fn test_patch_null_resets_to_default() {
let json = r#"{"rate_read": null, "storage_quota_mb": null}"#;
let patch: UserQuotaPatch = serde_json::from_str(json).unwrap();
assert_eq!(patch.storage_quota_mb, Some(QuotaOverride::Default));
assert_eq!(patch.rate_read, Some(QuotaOverride::Default));
assert!(patch.rate_write.is_none());
}
#[test]
fn test_patch_unlimited_string() {
let json = r#"{"storage_quota_mb": "unlimited", "rate_write": "unlimited"}"#;
let patch: UserQuotaPatch = serde_json::from_str(json).unwrap();
assert_eq!(patch.storage_quota_mb, Some(QuotaOverride::Unlimited));
assert_eq!(patch.rate_write, Some(QuotaOverride::Unlimited));
}
#[test]
fn test_patch_value_sets_value() {
let json = r#"{"storage_quota_mb": 500, "rate_write": "10mb/s"}"#;
let patch: UserQuotaPatch = serde_json::from_str(json).unwrap();
assert_eq!(patch.storage_quota_mb, Some(QuotaOverride::Value(500)));
assert_eq!(
patch.rate_write,
Some(QuotaOverride::Value(
BandwidthQuota::from_str("10mb/s").unwrap()
))
);
assert!(patch.rate_read.is_none());
}
#[test]
fn test_merge_applies_only_present_fields() {
let mut base = UserQuota {
storage_quota_mb: QuotaOverride::Value(500),
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("100mb/m").unwrap()),
rate_write: QuotaOverride::Value(BandwidthQuota::from_str("50mb/s").unwrap()),
..Default::default()
};
let patch: UserQuotaPatch =
serde_json::from_str(r#"{"storage_quota_mb": 200, "rate_write": "unlimited"}"#)
.unwrap();
base.merge(&patch);
assert_eq!(base.storage_quota_mb, QuotaOverride::Value(200));
assert_eq!(
base.rate_read,
QuotaOverride::Value(BandwidthQuota::from_str("100mb/m").unwrap())
); assert_eq!(base.rate_write, QuotaOverride::Unlimited); }
#[test]
fn test_merge_null_resets_to_default() {
let mut base = UserQuota {
storage_quota_mb: QuotaOverride::Value(500),
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("100mb/m").unwrap()),
..Default::default()
};
let patch: UserQuotaPatch =
serde_json::from_str(r#"{"storage_quota_mb": null, "rate_read": null}"#).unwrap();
base.merge(&patch);
assert_eq!(base.storage_quota_mb, QuotaOverride::Default);
assert_eq!(base.rate_read, QuotaOverride::Default);
}
#[test]
fn test_merge_empty_patch_is_noop() {
let original = UserQuota {
storage_quota_mb: QuotaOverride::Value(500),
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("100mb/m").unwrap()),
rate_write: QuotaOverride::Unlimited,
..Default::default()
};
let mut patched = original.clone();
let patch: UserQuotaPatch = serde_json::from_str("{}").unwrap();
patched.merge(&patch);
assert_eq!(patched, original);
}
#[test]
fn test_burst_valid_with_rate() {
let q = UserQuota {
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("10mb/s").unwrap()),
rate_read_burst: Some(nz(50)),
..Default::default()
};
assert!(q.validate().is_ok());
}
#[test]
fn test_burst_zero_rejected_by_serde() {
let err =
serde_json::from_str::<UserQuota>(r#"{"rate_read":"10mb/s","rate_read_burst":0}"#)
.unwrap_err();
assert!(err.to_string().contains("zero"), "error: {err}");
}
#[test]
fn test_burst_exceeds_i32_max_rejected() {
let q = UserQuota {
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("10mb/s").unwrap()),
rate_read_burst: Some(nz(i32::MAX as u32 + 1)),
..Default::default()
};
let err = q.validate().unwrap_err();
assert!(err.contains("rate_read_burst"), "error: {err}");
assert!(err.contains("exceeds maximum"), "error: {err}");
}
#[test]
fn test_burst_at_i32_max_accepted() {
let q = UserQuota {
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("10mb/s").unwrap()),
rate_read_burst: Some(nz(i32::MAX as u32)),
..Default::default()
};
assert!(q.validate().is_ok());
}
#[test]
fn test_burst_without_rate_rejected() {
let q = UserQuota {
rate_read: QuotaOverride::Default,
rate_read_burst: Some(nz(50)),
..Default::default()
};
let err = q.validate().unwrap_err();
assert!(err.contains("rate_read_burst"), "error: {err}");
}
#[test]
fn test_burst_with_unlimited_rate_rejected() {
let q = UserQuota {
rate_write: QuotaOverride::Unlimited,
rate_write_burst: Some(nz(50)),
..Default::default()
};
let err = q.validate().unwrap_err();
assert!(err.contains("rate_write_burst"), "error: {err}");
}
#[test]
fn test_burst_none_always_valid() {
for rate in [
QuotaOverride::Default,
QuotaOverride::Unlimited,
QuotaOverride::Value(BandwidthQuota::from_str("10mb/s").unwrap()),
] {
let q = UserQuota {
rate_read: rate,
rate_read_burst: None,
..Default::default()
};
assert!(q.validate().is_ok());
}
}
#[test]
fn test_patch_burst_zero_rejected_by_serde() {
let err = serde_json::from_str::<UserQuotaPatch>(r#"{"rate_read_burst": 0}"#).unwrap_err();
assert!(err.to_string().contains("zero"), "error: {err}");
}
#[test]
fn test_patch_burst_null_valid() {
let patch: UserQuotaPatch = serde_json::from_str(r#"{"rate_read_burst": null}"#).unwrap();
assert!(patch.validate().is_ok());
}
#[test]
fn test_patch_burst_positive_valid() {
let patch: UserQuotaPatch = serde_json::from_str(r#"{"rate_read_burst": 50}"#).unwrap();
assert!(patch.validate().is_ok());
}
#[test]
fn test_burst_serde_roundtrip() {
let q = UserQuota {
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("10mb/s").unwrap()),
rate_read_burst: Some(nz(50)),
rate_write: QuotaOverride::Value(BandwidthQuota::from_str("5mb/s").unwrap()),
rate_write_burst: Some(nz(25)),
..Default::default()
};
let json = serde_json::to_string(&q).unwrap();
let deserialized: UserQuota = serde_json::from_str(&json).unwrap();
assert_eq!(q, deserialized);
assert_eq!(deserialized.rate_read_burst, Some(nz(50)));
assert_eq!(deserialized.rate_write_burst, Some(nz(25)));
}
#[test]
fn test_burst_db_roundtrip() {
let q = UserQuota {
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("10mb/s").unwrap()),
rate_read_burst: Some(nz(50)),
rate_write: QuotaOverride::Default,
rate_write_burst: None,
..Default::default()
};
let reconstructed = UserQuota::from_nullable_columns(
q.storage_quota_mb_i32(),
q.rate_read_str(),
q.rate_write_str(),
q.rate_read_burst_i32(),
q.rate_write_burst_i32(),
q.allowed_write_paths_db().unwrap(),
);
assert_eq!(q, reconstructed);
}
#[test]
fn test_burst_absent_from_json_when_none() {
let q = UserQuota {
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("10mb/s").unwrap()),
rate_read_burst: None,
..Default::default()
};
let json = serde_json::to_string(&q).unwrap();
assert!(!json.contains("rate_read_burst"));
}
#[test]
fn test_burst_present_in_json_when_set() {
let q = UserQuota {
rate_read: QuotaOverride::Value(BandwidthQuota::from_str("10mb/s").unwrap()),
rate_read_burst: Some(nz(50)),
..Default::default()
};
let json = serde_json::to_string(&q).unwrap();
assert!(json.contains(r#""rate_read_burst":50"#));
}
fn wdp(s: &str) -> WebDavPath {
WebDavPath::from_str(s).unwrap()
}
#[test]
fn test_is_write_path_allowed_none_means_unrestricted() {
let q = UserQuota::default();
assert!(q.is_write_path_allowed("/pub/anything"));
assert!(q.is_write_path_allowed("/"));
}
#[test]
fn test_is_write_path_allowed_empty_means_readonly() {
let q = UserQuota {
allowed_write_paths: Some(vec![]),
..Default::default()
};
assert!(!q.is_write_path_allowed("/pub/anything"));
}
#[test]
fn test_is_write_path_allowed_prefix_match() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/tokens/"), wdp("/pub/paykit/")]),
..Default::default()
};
assert!(q.is_write_path_allowed("/pub/tokens/foo.json"));
assert!(q.is_write_path_allowed("/pub/paykit/bar"));
assert!(!q.is_write_path_allowed("/pub/other/file"));
assert!(!q.is_write_path_allowed("/pub/token")); }
#[test]
fn test_is_write_path_allowed_exact_file_match() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/profile.json")]),
..Default::default()
};
assert!(q.is_write_path_allowed("/pub/profile.json"));
assert!(!q.is_write_path_allowed("/pub/profile.json/sub"));
assert!(!q.is_write_path_allowed("/pub/profile.jsonx"));
assert!(!q.is_write_path_allowed("/pub/other.json"));
}
#[test]
fn test_is_write_path_allowed_mixed_dirs_and_files() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/tokens/"), wdp("/pub/profile.json")]),
..Default::default()
};
assert!(q.is_write_path_allowed("/pub/tokens/foo.json"));
assert!(q.is_write_path_allowed("/pub/profile.json"));
assert!(!q.is_write_path_allowed("/pub/other/file"));
}
#[test]
fn test_is_write_path_allowed_prefix_not_child_rejected() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/tokens/")]),
..Default::default()
};
assert!(
!q.is_write_path_allowed("/pub/tokenstore/foo.json"),
"Path sharing a prefix but not under the allowed dir must be rejected"
);
assert!(
!q.is_write_path_allowed("/pub/tokens"),
"Allowed dir '/pub/tokens/' should not match file path '/pub/tokens' (no trailing slash)"
);
}
#[test]
fn test_is_write_path_allowed_nested_subdir() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/tokens/")]),
..Default::default()
};
assert!(q.is_write_path_allowed("/pub/tokens/sub/deep/file.json"));
assert!(q.is_write_path_allowed("/pub/tokens/a"));
}
#[test]
fn test_is_write_path_allowed_exact_file_no_children() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/config.json")]),
..Default::default()
};
assert!(q.is_write_path_allowed("/pub/config.json"));
assert!(
!q.is_write_path_allowed("/pub/config.json/extra"),
"Exact file match must not allow sub-paths"
);
assert!(
!q.is_write_path_allowed("/pub/config.jsonx"),
"Exact file match must not allow suffix extensions"
);
}
#[test]
fn test_allowed_write_paths_serde_roundtrip() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/tokens/")]),
..Default::default()
};
let json = serde_json::to_string(&q).unwrap();
assert!(json.contains("allowed_write_paths"));
let deserialized: UserQuota = serde_json::from_str(&json).unwrap();
assert_eq!(q, deserialized);
}
#[test]
fn test_allowed_write_paths_none_omitted_from_json() {
let q = UserQuota::default();
let json = serde_json::to_string(&q).unwrap();
assert!(!json.contains("allowed_write_paths"));
}
#[test]
fn test_allowed_write_paths_db_roundtrip() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/a/"), wdp("/pub/b/")]),
..Default::default()
};
let db_val = q.allowed_write_paths_db().unwrap();
let reconstructed = UserQuota::from_nullable_columns(None, None, None, None, None, db_val);
assert_eq!(q.allowed_write_paths, reconstructed.allowed_write_paths);
}
#[test]
fn test_allowed_write_paths_db_none() {
let q = UserQuota::default();
assert_eq!(q.allowed_write_paths_db().unwrap(), None);
}
#[test]
fn test_validate_allowed_write_paths_valid() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/tokens/")]),
..Default::default()
};
assert!(q.validate().is_ok());
}
#[test]
fn test_validate_allowed_write_paths_empty_is_valid() {
let q = UserQuota {
allowed_write_paths: Some(vec![]),
..Default::default()
};
assert!(q.validate().is_ok());
}
#[test]
fn test_serde_rejects_invalid_path_no_leading_slash() {
let result =
serde_json::from_str::<UserQuota>(r#"{"allowed_write_paths": ["pub/tokens/"]}"#);
assert!(result.is_err());
}
#[test]
fn test_validate_allowed_write_paths_file_path_accepted() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/tokens")]),
..Default::default()
};
assert!(q.validate().is_ok(), "File paths should be accepted");
}
#[test]
fn test_serde_rejects_dotdot_path() {
let result =
serde_json::from_str::<UserQuota>(r#"{"allowed_write_paths": ["/pub/../etc/"]}"#);
if let Ok(q) = result {
assert_eq!(q.allowed_write_paths.unwrap()[0].as_str(), "/etc/");
}
}
#[test]
fn test_serde_normalizes_double_slash() {
let q: UserQuota =
serde_json::from_str(r#"{"allowed_write_paths": ["/pub//tokens/"]}"#).unwrap();
assert_eq!(q.allowed_write_paths.unwrap()[0].as_str(), "/pub/tokens/");
}
#[test]
fn test_patch_allowed_write_paths_absent_keeps() {
let patch: UserQuotaPatch = serde_json::from_str("{}").unwrap();
assert!(patch.allowed_write_paths.is_none());
}
#[test]
fn test_patch_allowed_write_paths_null_resets() {
let patch: UserQuotaPatch =
serde_json::from_str(r#"{"allowed_write_paths": null}"#).unwrap();
assert_eq!(patch.allowed_write_paths, Some(None));
}
#[test]
fn test_patch_allowed_write_paths_array_sets() {
let patch: UserQuotaPatch =
serde_json::from_str(r#"{"allowed_write_paths": ["/pub/a/"]}"#).unwrap();
assert_eq!(patch.allowed_write_paths, Some(Some(vec![wdp("/pub/a/")])));
}
#[test]
fn test_validate_allowed_write_paths_root_slash_rejected() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/")]),
..Default::default()
};
assert!(q.validate().unwrap_err().contains("must not be '/'"));
}
#[test]
fn test_validate_allowed_write_paths_duplicate_rejected() {
let q = UserQuota {
allowed_write_paths: Some(vec![wdp("/pub/tokens/"), wdp("/pub/tokens/")]),
..Default::default()
};
assert!(q.validate().unwrap_err().contains("duplicate"));
}
#[test]
fn test_from_nullable_columns_malformed_json_falls_back_to_readonly() {
let q = UserQuota::from_nullable_columns(
None,
None,
None,
None,
None,
Some("not valid json".to_string()),
);
assert_eq!(
q.allowed_write_paths,
Some(vec![]),
"Malformed JSON should fall back to read-only (fail-closed)"
);
}
#[test]
fn test_from_nullable_columns_wrong_json_type_falls_back_to_readonly() {
let q = UserQuota::from_nullable_columns(
None,
None,
None,
None,
None,
Some(r#"{"not": "an array"}"#.to_string()),
);
assert_eq!(
q.allowed_write_paths,
Some(vec![]),
"Wrong JSON type should fall back to read-only (fail-closed)"
);
}
#[test]
fn test_patch_serde_rejects_bad_write_paths() {
let result = serde_json::from_str::<UserQuotaPatch>(
r#"{"allowed_write_paths": ["no-leading-slash/"]}"#,
);
assert!(
result.is_err(),
"Invalid paths should be rejected at deserialization"
);
}
#[test]
fn test_merge_allowed_write_paths() {
let mut base = UserQuota::default();
let patch: UserQuotaPatch =
serde_json::from_str(r#"{"allowed_write_paths": ["/pub/x/"]}"#).unwrap();
base.merge(&patch);
assert_eq!(base.allowed_write_paths, Some(vec![wdp("/pub/x/")]));
let patch: UserQuotaPatch =
serde_json::from_str(r#"{"allowed_write_paths": null}"#).unwrap();
base.merge(&patch);
assert_eq!(base.allowed_write_paths, None);
}
}