Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
148 changes: 141 additions & 7 deletions apps/tauri/src-tauri/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@ use std::{net::Ipv4Addr, path::PathBuf};
use tokio::{fs::File, io, net::TcpListener};
use tracing::{error, info};

/// Validate a path segment to prevent directory traversal attacks.
///
/// Rejects segments containing `..`, path separators, or null bytes. These checks
/// prevent attackers from escaping the intended directory via URL-encoded sequences
/// like `%2E%2E` (which Axum decodes to `..` before we see it).
fn is_safe_path_segment(segment: &str) -> bool {
// Reject empty segments
if segment.is_empty() {
return false;
}

// Reject segments containing path traversal or separator characters
if segment.contains("..")
|| segment.contains('/')
|| segment.contains('\\')
|| segment.contains('\0')
{
return false;
}

true
}

#[derive(Clone)]
pub struct ServerState {
/// Path to the Spacedrive data directory
Expand Down Expand Up @@ -59,15 +82,57 @@ 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> {
// Security: validate all user-provided path segments to prevent directory traversal.
// Axum URL-decodes segments, so `%2E%2E` becomes `..` before reaching this code.
if !is_safe_path_segment(library_id)
|| !is_safe_path_segment(content_uuid)
|| !is_safe_path_segment(kind)
|| !is_safe_path_segment(variant_and_ext)
{
error!(
"Invalid path segment detected: library_id={:?}, content_uuid={:?}, kind={:?}, variant={:?}",
library_id, content_uuid, kind, variant_and_ext
);
return Err(StatusCode::BAD_REQUEST);
}

// 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/detail@1x.webp
// content_uuid is validated above, so indexing is safe (minimum 4 chars for UUID prefix)
if content_uuid.len() < 4 {
error!("Content UUID too short: {:?}", content_uuid);
return Err(StatusCode::BAD_REQUEST);
}
let first_two = &content_uuid[0..2];
let next_two = &content_uuid[2..4];

Expand All @@ -83,11 +148,12 @@ 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
// Secondary defense: verify the constructed path is under the expected root.
// This catches edge cases the segment validation might miss.
let sidecars_root = state.data_dir.join("libraries");
if !sidecar_path.starts_with(&sidecars_root) {
error!(
Expand All @@ -100,16 +166,82 @@ 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> {
// Security: validate all user-provided path segments to prevent directory traversal.
// Axum URL-decodes segments, so `%2E%2E` becomes `..` before reaching this code.
if !is_safe_path_segment(library_id)
|| !is_safe_path_segment(entry_uuid)
|| !is_safe_path_segment(kind)
|| !is_safe_path_segment(variant_and_ext)
{
error!(
"Invalid path segment in ephemeral: library_id={:?}, entry_uuid={:?}, kind={:?}, variant={:?}",
library_id, entry_uuid, kind, variant_and_ext
);
return Err(StatusCode::BAD_REQUEST);
}

// 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);

// Secondary defense: verify the constructed path is under the expected root.
// This catches edge cases the segment validation might miss.
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 +253,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