Files
Oxicloud/src/domain/entities/share.rs
T
Claude 992bdae898 perf: round 22 — hot-GET HeaderMap borrow, native-WebDAV/CalDAV etag borrowed quotes, FileDto content_hash move, CalendarEvent stamp, ShareItemType case-fold
Benchmark-gated, same rule as ROUND2-21: every change ships with a
BEFORE/AFTER counting-allocator benchmark and a byte/-value equivalence
gate; an AFTER that fails to reduce allocations exits non-zero (rollback).
See benches/ROUND22.md and examples/bench_round22_micro.rs. All arms
no-Postgres.

- H1: the hot GET handlers (get_thumbnail, download_file, list_files_query,
  list_photos, NextCloud preview, public-share download/access) take
  `req: Request` last and read `req.headers()` by borrow instead of axum's
  HeaderMap extractor, whose FromRequestParts impl clones the whole request
  header table just to read 1-3 headers (the ROUND14 §A4 middleware pattern,
  finally propagated to the handlers). 2 -> 0 allocs/req · 9.95x wall.
- W1: native WebDAV write_etag_quoted — the etag emitter for every /webdav/
  PROPFIND row (per file AND per folder, up to 500/page) — emits the quotes
  as borrowed pre-escaped " text events instead of escaping a "{etag}"
  String (the ROUND20 §C1 / ROUND21 §R4 pattern). 3 -> 0 allocs/row.
- C1: CalDAV getetag routed through a shared write_quoted_etag helper across
  all 5 sites (3 per-event + 2 per-calendar); the now-dead etag: &mut String
  buffer threaded through write_event_response/standard/requested props + the
  two per-page buffers removed. 2 -> 0 allocs/row.
- D1: FileDto::from reuses the moved parts.blob_hash instead of cloning it
  via the content_hash() getter (the ROUND19/20 move-not-clone sweep missed
  it — hash/etag are read before into_parts()). Per file row of every
  listing. 1 -> 0 allocs/row.
- E1: CalendarEvent::update_time_range/update_all_day stamp timed
  DTSTART/DTEND via fmt::compact_ical_utc stack render (chrono fallback out
  of range) instead of the %Y%m%dT%H%M%SZ strftime interpreter. 4 -> 0.
- S1: ShareItemType::try_from uses eq_ignore_ascii_case instead of a
  throwaway to_lowercase() String. 1 -> 0 allocs/parse.

Verified: cargo clippy --features bench --all-targets -D warnings clean,
cargo fmt --all --check clean, cargo test --lib --features bench = 529
passed / 0 failed (incl. the OpenAPI-spec-validity test guarding the H1
utoipa-handler signature change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DKyQ4AnYtgp1JtjzweyMeo
2026-07-20 13:48:47 +00:00

277 lines
6.8 KiB
Rust

use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
// Re-export entity errors from the centralized module
pub use super::entity_errors::ShareError;
#[derive(Debug, Clone, PartialEq)]
pub struct Share {
id: Uuid,
item_id: String,
item_name: Option<String>,
item_type: ShareItemType,
token: String,
password_hash: Option<String>,
/// Derived from `storage.role_grants.expires_at` — not stored on the share row.
expires_at: Option<u64>,
created_at: u64,
created_by: Uuid,
access_count: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ShareItemType {
File,
Folder,
}
impl Share {
pub fn new(
item_id: String,
item_name: Option<String>,
item_type: ShareItemType,
created_by: Uuid,
password_hash: Option<String>,
) -> Result<Self, ShareError> {
if item_id.is_empty() {
return Err(ShareError::ValidationError(
"Item ID cannot be empty".to_string(),
));
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
Ok(Self {
id: Uuid::new_v4(),
item_id,
item_name,
item_type,
token: Uuid::new_v4().to_string(),
password_hash,
expires_at: None,
created_at: now,
created_by,
access_count: 0,
})
}
#[allow(clippy::too_many_arguments)]
pub fn from_raw(
id: Uuid,
item_id: String,
item_name: Option<String>,
item_type: ShareItemType,
token: String,
password_hash: Option<String>,
expires_at: Option<u64>,
created_at: u64,
created_by: Uuid,
access_count: u64,
) -> Self {
Self {
id,
item_id,
item_name,
item_type,
token,
password_hash,
expires_at,
created_at,
created_by,
access_count,
}
}
// ── Getters ──
pub fn id(&self) -> Uuid {
self.id
}
pub fn item_id(&self) -> &str {
&self.item_id
}
pub fn item_name(&self) -> Option<&str> {
self.item_name.as_deref()
}
pub fn item_type(&self) -> &ShareItemType {
&self.item_type
}
pub fn token(&self) -> &str {
&self.token
}
pub fn expires_at(&self) -> Option<u64> {
self.expires_at
}
pub fn created_at(&self) -> u64 {
self.created_at
}
pub fn created_by(&self) -> Uuid {
self.created_by
}
pub fn access_count(&self) -> u64 {
self.access_count
}
// ── Builder-style modifiers (immutable) ──
pub fn with_password(mut self, password_hash: Option<String>) -> Self {
self.password_hash = password_hash;
self
}
pub fn with_token(mut self, token: String) -> Self {
self.token = token;
self
}
pub fn is_expired(&self) -> bool {
if let Some(expires_at) = self.expires_at {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
return expires_at <= now;
}
false
}
pub fn increment_access_count(mut self) -> Self {
self.access_count += 1;
self
}
/// Returns whether this share requires a password to access.
pub fn has_password(&self) -> bool {
self.password_hash.is_some()
}
/// Returns a reference to the password hash, if one is set.
///
/// Password verification should be performed externally via PasswordHasherPort
/// to keep cryptographic dependencies out of the domain layer.
pub fn password_hash(&self) -> Option<&str> {
self.password_hash.as_deref()
}
}
impl std::fmt::Display for ShareItemType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ShareItemType::File => write!(f, "file"),
ShareItemType::Folder => write!(f, "folder"),
}
}
}
impl TryFrom<&str> for ShareItemType {
type Error = ShareError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
// ASCII case-insensitive compare against the two literals instead of a
// throwaway Unicode `to_lowercase()` String — byte-identical acceptance
// for the ASCII targets "file"/"folder" (1 → 0 allocs/call).
if s.eq_ignore_ascii_case("file") {
Ok(ShareItemType::File)
} else if s.eq_ignore_ascii_case("folder") {
Ok(ShareItemType::Folder)
} else {
Err(ShareError::ValidationError(format!(
"Invalid item type: {}",
s
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_user_id() -> Uuid {
Uuid::new_v4()
}
#[test]
fn test_create_share() {
let uid = test_user_id();
let share = Share::new(
"test_file_id".to_string(),
None,
ShareItemType::File,
uid,
None,
)
.unwrap();
assert_eq!(share.item_id(), "test_file_id");
assert_eq!(*share.item_type(), ShareItemType::File);
assert_eq!(share.created_by(), uid);
assert!(!share.has_password());
assert!(share.expires_at().is_none());
assert_eq!(share.access_count(), 0);
}
#[test]
fn test_share_item_type_conversion() {
assert_eq!(ShareItemType::File.to_string(), "file");
assert_eq!(ShareItemType::Folder.to_string(), "folder");
assert_eq!(
ShareItemType::try_from("file").unwrap(),
ShareItemType::File
);
assert_eq!(
ShareItemType::try_from("folder").unwrap(),
ShareItemType::Folder
);
assert_eq!(
ShareItemType::try_from("FILE").unwrap(),
ShareItemType::File
);
assert!(ShareItemType::try_from("invalid").is_err());
}
#[test]
fn test_has_password_with_hash() {
let share = Share::new(
"test_file_id".to_string(),
None,
ShareItemType::File,
test_user_id(),
Some("some_hash_value".to_string()),
)
.unwrap();
assert!(share.has_password());
assert_eq!(share.password_hash(), Some("some_hash_value"));
}
#[test]
fn test_has_password_without_hash() {
let share = Share::new(
"test_file_id".to_string(),
None,
ShareItemType::File,
test_user_id(),
None,
)
.unwrap();
assert!(!share.has_password());
assert_eq!(share.password_hash(), None);
}
}