Merge pull request #521 from BCNelson/feat/external-file-mounts

feat(mounts): external file mounts — pluggable provider, read-write, WebDAV/NextCloud, admin UI
This commit is contained in:
Dionisio Pozo
2026-07-22 06:52:43 +02:00
committed by GitHub
40 changed files with 5920 additions and 76 deletions
@@ -0,0 +1,214 @@
//! Admin CRUD for external file mounts (`/api/admin/external-mounts`).
//!
//! Creating a mount: validate the backend config, create a mount-root folder
//! under the admin's drive, insert the `external_mounts` row, then hot-reload
//! the in-memory registry. Deleting: remove the row + the folder and reload.
//! Every endpoint is admin-gated.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::application::ports::external_mount_ports::{
ExternalMountRecord, ExternalMountRepositoryPort, MountProviderFactory, NewExternalMount,
};
use crate::common::di::AppState;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::infrastructure::repositories::pg::ExternalMountPgRepository;
use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::admin::require_admin;
/// JSON view of a configured mount.
#[derive(Debug, Serialize)]
pub struct ExternalMountResponse {
pub mount_folder_id: String,
pub name: String,
pub kind: String,
pub owner_id: String,
pub read_only: bool,
pub drive_id: String,
pub mount_path: String,
pub config: serde_json::Value,
}
impl From<ExternalMountRecord> for ExternalMountResponse {
fn from(r: ExternalMountRecord) -> Self {
Self {
mount_folder_id: r.mount_folder_id.to_string(),
name: r.name,
kind: r.kind,
owner_id: r.owner_id.to_string(),
read_only: r.read_only,
drive_id: r.drive_id.to_string(),
mount_path: r.mount_path,
config: r.config,
}
}
}
/// Request body for creating a mount.
#[derive(Debug, Deserialize)]
pub struct CreateExternalMountRequest {
/// Display name (also the mount-root folder name).
pub name: String,
/// Absolute host path for the `local_fs` provider.
pub host_path: String,
/// Provider kind. Defaults to `local_fs`.
#[serde(default = "default_kind")]
pub kind: String,
/// When true, the mount refuses all mutations.
#[serde(default)]
pub read_only: bool,
}
fn default_kind() -> String {
"local_fs".to_string()
}
fn pool(state: &AppState) -> Result<Arc<sqlx::PgPool>, AppError> {
state
.db_pool
.clone()
.ok_or_else(|| AppError::internal_error("Database not available"))
}
/// `GET /api/admin/external-mounts` — list all configured mounts.
pub async fn list_external_mounts(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
require_admin(&state, &headers).await?;
let repo = ExternalMountPgRepository::new(pool(&state)?);
let mounts = repo
.list_all()
.await
.map_err(|e| AppError::internal_error(format!("list external mounts: {e}")))?;
let out: Vec<ExternalMountResponse> = mounts.into_iter().map(Into::into).collect();
Ok(Json(out))
}
/// `POST /api/admin/external-mounts` — create a mount in the admin's drive.
pub async fn create_external_mount(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(req): Json<CreateExternalMountRequest>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _role) = require_admin(&state, &headers).await?;
if req.name.trim().is_empty() {
return Err(AppError::bad_request("Mount name must not be empty"));
}
// Build the provider config and validate it up front (path exists, etc.).
let config = serde_json::json!({ "path": req.host_path, "read_only": req.read_only });
let factory = DefaultMountProviderFactory::new();
factory
.build(&req.kind, &config)
.await
.map_err(|e| AppError::bad_request(format!("invalid mount configuration: {e}")))?;
// Create the mount-root folder under the admin's default drive root.
let drive = state
.drive_repo
.find_default_for_user(admin_id)
.await
.map_err(|e| AppError::internal_error(format!("find default drive: {e}")))?;
let root_folder_id = drive.drive.root_folder_id.to_string();
let folder = state
.repositories
.folder_repository
.create_folder(req.name.clone(), Some(root_folder_id), admin_id)
.await
.map_err(AppError::from)?;
let mount_folder_id =
Uuid::parse_str(folder.id()).map_err(|_| AppError::internal_error("bad folder id"))?;
let repo = ExternalMountPgRepository::new(pool(&state)?);
repo.create(&NewExternalMount {
mount_folder_id,
kind: req.kind.clone(),
config,
name: req.name.clone(),
owner_id: admin_id,
read_only: req.read_only,
})
.await
.map_err(|e| AppError::internal_error(format!("create mount row: {e}")))?;
// Hot-reload so the new mount is live immediately.
state.mount_router.registry().reload(&repo, &factory).await;
tracing::info!(
target: "audit",
event = "external_mount.config",
action = "create",
mount_id = %mount_folder_id,
caller_id = %admin_id,
kind = %req.kind,
reason = "external_mount_admin",
"👮🏻‍♂️ external mount created",
);
// Return the freshly created mount.
let created = repo
.list_all()
.await
.map_err(|e| AppError::internal_error(format!("reload mounts: {e}")))?
.into_iter()
.find(|m| m.mount_folder_id == mount_folder_id)
.map(ExternalMountResponse::from)
.ok_or_else(|| AppError::internal_error("created mount not found"))?;
Ok((StatusCode::CREATED, Json(created)))
}
/// `DELETE /api/admin/external-mounts/{id}` — remove a mount (and its root
/// folder). The host filesystem content is untouched.
pub async fn delete_external_mount(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _role) = require_admin(&state, &headers).await?;
let repo = ExternalMountPgRepository::new(pool(&state)?);
let removed = repo
.delete(id)
.await
.map_err(|e| AppError::internal_error(format!("delete mount row: {e}")))?;
if !removed {
return Err(AppError::not_found("Mount not found"));
}
// Remove the mount-root folder row (host content is left intact).
state
.repositories
.folder_repository
.delete_folder(&id.to_string())
.await
.map_err(AppError::from)?;
let factory = DefaultMountProviderFactory::new();
state.mount_router.registry().reload(&repo, &factory).await;
tracing::info!(
target: "audit",
event = "external_mount.config",
action = "delete",
mount_id = %id,
caller_id = %admin_id,
reason = "external_mount_admin",
"👮🏻‍♂️ external mount deleted",
);
Ok(StatusCode::NO_CONTENT)
}
@@ -52,7 +52,17 @@ struct AdminUsersPageResponse {
/// Admin API routes — all require admin role.
pub fn admin_routes() -> Router<Arc<AppState>> {
use super::admin_external_mounts as ext_mounts;
Router::new()
// External file mounts
.route(
"/external-mounts",
get(ext_mounts::list_external_mounts).post(ext_mounts::create_external_mount),
)
.route(
"/external-mounts/{id}",
delete(ext_mounts::delete_external_mount),
)
// OIDC settings
.route("/settings/oidc", get(get_oidc_settings))
.route("/settings/oidc", put(save_oidc_settings))
+268
View File
@@ -11,13 +11,18 @@ use serde::Deserialize;
use std::collections::HashMap;
use utoipa::ToSchema;
use crate::application::ports::external_mount_ports::MountStat;
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, RangeContent,
};
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
use crate::application::ports::thumbnail_ports::ThumbnailPort;
use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::FolderUseCase};
use crate::application::services::external_mount_router::ResolvedId;
use crate::application::services::mount_registry::MountConfig;
use crate::common::di::AppState;
use crate::domain::errors::DomainError;
use crate::domain::services::external_mount_id::{NodeId, virtual_file_etag};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use crate::interfaces::range_requests::not_modified_response;
@@ -251,6 +256,35 @@ impl FileHandler {
}
}
// ── External mount destination? Stream to the provider ──
// Detected BEFORE the CAS ingest so the bytes never touch
// BLAKE3/dedup. Authorization happens inside the service.
if let Some(ref fid) = folder_id {
let (mount_cfg, parent_node) = match state.mount_router.classify(fid) {
ResolvedId::MountRoot { cfg } => (Some(cfg), NodeId::default()),
ResolvedId::MountChild { cfg, node_id } => (Some(cfg), node_id),
ResolvedId::Regular => (None, NodeId::default()),
};
if let Some(cfg) = mount_cfg {
use futures::StreamExt;
let body: crate::application::ports::external_mount_ports::MountByteStream<
'_,
> = Box::pin(
upload_ingest::multipart_field_stream(field)
.map(|r| r.map_err(|e| std::io::Error::other(e.to_string()))),
);
return match state
.applications
.external_upload_service
.write_file(&cfg, &parent_node, &filename, body, auth_user.id)
.await
{
Ok(file) => Ok((file, String::new())),
Err(err) => Err(Self::domain_error_response(err)),
};
}
}
// ── Stream the field into the CDC chunk store ────────
// Chunking (FastCDC) + hashing (BLAKE3) + dedup checks +
// MIME sniffing all happen while the bytes arrive; chunks
@@ -683,6 +717,22 @@ impl FileHandler {
Query(params): Query<HashMap<String, String>>,
headers: &HeaderMap,
) -> impl IntoResponse + use<> {
// External mount: download a file living on the provider's backend.
// (A mount-root UUID is a folder and is not downloadable — it falls
// through and 404s as a non-file.)
if let ResolvedId::MountChild { cfg, node_id } = state.mount_router.classify(&id) {
return Self::download_mount_file(
&state,
&cfg,
&node_id,
&id,
auth_user.id,
&params,
headers,
)
.await;
}
let retrieval = &state.applications.file_retrieval_service;
// ── Get file metadata (ownership-scoped) ────────────────────────
@@ -838,6 +888,134 @@ impl FileHandler {
}
}
/// Download a file living inside an external mount: stat via the provider
/// (authorized against the mount root), then serve metadata / 304 / Range /
/// full stream straight from the backend. No blob cache, dedup, or WebP
/// transcode — mount content is served as-is.
#[allow(clippy::too_many_arguments)]
pub(super) async fn download_mount_file(
state: &AppState,
cfg: &MountConfig,
node_id: &NodeId,
id: &str,
caller_id: uuid::Uuid,
params: &HashMap<String, String>,
headers: &HeaderMap,
) -> axum::response::Response {
let retrieval = &state.applications.file_retrieval_service;
let stat: MountStat = match retrieval
.stat_mount_file_with_perms(cfg, node_id, caller_id)
.await
{
Ok(s) => s,
Err(err) => return AppError::from(err).into_response(),
};
if stat.is_dir {
// Directories are not downloadable through this endpoint.
return AppError::from(DomainError::not_found("File", id)).into_response();
}
let name = node_id
.as_str()
.rsplit('/')
.next()
.unwrap_or_else(|| node_id.as_str());
// ── Metadata-only request ────────────────────────────────────
if params
.get("metadata")
.is_some_and(|v| v == "true" || v == "1")
{
return (
StatusCode::OK,
Json(serde_json::json!({
"id": id,
"name": name,
"size": stat.size,
"mime_type": stat.mime_type,
"modified_at": stat.modified_at,
})),
)
.into_response();
}
let etag = format!("\"{}\"", virtual_file_etag(stat.size, stat.modified_at));
if let Some(resp) = not_modified_response(headers, &etag) {
return resp.into_response();
}
// ── Range Requests ───────────────────────────────────────────
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
match plan_mount_range(stat.size, range_header) {
MountRangePlan::Full => {}
MountRangePlan::NotSatisfiable => {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", stat.size))
.body(Body::empty())
.unwrap()
.into_response();
}
MountRangePlan::Range { start, end } => {
let range_length = end - start + 1;
let disposition = Self::content_disposition(name, &stat.mime_type, params);
match retrieval
.open_mount_file_with_perms(cfg, node_id, caller_id, Some((start, Some(end))))
.await
{
Ok(stream) => {
return Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &stat.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, range_length)
.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, stat.size),
)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.body(Body::from_stream(stream))
.unwrap()
.into_response();
}
Err(err) => {
tracing::error!("Error creating mount range stream: {}", err);
// fall through to full download
}
}
}
}
// ── Normal download ──────────────────────────────────────────
let disposition = Self::content_disposition(name, &stat.mime_type, params);
match retrieval
.open_mount_file_with_perms(cfg, node_id, caller_id, None)
.await
{
Ok(stream) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &stat.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, stat.size)
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(stream))
.unwrap()
.into_response(),
Err(err) => AppError::from(err).into_response(),
}
}
// ═══════════════════════════════════════════════════════════════════════
// LIST
// ═══════════════════════════════════════════════════════════════════════
@@ -1472,3 +1650,93 @@ pub async fn move_file_simple(
) -> impl IntoResponse {
FileHandler::move_file_simple_impl(state, auth_user, path, json).await
}
/// The download decision for a mount file given a `Range` header — the gnarly
/// parse-and-validate logic, extracted so it is unit-testable without I/O.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum MountRangePlan {
/// Serve the whole file (no/invalid range header).
Full,
/// Serve `start..=end` (inclusive) as 206 Partial Content.
Range { start: u64, end: u64 },
/// The requested range is unsatisfiable for this size → 416.
NotSatisfiable,
}
/// Decide how to serve a mount file for a given size + optional `Range` header.
/// A missing or unparseable range → `Full`; a valid range → `Range`; an
/// out-of-bounds range → `NotSatisfiable`.
pub(super) fn plan_mount_range(size: u64, range_header: Option<&str>) -> MountRangePlan {
let Some(rh) = range_header else {
return MountRangePlan::Full;
};
let Ok(ranges) = parse_range_header(rh) else {
// Malformed range header: ignore it and serve the whole file (RFC 7233).
return MountRangePlan::Full;
};
match ranges.validate(size) {
Ok(valid) => match valid.first() {
Some(r) => MountRangePlan::Range {
start: *r.start(),
end: *r.end(),
},
None => MountRangePlan::Full,
},
Err(_) => MountRangePlan::NotSatisfiable,
}
}
#[cfg(test)]
mod mount_range_tests {
use super::{MountRangePlan, plan_mount_range};
#[test]
fn no_range_header_is_full() {
assert_eq!(plan_mount_range(100, None), MountRangePlan::Full);
}
#[test]
fn malformed_range_falls_back_to_full() {
assert_eq!(
plan_mount_range(100, Some("not-a-range")),
MountRangePlan::Full
);
assert_eq!(
plan_mount_range(100, Some("bytes=abc")),
MountRangePlan::Full
);
}
#[test]
fn valid_range_is_parsed_inclusive() {
assert_eq!(
plan_mount_range(100, Some("bytes=10-19")),
MountRangePlan::Range { start: 10, end: 19 }
);
}
#[test]
fn open_ended_range_extends_to_eof() {
assert_eq!(
plan_mount_range(100, Some("bytes=90-")),
MountRangePlan::Range { start: 90, end: 99 }
);
}
#[test]
fn suffix_range_counts_from_end() {
// last 10 bytes of a 100-byte file => 90..=99
assert_eq!(
plan_mount_range(100, Some("bytes=-10")),
MountRangePlan::Range { start: 90, end: 99 }
);
}
#[test]
fn out_of_bounds_range_is_not_satisfiable() {
assert_eq!(
plan_mount_range(100, Some("bytes=200-300")),
MountRangePlan::NotSatisfiable
);
}
}
+217 -1
View File
@@ -7,7 +7,8 @@ use axum::{
use std::sync::Arc;
use crate::application::dtos::display_helpers::{
classify_display, format_file_size, intern_display, intern_mime,
category_for, classify_display, format_file_size, icon_class_for, icon_special_class_for,
intern_display, intern_mime,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{
@@ -15,11 +16,17 @@ use crate::application::dtos::folder_dto::{
ListResourcesOptions, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::ports::external_mount_ports::MountEntry;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::external_mount_router::ResolvedId;
use crate::application::services::folder_service::FolderService;
use crate::application::services::mount_registry::MountConfig;
use crate::common::di::AppState as GlobalAppState;
use crate::domain::entities::file::File;
use crate::domain::services::external_mount_id::{
NodeId, encode_child_id, virtual_file_etag, virtual_folder_etag,
};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
@@ -190,6 +197,22 @@ impl FolderHandler {
Path(id): Path<String>,
) -> impl IntoResponse {
let user_id = auth_user.id;
// External mounts have no trash — a permanent provider delete is the
// only option. Route `ext:` ids straight to the mount-aware service
// delete, skipping the (always-failing) trash attempt.
if state.mount_router.is_mount_id(&id) {
return match state
.applications
.folder_service
.delete_folder_with_perms(&id, user_id)
.await
{
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) => AppError::from(err).into_response(),
};
}
// Check if trash service is available
// FIXME: permissions !!
if let Some(trash_service) = &state.trash_service {
@@ -481,6 +504,28 @@ pub async fn list_folder_resources(
reverse: q.reverse,
};
// External mount branch: a mount-root UUID or an `ext:` id lists live from
// the provider instead of the PostgreSQL UNION. The parent of each entry is
// the requested id itself.
match service.mount_router().classify(&id) {
ResolvedId::MountRoot { cfg } => {
return list_mount_dir_response(
&service,
&cfg,
&NodeId::default(),
&id,
auth_user.id,
opts,
)
.await;
}
ResolvedId::MountChild { cfg, node_id } => {
return list_mount_dir_response(&service, &cfg, &node_id, &id, auth_user.id, opts)
.await;
}
ResolvedId::Regular => {}
}
match service
.list_resources_paged_with_perms(&id, auth_user.id, opts)
.await
@@ -585,3 +630,174 @@ pub async fn list_folder_resources(
Err(e) => AppError::from(e).into_response(),
}
}
/// List one directory inside an external mount and render the standard
/// `/resources` envelope, mapping each live provider entry to a
/// `FolderResourceItemDto` with a synthetic `ext:` id. `parent_id` is the
/// requested id (the directory being listed), which becomes each entry's parent.
async fn list_mount_dir_response(
service: &FolderService,
cfg: &MountConfig,
node_id: &NodeId,
parent_id: &str,
caller_id: uuid::Uuid,
opts: ListResourcesOptions<'_>,
) -> axum::response::Response {
match service
.list_mount_dir_with_perms(cfg, node_id, caller_id, opts)
.await
{
Ok((entries, next_cursor)) => {
let items: Vec<FolderResourceItemDto> = entries
.into_iter()
.map(|entry| mount_entry_to_item(cfg, parent_id, entry))
.collect();
(
StatusCode::OK,
Json(FolderResourcesDto::with_cursor(items, next_cursor)),
)
.into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}
/// Map a live mount entry to a `/resources` item with a synthetic `ext:` id and
/// virtual (size+mtime / mtime) etag. Mount entries have no blob hash.
fn mount_entry_to_item(
cfg: &MountConfig,
parent_id: &str,
entry: MountEntry,
) -> FolderResourceItemDto {
let id = encode_child_id(cfg.mount_id, entry.node_id.clone());
if entry.is_dir {
let dto = FolderDto {
etag: virtual_folder_etag(entry.modified_at),
id,
name: entry.name.clone(),
path: String::new(),
parent_id: Some(parent_id.to_owned()),
drive_id: cfg.drive_id,
created_at: entry.created_at,
modified_at: entry.modified_at,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
created_by: Some(cfg.owner_id),
updated_by: Some(cfg.owner_id),
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::Folder,
resource: ResourceContentDto::Folder(dto),
}
} else {
let mime = mime_guess::from_path(&entry.name)
.first_or_octet_stream()
.to_string();
let dto = FileDto {
id,
name: entry.name.clone(),
path: String::new(),
size: entry.size,
mime_type: Arc::from(mime.as_str()),
folder_id: Some(parent_id.to_owned()),
created_at: entry.created_at,
modified_at: entry.modified_at,
icon_class: Arc::from(icon_class_for(&entry.name, &mime)),
icon_special_class: Arc::from(icon_special_class_for(&entry.name, &mime)),
category: Arc::from(category_for(&entry.name, &mime)),
size_formatted: format_file_size(entry.size),
sort_date: None,
content_hash: String::new(),
etag: virtual_file_etag(entry.size, entry.modified_at),
created_by: Some(cfg.owner_id),
updated_by: Some(cfg.owner_id),
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::File,
resource: ResourceContentDto::File(dto),
}
}
}
#[cfg(test)]
mod mount_mapping_tests {
use super::*;
use crate::application::services::mount_registry::MountConfig;
use crate::infrastructure::services::local_fs_mount_provider::LocalFsMountProvider;
use uuid::Uuid;
fn config() -> MountConfig {
let dir = tempfile::tempdir().unwrap();
// Leak the tempdir so the path stays valid for the provider's lifetime;
// the provider is never exercised here (mapping is pure metadata).
let path = dir.keep();
MountConfig {
mount_id: Uuid::new_v4(),
kind: "local_fs".to_string(),
name: "Media".to_string(),
owner_id: Uuid::new_v4(),
drive_id: Uuid::new_v4(),
read_only: false,
mount_path: "Personal/Media".to_string(),
provider: Arc::new(LocalFsMountProvider::new(&path, false).unwrap()),
}
}
fn mount_entry(name: &str, node_id: &str, is_dir: bool, size: u64, mtime: u64) -> MountEntry {
MountEntry {
name: name.to_string(),
node_id: NodeId(node_id.to_string()),
is_dir,
size,
modified_at: mtime,
created_at: mtime,
}
}
#[test]
fn maps_folder_entry_to_item() {
let cfg = config();
let parent = cfg.mount_id.to_string();
let item = mount_entry_to_item(&cfg, &parent, mount_entry("docs", "docs", true, 0, 1234));
assert!(matches!(item.resource_type, ResourceTypeDto::Folder));
let ResourceContentDto::Folder(dto) = item.resource else {
panic!("expected folder");
};
assert_eq!(dto.name, "docs");
// id is the synthetic ext: envelope for (mount_id, node_id).
assert_eq!(dto.id, encode_child_id(cfg.mount_id, "docs"));
assert_eq!(dto.parent_id.as_deref(), Some(parent.as_str()));
assert_eq!(dto.etag, virtual_folder_etag(1234));
assert_eq!(dto.drive_id, cfg.drive_id);
assert!(!dto.is_root);
// Hierarchy is intentionally cleared on this listing.
assert_eq!(dto.path, "");
}
#[test]
fn maps_file_entry_to_item_with_virtual_etag_and_no_hash() {
let cfg = config();
let parent = encode_child_id(cfg.mount_id, "docs");
let item = mount_entry_to_item(
&cfg,
&parent,
mount_entry("report.json", "docs/report.json", false, 42, 999),
);
assert!(matches!(item.resource_type, ResourceTypeDto::File));
let ResourceContentDto::File(dto) = item.resource else {
panic!("expected file");
};
assert_eq!(dto.id, encode_child_id(cfg.mount_id, "docs/report.json"));
assert_eq!(dto.folder_id.as_deref(), Some(parent.as_str()));
assert_eq!(dto.size, 42);
assert_eq!(dto.etag, virtual_file_etag(42, 999));
// Virtual files have no blob hash.
assert_eq!(dto.content_hash, "");
// Mime is sniffed from the name.
assert_eq!(&*dto.mime_type, "application/json");
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod admin_external_mounts;
pub mod admin_handler;
pub mod app_password_handler;
pub mod auth_handler;