2026-03-07 18:05:52 +01:00
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
2026-06-06 17:47:59 +02:00
|
|
|
use crate::domain::services::path_service::{
|
|
|
|
|
StoragePath, normalize_storage_name, validate_storage_name,
|
|
|
|
|
};
|
2025-03-19 00:44:27 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Re-export entity errors from the centralized module
|
2026-02-06 20:57:00 +01:00
|
|
|
pub use super::entity_errors::{FileError, FileResult};
|
2025-03-17 21:28:08 +01:00
|
|
|
|
2026-03-06 22:37:31 +01:00
|
|
|
/// Owned parts of a [`File`] entity, produced by [`File::into_parts()`].
|
|
|
|
|
///
|
|
|
|
|
/// Consuming a `File` into `FileParts` **moves** every field without cloning,
|
|
|
|
|
/// eliminating 3-5 heap allocations that previously occurred when converting
|
|
|
|
|
/// `File → FileDto` via `.to_string()` on each getter.
|
|
|
|
|
pub struct FileParts {
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub storage_path: StoragePath,
|
|
|
|
|
pub path_string: String,
|
|
|
|
|
pub size: u64,
|
|
|
|
|
pub mime_type: String,
|
|
|
|
|
pub folder_id: Option<String>,
|
|
|
|
|
pub created_at: u64,
|
|
|
|
|
pub modified_at: u64,
|
2026-03-07 18:05:52 +01:00
|
|
|
pub owner_id: Option<Uuid>,
|
2026-06-06 15:37:27 +02:00
|
|
|
/// BLAKE3 content hash. See [`File::content_hash`] for semantics.
|
|
|
|
|
pub blob_hash: String,
|
2026-03-06 22:37:31 +01:00
|
|
|
}
|
|
|
|
|
|
2025-03-26 19:08:07 +01:00
|
|
|
/**
|
|
|
|
|
* Represents a file in the system's domain model.
|
2026-02-14 01:29:34 +01:00
|
|
|
*
|
2025-03-26 19:08:07 +01:00
|
|
|
* The File entity is a core domain object that encapsulates all properties and behaviors
|
|
|
|
|
* of a file in the system. It implements an immutable design pattern where modification
|
|
|
|
|
* operations return new instances rather than modifying the existing one.
|
2026-02-14 01:29:34 +01:00
|
|
|
*
|
2025-03-26 19:08:07 +01:00
|
|
|
* This entity maintains both physical storage information and logical metadata about files,
|
|
|
|
|
* serving as the bridge between the storage system and the application.
|
|
|
|
|
*/
|
2026-02-02 23:56:40 +01:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
2025-03-17 21:28:08 +01:00
|
|
|
pub struct File {
|
2025-03-26 19:08:07 +01:00
|
|
|
/// Unique identifier for the file - used throughout the system for file operations
|
2025-03-19 00:44:27 +01:00
|
|
|
id: String,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-26 19:08:07 +01:00
|
|
|
/// Name of the file including extension
|
2025-03-19 00:44:27 +01:00
|
|
|
name: String,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-02 23:56:40 +01:00
|
|
|
/// Path to the file in the domain model
|
2025-03-19 00:44:27 +01:00
|
|
|
storage_path: StoragePath,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-02 23:56:40 +01:00
|
|
|
/// String representation of the path for API compatibility
|
2025-03-19 00:44:27 +01:00
|
|
|
path_string: String,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-17 21:28:08 +01:00
|
|
|
/// Size of the file in bytes
|
2025-03-19 00:44:27 +01:00
|
|
|
size: u64,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-26 19:08:07 +01:00
|
|
|
/// MIME type of the file (e.g., "text/plain", "image/jpeg")
|
2025-03-19 00:44:27 +01:00
|
|
|
mime_type: String,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-26 19:08:07 +01:00
|
|
|
/// Parent folder ID if the file is within a folder, None if in root
|
2025-03-19 00:44:27 +01:00
|
|
|
folder_id: Option<String>,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-26 19:08:07 +01:00
|
|
|
/// Creation timestamp (seconds since UNIX epoch)
|
2025-03-19 00:44:27 +01:00
|
|
|
created_at: u64,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-26 19:08:07 +01:00
|
|
|
/// Last modification timestamp (seconds since UNIX epoch)
|
2025-03-19 00:44:27 +01:00
|
|
|
modified_at: u64,
|
2026-02-21 13:39:27 +01:00
|
|
|
|
|
|
|
|
/// Owner user ID (from storage.files.user_id)
|
2026-03-07 18:05:52 +01:00
|
|
|
owner_id: Option<Uuid>,
|
2026-03-15 14:14:24 -04:00
|
|
|
|
2026-06-06 15:37:27 +02:00
|
|
|
/// BLAKE3 content hash. Stable across renames/moves, changes only
|
|
|
|
|
/// when the file's content bytes change. Source of truth for both
|
|
|
|
|
/// content-addressable storage and the HTTP ETag (via
|
|
|
|
|
/// [`File::etag`]). Exposed publicly via [`File::content_hash`]
|
|
|
|
|
/// so the REST API can surface it as a distinct concept from the
|
|
|
|
|
/// ETag (the ETag formula may grow to include `modified_at` etc.,
|
|
|
|
|
/// but `content_hash` remains the raw hash).
|
|
|
|
|
blob_hash: String,
|
2025-03-17 21:28:08 +01:00
|
|
|
}
|
|
|
|
|
|
2025-03-30 14:17:09 +00:00
|
|
|
// We no longer need this module, now we use a String directly
|
2025-03-19 00:44:27 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
impl Default for File {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
id: "stub-id".to_string(),
|
|
|
|
|
name: "stub-file.txt".to_string(),
|
|
|
|
|
storage_path: StoragePath::from_string("/"),
|
|
|
|
|
path_string: "/".to_string(),
|
|
|
|
|
size: 0,
|
|
|
|
|
mime_type: "application/octet-stream".to_string(),
|
|
|
|
|
folder_id: None,
|
|
|
|
|
created_at: 0,
|
|
|
|
|
modified_at: 0,
|
2026-02-21 13:39:27 +01:00
|
|
|
owner_id: None,
|
2026-06-06 15:37:27 +02:00
|
|
|
blob_hash: String::new(),
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-17 21:28:08 +01:00
|
|
|
impl File {
|
2025-03-30 14:17:09 +00:00
|
|
|
/// Creates a new file with validation
|
2025-03-17 21:28:08 +01:00
|
|
|
pub fn new(
|
|
|
|
|
id: String,
|
|
|
|
|
name: String,
|
2025-03-19 00:44:27 +01:00
|
|
|
storage_path: StoragePath,
|
2025-03-17 21:28:08 +01:00
|
|
|
size: u64,
|
|
|
|
|
mime_type: String,
|
|
|
|
|
folder_id: Option<String>,
|
2025-03-19 00:44:27 +01:00
|
|
|
) -> FileResult<Self> {
|
2026-06-06 17:47:59 +02:00
|
|
|
let name = normalize_storage_name(&name);
|
2026-05-08 00:11:02 +02:00
|
|
|
if let Err(reason) = validate_storage_name(&name) {
|
|
|
|
|
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-17 21:28:08 +01:00
|
|
|
let now = std::time::SystemTime::now()
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
2025-03-19 00:44:27 +01:00
|
|
|
.unwrap_or_default()
|
2025-03-17 21:28:08 +01:00
|
|
|
.as_secs();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-30 14:17:09 +00:00
|
|
|
// Store the path string for serialization compatibility
|
2025-03-19 00:44:27 +01:00
|
|
|
let path_string = storage_path.to_string();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
Ok(Self {
|
2025-03-17 21:28:08 +01:00
|
|
|
id,
|
|
|
|
|
name,
|
2025-03-19 00:44:27 +01:00
|
|
|
storage_path,
|
|
|
|
|
path_string,
|
2025-03-17 21:28:08 +01:00
|
|
|
size,
|
|
|
|
|
mime_type,
|
|
|
|
|
folder_id,
|
|
|
|
|
created_at: now,
|
|
|
|
|
modified_at: now,
|
2026-02-21 13:39:27 +01:00
|
|
|
owner_id: None,
|
2026-06-06 15:37:27 +02:00
|
|
|
blob_hash: String::new(),
|
2025-03-19 00:44:27 +01:00
|
|
|
})
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-04-09 00:21:20 +02:00
|
|
|
/// Creates a folder entity
|
|
|
|
|
pub fn new_folder(
|
|
|
|
|
id: String,
|
|
|
|
|
name: String,
|
|
|
|
|
storage_path: StoragePath,
|
|
|
|
|
parent_id: Option<String>,
|
|
|
|
|
created_at: u64,
|
|
|
|
|
modified_at: u64,
|
|
|
|
|
) -> FileResult<Self> {
|
2026-06-06 17:47:59 +02:00
|
|
|
let name = normalize_storage_name(&name);
|
2026-05-08 00:11:02 +02:00
|
|
|
if let Err(reason) = validate_storage_name(&name) {
|
|
|
|
|
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
|
2025-04-09 00:21:20 +02:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-04-09 00:21:20 +02:00
|
|
|
// Store the path string for serialization compatibility
|
|
|
|
|
let path_string = storage_path.to_string();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-04-09 00:21:20 +02:00
|
|
|
Ok(Self {
|
|
|
|
|
id,
|
|
|
|
|
name,
|
|
|
|
|
storage_path,
|
|
|
|
|
path_string,
|
2026-02-14 01:29:34 +01:00
|
|
|
size: 0, // Folders have zero size
|
2025-04-09 00:21:20 +02:00
|
|
|
mime_type: "directory".to_string(), // Standard MIME type for directories
|
|
|
|
|
folder_id: parent_id,
|
|
|
|
|
created_at,
|
|
|
|
|
modified_at,
|
2026-02-21 13:39:27 +01:00
|
|
|
owner_id: None,
|
2026-06-06 15:37:27 +02:00
|
|
|
blob_hash: String::new(),
|
2025-04-09 00:21:20 +02:00
|
|
|
})
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
#[allow(clippy::too_many_arguments)]
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn with_timestamps(
|
|
|
|
|
id: String,
|
|
|
|
|
name: String,
|
|
|
|
|
storage_path: StoragePath,
|
|
|
|
|
size: u64,
|
|
|
|
|
mime_type: String,
|
|
|
|
|
folder_id: Option<String>,
|
|
|
|
|
created_at: u64,
|
|
|
|
|
modified_at: u64,
|
2026-03-07 18:05:52 +01:00
|
|
|
owner_id: Option<Uuid>,
|
2026-03-15 14:14:24 -04:00
|
|
|
) -> FileResult<Self> {
|
2026-06-06 15:37:27 +02:00
|
|
|
Self::with_timestamps_and_blob_hash(
|
2026-03-15 14:14:24 -04:00
|
|
|
id,
|
|
|
|
|
name,
|
|
|
|
|
storage_path,
|
|
|
|
|
size,
|
|
|
|
|
mime_type,
|
|
|
|
|
folder_id,
|
|
|
|
|
created_at,
|
|
|
|
|
modified_at,
|
|
|
|
|
owner_id,
|
|
|
|
|
String::new(),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
2026-06-06 15:37:27 +02:00
|
|
|
pub fn with_timestamps_and_blob_hash(
|
2026-03-15 14:14:24 -04:00
|
|
|
id: String,
|
|
|
|
|
name: String,
|
|
|
|
|
storage_path: StoragePath,
|
|
|
|
|
size: u64,
|
|
|
|
|
mime_type: String,
|
|
|
|
|
folder_id: Option<String>,
|
|
|
|
|
created_at: u64,
|
|
|
|
|
modified_at: u64,
|
|
|
|
|
owner_id: Option<Uuid>,
|
2026-06-06 15:37:27 +02:00
|
|
|
blob_hash: String,
|
2025-03-19 00:44:27 +01:00
|
|
|
) -> FileResult<Self> {
|
2026-06-06 17:47:59 +02:00
|
|
|
let name = normalize_storage_name(&name);
|
2026-05-08 00:11:02 +02:00
|
|
|
if let Err(reason) = validate_storage_name(&name) {
|
|
|
|
|
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-30 14:17:09 +00:00
|
|
|
// Store the path string for serialization compatibility
|
2025-03-19 00:44:27 +01:00
|
|
|
let path_string = storage_path.to_string();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
Ok(Self {
|
|
|
|
|
id,
|
|
|
|
|
name,
|
|
|
|
|
storage_path,
|
|
|
|
|
path_string,
|
|
|
|
|
size,
|
|
|
|
|
mime_type,
|
|
|
|
|
folder_id,
|
|
|
|
|
created_at,
|
|
|
|
|
modified_at,
|
2026-02-21 13:39:27 +01:00
|
|
|
owner_id,
|
2026-06-06 15:37:27 +02:00
|
|
|
blob_hash,
|
2025-03-19 00:44:27 +01:00
|
|
|
})
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-03-06 22:37:31 +01:00
|
|
|
/// Consume the entity and return all fields by ownership.
|
|
|
|
|
///
|
|
|
|
|
/// Use this when converting `File` into a DTO to avoid cloning
|
|
|
|
|
/// every `String` field (saves 3-5 heap allocations per file).
|
|
|
|
|
pub fn into_parts(self) -> FileParts {
|
|
|
|
|
FileParts {
|
|
|
|
|
id: self.id,
|
|
|
|
|
name: self.name,
|
|
|
|
|
storage_path: self.storage_path,
|
|
|
|
|
path_string: self.path_string,
|
|
|
|
|
size: self.size,
|
|
|
|
|
mime_type: self.mime_type,
|
|
|
|
|
folder_id: self.folder_id,
|
|
|
|
|
created_at: self.created_at,
|
|
|
|
|
modified_at: self.modified_at,
|
|
|
|
|
owner_id: self.owner_id,
|
2026-06-06 15:37:27 +02:00
|
|
|
blob_hash: self.blob_hash,
|
2026-03-06 22:37:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 15:37:27 +02:00
|
|
|
/// Raw BLAKE3 content hash — the cryptographic identity of the
|
|
|
|
|
/// file's bytes. Stable across renames, moves, and metadata
|
|
|
|
|
/// updates. Changes only when the underlying content changes.
|
|
|
|
|
///
|
|
|
|
|
/// This is **distinct from [`File::etag`]**: the ETag is an HTTP
|
|
|
|
|
/// cache token that may incorporate non-content signals (mtime,
|
|
|
|
|
/// permissions, …) in future revisions; `content_hash` is the
|
|
|
|
|
/// raw hash, suitable for content-addressable URLs, dedup
|
|
|
|
|
/// verification, and integrity audits. Keep both accessible —
|
|
|
|
|
/// the API layer can choose to expose `content_hash` even when
|
|
|
|
|
/// `etag` grows additional inputs.
|
|
|
|
|
pub fn content_hash(&self) -> &str {
|
|
|
|
|
&self.blob_hash
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap
|
|
|
|
|
/// in `"…"` themselves at the HTTP boundary.
|
|
|
|
|
///
|
2026-06-06 19:51:51 +02:00
|
|
|
/// This is a thin instance-method wrapper around
|
|
|
|
|
/// [`File::compute_etag`] — see that function for the full
|
|
|
|
|
/// formula, rationale, and the "single source of truth"
|
|
|
|
|
/// guarantee that lets raw-row listings (`/api/folders/{id}/resources`,
|
|
|
|
|
/// favorites, trash, recents, REPORT/SEARCH) compute the same
|
|
|
|
|
/// value without constructing a full `File` entity.
|
|
|
|
|
pub fn etag(&self) -> String {
|
|
|
|
|
Self::compute_etag(&self.blob_hash, self.modified_at)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pure formula for the file ETag, exposed as a static method so
|
|
|
|
|
/// listing handlers that operate on raw SQL rows (rather than
|
|
|
|
|
/// fully-constructed `File` entities) route through the same
|
|
|
|
|
/// definition.
|
|
|
|
|
///
|
2026-06-06 16:46:19 +02:00
|
|
|
/// **Formula**: `{blob_hash[..16]}-{modified_at}`.
|
2026-06-06 15:37:27 +02:00
|
|
|
///
|
2026-06-06 16:46:19 +02:00
|
|
|
/// - The 16-char BLAKE3 prefix is the content identity (64 bits
|
|
|
|
|
/// ≈ 10⁻⁹ collision probability over 10M files).
|
|
|
|
|
/// - `modified_at` (Unix seconds) catches the `x-oc-mtime`
|
|
|
|
|
/// case: NextCloud preserves the client-side mtime on upload,
|
|
|
|
|
/// so a "touch-then-resync" of unchanged content still bumps
|
|
|
|
|
/// the mtime — without the suffix the ETag wouldn't change
|
|
|
|
|
/// and clients would serve stale metadata.
|
|
|
|
|
/// - When `blob_hash` is shorter than 16 chars (test fixtures,
|
|
|
|
|
/// stub entities) the prefix is just the whole value.
|
2026-06-06 19:51:51 +02:00
|
|
|
/// - Folder ETags follow a separate formula — see
|
|
|
|
|
/// [`crate::domain::entities::folder::Folder::compute_etag`].
|
2026-06-06 16:46:19 +02:00
|
|
|
///
|
2026-06-06 19:51:51 +02:00
|
|
|
/// Every handler that emits a file ETag header MUST go through
|
|
|
|
|
/// this function (directly or via [`File::etag`] /
|
|
|
|
|
/// `FileDto::etag`) so `GET`, `HEAD`, `PROPFIND`, `PUT`
|
|
|
|
|
/// response, `MOVE`, and every JSON listing return
|
|
|
|
|
/// byte-identical values for the same file. Changing the
|
|
|
|
|
/// formula here changes it everywhere — that is the property
|
|
|
|
|
/// we want.
|
|
|
|
|
pub fn compute_etag(blob_hash: &str, modified_at: u64) -> String {
|
|
|
|
|
let prefix: String = blob_hash.chars().take(16).collect();
|
|
|
|
|
format!("{}-{}", prefix, modified_at)
|
2026-03-15 14:14:24 -04:00
|
|
|
}
|
|
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
// Getters
|
|
|
|
|
pub fn id(&self) -> &str {
|
|
|
|
|
&self.id
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn name(&self) -> &str {
|
|
|
|
|
&self.name
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn storage_path(&self) -> &StoragePath {
|
|
|
|
|
&self.storage_path
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn path_string(&self) -> &str {
|
|
|
|
|
&self.path_string
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn size(&self) -> u64 {
|
|
|
|
|
self.size
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn mime_type(&self) -> &str {
|
|
|
|
|
&self.mime_type
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn folder_id(&self) -> Option<&str> {
|
|
|
|
|
self.folder_id.as_deref()
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn created_at(&self) -> u64 {
|
|
|
|
|
self.created_at
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn modified_at(&self) -> u64 {
|
|
|
|
|
self.modified_at
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-03-07 18:05:52 +01:00
|
|
|
pub fn owner_id(&self) -> Option<Uuid> {
|
|
|
|
|
self.owner_id
|
2026-02-21 13:39:27 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
#[allow(clippy::too_many_arguments)]
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn from_dto(
|
|
|
|
|
id: String,
|
|
|
|
|
name: String,
|
|
|
|
|
path: String,
|
|
|
|
|
size: u64,
|
|
|
|
|
mime_type: String,
|
|
|
|
|
folder_id: Option<String>,
|
|
|
|
|
created_at: u64,
|
|
|
|
|
modified_at: u64,
|
|
|
|
|
) -> Self {
|
2025-03-30 14:17:09 +00:00
|
|
|
// Create storage_path from string
|
2025-03-19 00:44:27 +01:00
|
|
|
let storage_path = StoragePath::from_string(&path);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-06 17:47:59 +02:00
|
|
|
// Create directly without validation to avoid errors in DTO
|
|
|
|
|
// conversions. Still NFC-normalize so even DTO-reconstructed
|
|
|
|
|
// entities maintain the storage invariant.
|
|
|
|
|
let name = normalize_storage_name(&name);
|
|
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
Self {
|
|
|
|
|
id,
|
|
|
|
|
name,
|
|
|
|
|
storage_path,
|
|
|
|
|
path_string: path,
|
|
|
|
|
size,
|
|
|
|
|
mime_type,
|
|
|
|
|
folder_id,
|
|
|
|
|
created_at,
|
|
|
|
|
modified_at,
|
2026-02-21 13:39:27 +01:00
|
|
|
owner_id: None,
|
2026-06-06 15:37:27 +02:00
|
|
|
blob_hash: String::new(),
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-30 14:17:09 +00:00
|
|
|
// Methods to create new versions of the file (immutable)
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-30 14:17:09 +00:00
|
|
|
/// Creates a new version of the file with updated name
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn with_name(&self, new_name: String) -> FileResult<Self> {
|
2026-06-06 17:47:59 +02:00
|
|
|
let new_name = normalize_storage_name(&new_name);
|
2026-05-08 00:11:02 +02:00
|
|
|
if let Err(reason) = validate_storage_name(&new_name) {
|
|
|
|
|
return Err(FileError::InvalidFileName(format!("{new_name}: {reason}")));
|
2025-03-17 21:28:08 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-30 14:17:09 +00:00
|
|
|
// Update path based on name
|
2025-03-19 00:44:27 +01:00
|
|
|
let parent_path = self.storage_path.parent();
|
|
|
|
|
let new_storage_path = match parent_path {
|
|
|
|
|
Some(parent) => parent.join(&new_name),
|
|
|
|
|
None => StoragePath::from_string(&new_name),
|
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-30 14:17:09 +00:00
|
|
|
// Update string representation
|
2025-03-19 00:44:27 +01:00
|
|
|
let new_path_string = new_storage_path.to_string();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
let now = std::time::SystemTime::now()
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.as_secs();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
Ok(Self {
|
|
|
|
|
id: self.id.clone(),
|
|
|
|
|
name: new_name,
|
|
|
|
|
storage_path: new_storage_path,
|
|
|
|
|
path_string: new_path_string,
|
|
|
|
|
size: self.size,
|
|
|
|
|
mime_type: self.mime_type.clone(),
|
|
|
|
|
folder_id: self.folder_id.clone(),
|
|
|
|
|
created_at: self.created_at,
|
|
|
|
|
modified_at: now,
|
2026-03-09 14:34:07 +01:00
|
|
|
owner_id: self.owner_id,
|
2026-06-06 15:37:27 +02:00
|
|
|
blob_hash: self.blob_hash.clone(),
|
2025-03-19 00:44:27 +01:00
|
|
|
})
|
2025-03-17 21:28:08 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-30 14:17:09 +00:00
|
|
|
/// Creates a new version of the file with updated folder
|
2026-02-14 01:29:34 +01:00
|
|
|
pub fn with_folder(
|
|
|
|
|
&self,
|
|
|
|
|
folder_id: Option<String>,
|
|
|
|
|
folder_path: Option<StoragePath>,
|
|
|
|
|
) -> FileResult<Self> {
|
2025-03-30 14:17:09 +00:00
|
|
|
// We need a folder path to update the file path
|
2025-03-19 00:44:27 +01:00
|
|
|
let new_storage_path = match folder_path {
|
|
|
|
|
Some(path) => path.join(&self.name),
|
2025-03-30 14:17:09 +00:00
|
|
|
None => StoragePath::from_string(&self.name), // Root
|
2025-03-19 00:44:27 +01:00
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-30 14:17:09 +00:00
|
|
|
// Update string representation
|
2025-03-19 00:44:27 +01:00
|
|
|
let new_path_string = new_storage_path.to_string();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
let now = std::time::SystemTime::now()
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.as_secs();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
Ok(Self {
|
|
|
|
|
id: self.id.clone(),
|
|
|
|
|
name: self.name.clone(),
|
|
|
|
|
storage_path: new_storage_path,
|
|
|
|
|
path_string: new_path_string,
|
|
|
|
|
size: self.size,
|
|
|
|
|
mime_type: self.mime_type.clone(),
|
|
|
|
|
folder_id,
|
|
|
|
|
created_at: self.created_at,
|
|
|
|
|
modified_at: now,
|
2026-03-09 14:34:07 +01:00
|
|
|
owner_id: self.owner_id,
|
2026-06-06 15:37:27 +02:00
|
|
|
blob_hash: self.blob_hash.clone(),
|
2025-03-19 00:44:27 +01:00
|
|
|
})
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-30 14:17:09 +00:00
|
|
|
/// Creates a new version of the file with updated size
|
2025-03-19 00:44:27 +01:00
|
|
|
pub fn with_size(&self, new_size: u64) -> Self {
|
|
|
|
|
let now = std::time::SystemTime::now()
|
2025-03-17 21:28:08 +01:00
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
2025-03-19 00:44:27 +01:00
|
|
|
.unwrap_or_default()
|
2025-03-17 21:28:08 +01:00
|
|
|
.as_secs();
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
Self {
|
|
|
|
|
id: self.id.clone(),
|
|
|
|
|
name: self.name.clone(),
|
|
|
|
|
storage_path: self.storage_path.clone(),
|
|
|
|
|
path_string: self.path_string.clone(),
|
|
|
|
|
size: new_size,
|
|
|
|
|
mime_type: self.mime_type.clone(),
|
|
|
|
|
folder_id: self.folder_id.clone(),
|
|
|
|
|
created_at: self.created_at,
|
|
|
|
|
modified_at: now,
|
2026-03-09 14:34:07 +01:00
|
|
|
owner_id: self.owner_id,
|
2026-06-06 15:37:27 +02:00
|
|
|
blob_hash: self.blob_hash.clone(),
|
2025-03-19 00:44:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
#[test]
|
|
|
|
|
fn test_file_creation_with_valid_name() {
|
|
|
|
|
let storage_path = StoragePath::from_string("/test/file.txt");
|
|
|
|
|
let file = File::new(
|
|
|
|
|
"123".to_string(),
|
|
|
|
|
"file.txt".to_string(),
|
|
|
|
|
storage_path,
|
|
|
|
|
100,
|
|
|
|
|
"text/plain".to_string(),
|
|
|
|
|
None,
|
|
|
|
|
);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
assert!(file.is_ok());
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
#[test]
|
|
|
|
|
fn test_file_creation_with_invalid_name() {
|
|
|
|
|
let storage_path = StoragePath::from_string("/test/invalid/file.txt");
|
|
|
|
|
let file = File::new(
|
|
|
|
|
"123".to_string(),
|
2025-03-30 14:17:09 +00:00
|
|
|
"file/with/slash.txt".to_string(), // Invalid name
|
2025-03-19 00:44:27 +01:00
|
|
|
storage_path,
|
|
|
|
|
100,
|
|
|
|
|
"text/plain".to_string(),
|
|
|
|
|
None,
|
|
|
|
|
);
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
assert!(file.is_err());
|
|
|
|
|
match file {
|
|
|
|
|
Err(FileError::InvalidFileName(_)) => (),
|
|
|
|
|
_ => panic!("Expected InvalidFileName error"),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
#[test]
|
|
|
|
|
fn test_file_with_name() {
|
|
|
|
|
let storage_path = StoragePath::from_string("/test/file.txt");
|
|
|
|
|
let file = File::new(
|
|
|
|
|
"123".to_string(),
|
|
|
|
|
"file.txt".to_string(),
|
|
|
|
|
storage_path,
|
|
|
|
|
100,
|
|
|
|
|
"text/plain".to_string(),
|
|
|
|
|
None,
|
2026-02-14 01:29:34 +01:00
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
2025-03-19 00:44:27 +01:00
|
|
|
let renamed = file.with_name("newname.txt".to_string());
|
|
|
|
|
assert!(renamed.is_ok());
|
|
|
|
|
let renamed = renamed.unwrap();
|
|
|
|
|
assert_eq!(renamed.name(), "newname.txt");
|
2026-02-12 09:41:25 +01:00
|
|
|
assert_eq!(renamed.id(), "123"); // The ID does not change
|
2025-03-17 21:28:08 +01:00
|
|
|
}
|
2026-06-06 15:37:27 +02:00
|
|
|
|
2026-06-06 16:46:19 +02:00
|
|
|
/// The ETag formula is `{blob_hash[..16]}-{modified_at}`. Two
|
|
|
|
|
/// fixtures with identical content + mtime must produce
|
|
|
|
|
/// byte-identical ETags — that's the invariant every handler
|
|
|
|
|
/// relies on when comparing a cached client ETag against a
|
|
|
|
|
/// freshly-loaded one.
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_etag_combines_blob_hash_prefix_and_mtime() {
|
|
|
|
|
let file = File::with_timestamps_and_blob_hash(
|
|
|
|
|
"id-1".to_string(),
|
|
|
|
|
"file.txt".to_string(),
|
|
|
|
|
StoragePath::from_string("/file.txt"),
|
|
|
|
|
42,
|
|
|
|
|
"text/plain".to_string(),
|
|
|
|
|
None,
|
|
|
|
|
1_000,
|
|
|
|
|
2_000,
|
|
|
|
|
None,
|
|
|
|
|
"abcdef0123456789ZZZZZZZZ".to_string(),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
// content_hash stays raw — full blob hash, no truncation.
|
|
|
|
|
assert_eq!(file.content_hash(), "abcdef0123456789ZZZZZZZZ");
|
|
|
|
|
// etag is the 16-char prefix + "-" + mtime.
|
|
|
|
|
assert_eq!(file.etag(), "abcdef0123456789-2000");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// When the blob hash is shorter than 16 chars (test fixtures,
|
|
|
|
|
/// stub entities), the prefix degrades to "whatever is there".
|
|
|
|
|
/// Production blob hashes are always full BLAKE3 hex (64 chars).
|
2026-06-06 15:37:27 +02:00
|
|
|
#[test]
|
2026-06-06 16:46:19 +02:00
|
|
|
fn test_etag_short_blob_hash_uses_full_value() {
|
2026-06-06 15:37:27 +02:00
|
|
|
let file = File::with_timestamps_and_blob_hash(
|
|
|
|
|
"id-1".to_string(),
|
|
|
|
|
"file.txt".to_string(),
|
|
|
|
|
StoragePath::from_string("/file.txt"),
|
|
|
|
|
42,
|
|
|
|
|
"text/plain".to_string(),
|
|
|
|
|
None,
|
|
|
|
|
1_000,
|
|
|
|
|
2_000,
|
|
|
|
|
None,
|
2026-06-06 16:46:19 +02:00
|
|
|
"shorthash".to_string(),
|
2026-06-06 15:37:27 +02:00
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
2026-06-06 16:46:19 +02:00
|
|
|
assert_eq!(file.etag(), "shorthash-2000");
|
2026-06-06 15:37:27 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-06 16:46:19 +02:00
|
|
|
/// `content_hash` is the cryptographic identity of the bytes —
|
|
|
|
|
/// it must NEVER change because of metadata operations like
|
|
|
|
|
/// rename. The ETag is allowed to change (because `with_name`
|
|
|
|
|
/// bumps `modified_at`), but the content hash is not.
|
2026-06-06 15:37:27 +02:00
|
|
|
#[test]
|
2026-06-06 16:46:19 +02:00
|
|
|
fn test_content_hash_stable_across_rename() {
|
2026-06-06 15:37:27 +02:00
|
|
|
let file = File::with_timestamps_and_blob_hash(
|
|
|
|
|
"id-1".to_string(),
|
|
|
|
|
"file.txt".to_string(),
|
|
|
|
|
StoragePath::from_string("/file.txt"),
|
|
|
|
|
42,
|
|
|
|
|
"text/plain".to_string(),
|
|
|
|
|
None,
|
|
|
|
|
1_000,
|
|
|
|
|
2_000,
|
|
|
|
|
None,
|
2026-06-06 16:46:19 +02:00
|
|
|
"stable-content-hash".to_string(),
|
2026-06-06 15:37:27 +02:00
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let renamed = file.with_name("renamed.txt".to_string()).unwrap();
|
2026-06-06 16:46:19 +02:00
|
|
|
assert_eq!(renamed.content_hash(), "stable-content-hash");
|
2026-06-06 15:37:27 +02:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|