Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .tasks/core/VSS-006-ephemeral-sidecar-system.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
---
id: VSS-006
title: Ephemeral Sidecar System
status: To Do
status: Done
assignee: jamiepine
parent: CORE-008
priority: High
tags: [core, vdfs, sidecars, ephemeral, thumbnails]
last_updated: 2025-12-24
related_tasks: [CORE-008, VSS-001, VSS-002, INDEX-000]
completed_date: 2025-12-24
---

## Overview
Expand Down
90 changes: 84 additions & 6 deletions apps/tauri/src-tauri/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,33 @@ async fn find_library_folder(
}

/// Serve a sidecar file (e.g., thumbnail)
///
/// Supports both managed sidecars (content-addressed in library folder) and
/// ephemeral sidecars (entry-addressed in temp directory). Tries managed
/// first, falls back to ephemeral if not found.
async fn serve_sidecar(
State(state): State<ServerState>,
Path((library_id, content_uuid, kind, variant_and_ext)): Path<(String, String, String, String)>,
) -> Result<Response<Body>, StatusCode> {
// Try managed sidecar first (content-addressed)
if let Ok(response) = serve_managed_sidecar(&state, &library_id, &content_uuid, &kind, &variant_and_ext).await {
return Ok(response);
}

// Fall back to ephemeral sidecar (entry-addressed)
serve_ephemeral_sidecar(&state, &library_id, &content_uuid, &kind, &variant_and_ext).await
}

/// Serve a managed sidecar (content-addressed in library folder)
async fn serve_managed_sidecar(
state: &ServerState,
library_id: &str,
content_uuid: &str,
kind: &str,
variant_and_ext: &str,
) -> Result<Response<Body>, StatusCode> {
// Find the actual library folder (might be named differently than the ID)
let library_folder = find_library_folder(&state.data_dir, &library_id).await?;
let library_folder = find_library_folder(&state.data_dir, library_id).await?;

// Actual path structure: sidecars/content/{first2}/{next2}/{uuid}/{kind}s/{variant}.{ext}
// Example: sidecars/content/0c/c0/0cc0b48f-a475-53ec-a580-bc7d47b486a9/thumbs/[email protected]
Expand All @@ -83,9 +104,9 @@ async fn serve_sidecar(
.join("content")
.join(first_two)
.join(next_two)
.join(&content_uuid)
.join(content_uuid)
.join(&kind_dir)
.join(&variant_and_ext);
.join(variant_and_ext);

// Security: prevent directory traversal
let sidecars_root = state.data_dir.join("libraries");
Expand All @@ -100,16 +121,71 @@ async fn serve_sidecar(
// Open the file
let file = File::open(&sidecar_path).await.map_err(|e| {
if e.kind() == io::ErrorKind::NotFound {
error!("Sidecar file not found: {:?}", sidecar_path);
StatusCode::NOT_FOUND
} else {
error!("Error opening sidecar {:?}: {}", sidecar_path, e);
error!("Error opening managed sidecar {:?}: {}", sidecar_path, e);
StatusCode::INTERNAL_SERVER_ERROR
}
})?;

serve_file(file, variant_and_ext).await
}

/// Serve an ephemeral sidecar (entry-addressed in temp directory)
async fn serve_ephemeral_sidecar(
state: &ServerState,
library_id: &str,
entry_uuid: &str,
kind: &str,
variant_and_ext: &str,
) -> Result<Response<Body>, StatusCode> {
// Ephemeral path structure: /tmp/spacedrive-ephemeral-{library_id}/sidecars/entry/{entry_uuid}/{kind}s/{variant}.{ext}
let temp_root = std::env::temp_dir()
.join(format!("spacedrive-ephemeral-{}", library_id))
.join("sidecars");

let kind_dir = if kind == "transcript" {
kind.to_string()
} else {
format!("{}s", kind)
};

let sidecar_path = temp_root
.join("entry")
.join(entry_uuid)
.join(&kind_dir)
.join(variant_and_ext);

// Security: ensure path is under temp_root
if !sidecar_path.starts_with(&temp_root) {
error!(
"Directory traversal attempt in ephemeral: {:?} not under {:?}",
sidecar_path, temp_root
);
return Err(StatusCode::FORBIDDEN);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directory traversal check ineffective on non-canonicalized paths

The serve_ephemeral_sidecar function uses starts_with on a non-canonicalized path to prevent directory traversal, but this check is ineffective. URL path segments like entry_uuid or variant_and_ext can contain .. sequences (URL-encoded as %2E%2E). The starts_with method compares path components literally, so a path like /tmp/.../entry/../../../etc/passwd passes the check because its first components match temp_root, even though File::open will resolve the .. components and access files outside the intended directory. An attacker could read arbitrary files accessible to the server process.

Fix in Cursor Fix in Web


// Open the file
let file = File::open(&sidecar_path).await.map_err(|e| {
if e.kind() == io::ErrorKind::NotFound {
StatusCode::NOT_FOUND
} else {
error!("Error opening ephemeral sidecar {:?}: {}", sidecar_path, e);
StatusCode::INTERNAL_SERVER_ERROR
}
})?;

serve_file(file, variant_and_ext).await
}

/// Common file serving logic
async fn serve_file(
file: File,
variant_and_ext: &str,
) -> Result<Response<Body>, StatusCode> {

let metadata = file.metadata().await.map_err(|e| {
error!("Error reading metadata for {:?}: {}", sidecar_path, e);
error!("Error reading file metadata: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;

Expand All @@ -121,6 +197,8 @@ async fn serve_sidecar(
"webp" => Some("image/webp"),
"jpg" | "jpeg" => Some("image/jpeg"),
"png" => Some("image/png"),
"mp4" => Some("video/mp4"),
"txt" => Some("text/plain"),
_ => None,
})
.unwrap_or("application/octet-stream");
Expand Down
24 changes: 24 additions & 0 deletions core/src/infra/event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,30 @@ pub enum Event {
resource_id: Uuid,
},

// Ephemeral sidecar events
/// Ephemeral sidecar was generated for a specific entry
EphemeralSidecarGenerated {
/// Library ID
library_id: Uuid,
/// Entry UUID (ephemeral, not content UUID)
entry_uuid: Uuid,
/// Sidecar kind (e.g., "thumb", "preview", "transcript")
kind: String,
/// Sidecar variant (e.g., "grid@1x", "detail@2x")
variant: String,
/// File format (e.g., "webp", "mp4", "txt")
format: String,
/// File size in bytes
size: u64,
},
/// Ephemeral sidecars were cleared for a library or session
EphemeralSidecarsCleared {
/// Library ID
library_id: Uuid,
/// Number of entries whose sidecars were removed
count: usize,
},

// Legacy events (for compatibility)
LocationAdded {
library_id: Uuid,
Expand Down
125 changes: 125 additions & 0 deletions core/src/ops/core/ephemeral_sidecars/list_query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//! List ephemeral sidecars query
//!
//! Returns all sidecars (thumbnails, previews, etc.) for a specific ephemeral
//! entry. Scans the temp directory to find what derivatives exist.

use crate::{
context::CoreContext,
infra::query::{CoreQuery, QueryResult},
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{path::PathBuf, sync::Arc};
use uuid::Uuid;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import. PathBuf isn't used anywhere in this file.

/// Input for listing ephemeral sidecars
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct ListEphemeralSidecarsInput {
/// Entry UUID to list sidecars for
pub entry_uuid: Uuid,
/// Library ID
pub library_id: Uuid,
}

/// Information about a single ephemeral sidecar
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct EphemeralSidecarInfo {
/// Sidecar kind (e.g., "thumb", "preview", "transcript")
pub kind: String,
/// Sidecar variant (e.g., "grid@1x", "detail@2x")
pub variant: String,
/// File format (e.g., "webp", "mp4", "txt")
pub format: String,
/// File size in bytes
pub size: u64,
/// Relative path within temp directory (for debugging)
pub path: String,
}

/// Output containing ephemeral sidecar information
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct ListEphemeralSidecarsOutput {
/// List of sidecars found for this entry
pub sidecars: Vec<EphemeralSidecarInfo>,
/// Total number of sidecars
pub total: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct ListEphemeralSidecarsQuery {
input: ListEphemeralSidecarsInput,
}

impl CoreQuery for ListEphemeralSidecarsQuery {
type Input = ListEphemeralSidecarsInput;
type Output = ListEphemeralSidecarsOutput;

fn from_input(input: Self::Input) -> QueryResult<Self> {
Ok(Self { input })
}

async fn execute(
self,
context: Arc<CoreContext>,
_session: crate::infra::api::SessionContext,
) -> QueryResult<Self::Output> {
let cache = context.ephemeral_cache();
let sidecar_cache = cache.get_sidecar_cache(self.input.library_id);

// Get the entry directory
let entry_dir = sidecar_cache.compute_entry_dir(&self.input.entry_uuid);

if !tokio::fs::try_exists(&entry_dir).await? {
return Ok(ListEphemeralSidecarsOutput {
sidecars: Vec::new(),
total: 0,
});
}

let mut sidecars = Vec::new();

// Scan the entry directory for sidecar kind directories
let mut read_dir = tokio::fs::read_dir(&entry_dir).await?;
while let Some(kind_entry) = read_dir.next_entry().await? {
let kind_name = kind_entry.file_name().to_string_lossy().to_string();

// Convert plural back to singular (thumbs -> thumb, etc.)
let kind = if kind_name == "transcript" {
kind_name.clone()
} else {
kind_name.trim_end_matches('s').to_string()
};

// Scan files within the kind directory
let mut files_dir = tokio::fs::read_dir(kind_entry.path()).await?;
while let Some(file_entry) = files_dir.next_entry().await? {
let filename = file_entry.file_name().to_string_lossy().to_string();

// Parse filename as "variant.format"
if let Some((variant, format)) = filename.rsplit_once('.') {
let metadata = file_entry.metadata().await?;
let relative_path = file_entry
.path()
.strip_prefix(sidecar_cache.temp_root())
.unwrap_or(&file_entry.path())
.to_string_lossy()
.to_string();

sidecars.push(EphemeralSidecarInfo {
kind: kind.clone(),
variant: variant.to_string(),
format: format.to_string(),
size: metadata.len(),
path: relative_path,
});
}
}
}

let total = sidecars.len();

Ok(ListEphemeralSidecarsOutput { sidecars, total })
}
}

crate::register_core_query!(ListEphemeralSidecarsQuery, "core.ephemeral_sidecars.list");
12 changes: 12 additions & 0 deletions core/src/ops/core/ephemeral_sidecars/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//! Ephemeral sidecar operations
//!
//! Queries and actions for managing ephemeral sidecars (thumbnails, previews,
//! etc.) for ephemeral entries. Unlike managed sidecars which are persistent
//! and database-tracked, ephemeral sidecars live in temp storage and are
//! queried directly from the filesystem.

pub mod list_query;
pub mod request_action;

pub use list_query::{ListEphemeralSidecarsInput, ListEphemeralSidecarsOutput};
pub use request_action::{RequestEphemeralThumbnailsInput, RequestEphemeralThumbnailsOutput};
Loading
Loading