feat(web): panneau Fichiers — navigation projet + téléchargements fichier/dossier ZIP — #211

This commit is contained in:
2026-08-15 10:38:53 +02:00
parent bb1fdecf7c
commit 79fc3d184d
22 changed files with 1648 additions and 78 deletions

View File

@ -195,8 +195,9 @@ pub use plugin::{
};
pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
OpenProjectInput, OpenProjectOutput, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
CreateProjectInput, CreateProjectOutput, ListProjectFiles, ListProjectFilesInput, ListProjects,
ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput, ProjectFileEntry,
ProjectFileKind, ProjectFileListing, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
PROJECT_CONTEXT_FILE,
};

View File

@ -4565,6 +4565,7 @@ mod tests {
is_file: true,
is_dir: false,
len: Some(bytes.len() as u64),
modified_at_ms: None,
});
}
if files
@ -4575,6 +4576,7 @@ mod tests {
is_file: false,
is_dir: true,
len: None,
modified_at_ms: None,
});
}
Err(domain::ports::FsError::NotFound(path.as_str().to_owned()))

View File

@ -0,0 +1,216 @@
//! Project file browser use case for the human web surface.
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use domain::ports::{FileSystem, RemotePath};
use domain::Project;
use serde::Serialize;
use crate::error::AppError;
/// Input for [`ListProjectFiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListProjectFilesInput {
/// Opened project.
pub project: Project,
/// Relative directory path inside the project root.
pub path: String,
}
/// Output of [`ListProjectFiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectFileListing {
/// Relative path of the listed directory.
pub path: String,
/// Direct children of the listed directory.
pub entries: Vec<ProjectFileEntry>,
}
/// One file-browser entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectFileEntry {
/// Basename displayed in the current directory listing.
pub name: String,
/// Relative path from the project root.
pub path: String,
/// File or directory kind.
pub kind: ProjectFileKind,
/// File size in bytes; absent for directories.
#[serde(skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
/// Last modification timestamp as epoch milliseconds when available.
#[serde(skip_serializing_if = "Option::is_none")]
pub modified_at_ms: Option<u64>,
}
/// Entry kind for the web contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProjectFileKind {
/// Regular file.
File,
/// Directory.
Directory,
}
/// Lists a project directory with root confinement and hidden `.ideai/`.
pub struct ListProjectFiles {
fs: Arc<dyn FileSystem>,
}
impl ListProjectFiles {
/// Builds the use case.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// Lists one project directory.
pub async fn execute(
&self,
input: ListProjectFilesInput,
) -> Result<ProjectFileListing, AppError> {
let root = canonical_project_root(self.fs.as_ref(), &input.project).await?;
let relative = validate_project_relative_path(&input.path)?;
let directory = confined_project_path(self.fs.as_ref(), &root, &relative).await?;
let meta = self
.fs
.metadata(&RemotePath::new(path_string(&directory)))
.await?;
if !meta.is_dir {
return Err(AppError::Invalid(
"project file path is not a directory".to_owned(),
));
}
let mut entries = Vec::new();
for entry in self
.fs
.list(&RemotePath::new(path_string(&directory)))
.await?
{
let name = entry.name;
if name == ".ideai" {
continue;
}
let path = join_relative(&relative, &name);
let full_path = confined_project_path(self.fs.as_ref(), &root, &path).await?;
let meta = self
.fs
.metadata(&RemotePath::new(path_string(&full_path)))
.await?;
let kind = if meta.is_dir {
ProjectFileKind::Directory
} else if meta.is_file {
ProjectFileKind::File
} else {
continue;
};
entries.push(ProjectFileEntry {
name,
path,
kind,
size_bytes: meta.is_file.then_some(meta.len).flatten(),
modified_at_ms: meta.modified_at_ms,
});
}
entries.sort_by(|a, b| {
matches!(b.kind, ProjectFileKind::Directory)
.cmp(&matches!(a.kind, ProjectFileKind::Directory))
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a.name.cmp(&b.name))
});
Ok(ProjectFileListing {
path: relative,
entries,
})
}
}
/// Canonicalizes the project root used as the confinement anchor.
pub async fn canonical_project_root(
fs: &dyn FileSystem,
project: &Project,
) -> Result<PathBuf, AppError> {
Ok(PathBuf::from(
fs.canonicalize(&RemotePath::new(project.root.as_str()))
.await?
.as_str(),
))
}
/// Validates and normalizes a project-root-relative path.
pub fn validate_project_relative_path(path: &str) -> Result<String, AppError> {
if path.starts_with('/') || path.starts_with('\\') || path.contains('\\') {
return Err(AppError::Invalid(
"project file path must be relative".to_owned(),
));
}
let path = path.trim_matches('/');
if path.is_empty() {
return Ok(String::new());
}
let candidate = Path::new(path);
let mut parts = Vec::new();
for component in candidate.components() {
match component {
Component::Normal(part) => {
let part = part.to_string_lossy();
if part.is_empty() {
return Err(AppError::Invalid("project file path is invalid".to_owned()));
}
parts.push(part.into_owned());
}
_ => {
return Err(AppError::Invalid(
"project file path must not contain traversal".to_owned(),
));
}
}
}
Ok(parts.join("/"))
}
/// Canonicalizes a relative path and guarantees it remains below `root`.
pub async fn confined_project_path(
fs: &dyn FileSystem,
root: &Path,
relative: &str,
) -> Result<PathBuf, AppError> {
let relative = validate_project_relative_path(relative)?;
if relative.split('/').any(|segment| segment == ".ideai") {
return Err(AppError::Invalid("project file path is hidden".to_owned()));
}
let candidate = if relative.is_empty() {
root.to_path_buf()
} else {
root.join(&relative)
};
let canonical = PathBuf::from(
fs.canonicalize(&RemotePath::new(path_string(&candidate)))
.await?
.as_str(),
);
if !canonical.starts_with(root) {
return Err(AppError::Invalid(
"project file path escapes project root".to_owned(),
));
}
Ok(canonical)
}
fn join_relative(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_owned()
} else {
format!("{parent}/{name}")
}
}
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}

View File

@ -16,6 +16,7 @@
mod close;
mod context;
mod create;
mod files;
pub(crate) mod meta;
mod open;
@ -25,6 +26,10 @@ pub use context::{
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
};
pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput};
pub use files::{
canonical_project_root, confined_project_path, validate_project_relative_path,
ListProjectFiles, ListProjectFilesInput, ProjectFileEntry, ProjectFileKind, ProjectFileListing,
};
pub use meta::ProjectMeta;
pub use open::{
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,

View File

@ -31,11 +31,11 @@ use application::{
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
ListResumableAgents, ListSkills, ListSlashCommands, ListSprints, ListTemplates,
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, McpRuntime,
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView,
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles,
ListProjectFiles, ListProjects, ListResumableAgents, ListSkills, ListSlashCommands,
ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized,
McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView,
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
@ -985,6 +985,8 @@ pub struct BackendCore {
pub close_tab: Arc<CloseTab>,
/// List known projects.
pub list_projects: Arc<ListProjects>,
/// List project files for the human web surface.
pub list_project_files: Arc<ListProjectFiles>,
/// Read `.ideai/CONTEXT.md`.
pub read_project_context: Arc<ReadProjectContext>,
/// Overwrite `.ideai/CONTEXT.md`.
@ -1588,6 +1590,7 @@ impl BackendCore {
let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port)));
let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port)));
let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port)));
let list_project_files = Arc::new(ListProjectFiles::new(Arc::clone(&fs_port)));
let read_project_context = Arc::new(ReadProjectContext::new(Arc::clone(&fs_port)));
let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port)));
@ -3100,6 +3103,7 @@ impl BackendCore {
close_project,
close_tab,
list_projects,
list_project_files,
read_project_context,
update_project_context,
open_terminal,

View File

@ -537,6 +537,8 @@ pub struct FileMetadata {
pub is_dir: bool,
/// File length in bytes when known.
pub len: Option<u64>,
/// Last modification timestamp as epoch milliseconds when known.
pub modified_at_ms: Option<u64>,
}
/// An owned, boxed stream of PTY output chunks.
@ -1916,12 +1918,29 @@ pub trait FileSystem: Send + Sync {
is_file: false,
is_dir: false,
len: None,
modified_at_ms: None,
})
} else {
Err(FsError::NotFound(path.as_str().to_owned()))
}
}
/// Canonicalizes a path, following symlinks when the backing filesystem does.
///
/// The default preserves source compatibility for remote/test adapters that do
/// not expose canonicalization; local confinement-sensitive use cases should
/// use an adapter that overrides this method.
///
/// # Errors
/// [`FsError`] on failure.
async fn canonicalize(&self, path: &RemotePath) -> Result<RemotePath, FsError> {
if self.exists(path).await? {
Ok(path.clone())
} else {
Err(FsError::NotFound(path.as_str().to_owned()))
}
}
/// Removes a single file. A **missing** file is treated as success (idempotent
/// delete), so this is safe to call best-effort.
///

View File

@ -7,6 +7,7 @@
use std::io;
use std::path::Path;
use std::time::UNIX_EPOCH;
use async_trait::async_trait;
use domain::ports::{DirEntry, FileMetadata, FileSystem, FsError, RemotePath};
@ -62,9 +63,17 @@ impl FileSystem for LocalFileSystem {
is_file: meta.is_file(),
is_dir: meta.is_dir(),
len: Some(meta.len()),
modified_at_ms: modified_at_ms(&meta),
})
}
async fn canonicalize(&self, path: &RemotePath) -> Result<RemotePath, FsError> {
fs::canonicalize(path.as_str())
.await
.map(|path| RemotePath::new(path.to_string_lossy().into_owned()))
.map_err(|e| map_io(path, &e))
}
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
match fs::remove_file(path.as_str()).await {
Ok(()) => Ok(()),
@ -103,6 +112,13 @@ impl FileSystem for LocalFileSystem {
}
}
fn modified_at_ms(meta: &std::fs::Metadata) -> Option<u64> {
meta.modified()
.ok()
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
}
#[cfg(unix)]
async fn symlink_impl(src: &Path, dst: &Path) -> io::Result<()> {
fs::symlink(src, dst).await

View File

@ -31,6 +31,7 @@ http = "1.4"
http-body-util = "0.1"
sha2 = { workspace = true }
subtle = { workspace = true }
zip = { version = "2", default-features = false, features = ["deflate"] }
[features]
vector-http = ["backend/vector-http"]

View File

@ -18,11 +18,14 @@ use bytes::Bytes;
use cookie::{Cookie, SameSite};
use domain::events::DomainEvent;
use domain::ports::BackgroundTaskPortError;
use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE};
use http::header::{HeaderValue, CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE};
#[cfg(test)]
use http::Request;
use http::{HeaderMap, Method, Response, StatusCode, Uri};
use http_body_util::{BodyExt, Full};
#[cfg(test)]
use http_body_util::BodyExt;
#[cfg(test)]
use http_body_util::Full;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
@ -32,6 +35,7 @@ use tokio::net::TcpListener;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use uuid::Uuid;
use zip::write::SimpleFileOptions;
use application::{
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput,
@ -43,20 +47,20 @@ use application::{
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput,
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions,
MarkIssueAttachmentSummarizedInput, McpRuntime, OpenProjectInput, PairAttemptDecision,
PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput,
ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput,
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
WriteToTerminalInput,
ListMemoriesInput, ListProjectFilesInput, ListResumableAgentsInput, ListSkillsInput,
ListSprintsInput, LiveSessions, MarkIssueAttachmentSummarizedInput, McpRuntime,
OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
RenameDeviceInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput,
ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput,
RevokeDeviceInput, RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput,
UnlinkIssuesInput, UpdateAgentContextInput, UpdateAgentEffortInput,
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
};
use domain::ports::PtyHandle;
use domain::IssueActor;
@ -119,7 +123,38 @@ const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const WS_MAX_PAYLOAD: usize = 64 * 1024;
const WS_OUTPUT_BUFFER: usize = 512;
type ResponseBody = Full<Bytes>;
#[cfg(test)]
type RequestBody = Full<Bytes>;
#[derive(Debug)]
enum ResponseBody {
Bytes(Bytes),
File(PathBuf),
TempFile(PathBuf),
}
impl ResponseBody {
fn empty() -> Self {
Self::Bytes(Bytes::new())
}
fn bytes(bytes: impl Into<Bytes>) -> Self {
Self::Bytes(bytes.into())
}
#[cfg(test)]
async fn into_bytes(self) -> Bytes {
match self {
Self::Bytes(bytes) => bytes,
Self::File(path) => Bytes::from(tokio::fs::read(&path).await.unwrap_or_default()),
Self::TempFile(path) => {
let bytes = tokio::fs::read(&path).await.unwrap_or_default();
let _ = tokio::fs::remove_file(path).await;
Bytes::from(bytes)
}
}
}
}
static NEXT_PAIRING_GENERATION_ID: AtomicU64 = AtomicU64::new(1);
/// Runs the `idea --serve` subcommand from already-split CLI arguments.
@ -1024,7 +1059,7 @@ async fn handle_tcp_connection(
#[cfg(test)]
async fn handle_request(
req: Request<ResponseBody>,
req: Request<RequestBody>,
state: Arc<ServerState>,
) -> Response<ResponseBody> {
handle_request_from_peer(req, Arc::clone(&state), Some(state.config.listen.ip())).await
@ -1032,7 +1067,7 @@ async fn handle_request(
#[cfg(test)]
async fn handle_request_from_peer(
req: Request<ResponseBody>,
req: Request<RequestBody>,
state: Arc<ServerState>,
peer_ip: Option<IpAddr>,
) -> Response<ResponseBody> {
@ -1114,12 +1149,7 @@ async fn write_http_response(
) -> Result<(), String> {
let status = response.status();
let headers = response.headers().clone();
let body = response
.into_body()
.collect()
.await
.map_err(|err| format!("failed to collect response: {err}"))?
.to_bytes();
let body = response.into_body();
let reason = status.canonical_reason().unwrap_or("Unknown");
let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes();
for (name, value) in &headers {
@ -1128,13 +1158,53 @@ async fn write_http_response(
bytes.extend_from_slice(value.as_bytes());
bytes.extend_from_slice(b"\r\n");
}
bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes());
match &body {
ResponseBody::Bytes(body) => {
bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes());
}
ResponseBody::File(path) | ResponseBody::TempFile(path) => {
if let Ok(meta) = tokio::fs::metadata(path).await {
bytes.extend_from_slice(format!("content-length: {}\r\n", meta.len()).as_bytes());
}
}
}
bytes.extend_from_slice(b"connection: close\r\n\r\n");
bytes.extend_from_slice(&body);
stream
.write_all(&bytes)
.await
.map_err(|err| format!("failed to write response: {err}"))
.map_err(|err| format!("failed to write response head: {err}"))?;
match body {
ResponseBody::Bytes(body) => stream
.write_all(&body)
.await
.map_err(|err| format!("failed to write response body: {err}")),
ResponseBody::File(path) => stream_file_body(stream, &path).await,
ResponseBody::TempFile(path) => {
let result = stream_file_body(stream, &path).await;
let _ = tokio::fs::remove_file(path).await;
result
}
}
}
async fn stream_file_body(stream: &mut tokio::net::TcpStream, path: &Path) -> Result<(), String> {
let mut file = tokio::fs::File::open(path)
.await
.map_err(|err| format!("failed to open response file {}: {err}", path.display()))?;
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.await
.map_err(|err| format!("failed to read response file {}: {err}", path.display()))?;
if read == 0 {
return Ok(());
}
stream
.write_all(&buffer[..read])
.await
.map_err(|err| format!("failed to write response file: {err}"))?;
}
}
async fn handle_ws_upgrade(
@ -1778,6 +1848,37 @@ async fn dispatch_http(
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::GET, "/api/project-files") => {
let (token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let mut response =
project_files_list_response(&uri, Arc::clone(&state), origin.as_deref()).await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::GET, "/api/project-files/download-file") => {
let (token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let mut response =
project_file_download_response(&uri, Arc::clone(&state), origin.as_deref()).await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::GET, "/api/project-files/download-directory") => {
let (token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let mut response =
project_directory_download_response(&uri, Arc::clone(&state), origin.as_deref())
.await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::POST, "/api/devices/revoke-all") => {
let (_token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
@ -1884,6 +1985,9 @@ async fn dispatch_http(
| "/api/invoke"
| "/api/logout"
| "/api/devices"
| "/api/project-files"
| "/api/project-files/download-file"
| "/api/project-files/download-directory"
| "/api/devices/revoke-all",
) => error_response(
StatusCode::METHOD_NOT_ALLOWED,
@ -1993,7 +2097,7 @@ fn static_file_response(path: &Path, bytes: Vec<u8>, head_only: bool) -> Respons
.status(StatusCode::OK)
.header(CONTENT_TYPE, content_type(path))
.header("x-content-type-options", "nosniff")
.body(Full::new(Bytes::from(body)))
.body(ResponseBody::bytes(Bytes::from(body)))
.expect("static response is valid");
if is_index {
response.headers_mut().insert(
@ -2076,6 +2180,421 @@ async fn authenticated_device(
Ok((token, device))
}
async fn project_files_list_response(
uri: &Uri,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
let params = match query_params(uri) {
Ok(params) => params,
Err(err) => return error_response(StatusCode::BAD_REQUEST, "INVALID", err, origin),
};
let project_id = match required_query(&params, "projectId") {
Ok(value) => value,
Err(err) => return error_response(StatusCode::BAD_REQUEST, "INVALID", err, origin),
};
let path = params.get("path").cloned().unwrap_or_default();
let project = match resolve_project_readonly(&project_id, &state.app).await {
Ok(project) => project,
Err(err) => return error_dto_response(error_status(&err), err, origin),
};
let listing = match state
.app
.list_project_files
.execute(ListProjectFilesInput { project, path })
.await
{
Ok(listing) => listing,
Err(err) => {
let err = ErrorDto::from(err);
return error_dto_response(error_status(&err), err, origin);
}
};
let value = serde_json::to_value(listing).expect("project file listing serializes");
json_response(StatusCode::OK, &value, origin)
}
async fn project_file_download_response(
uri: &Uri,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
let resolved = match resolve_project_download_path(uri, &state).await {
Ok(resolved) => resolved,
Err(response) => return response.with_origin(origin),
};
let meta = match tokio::fs::metadata(&resolved.full_path).await {
Ok(meta) => meta,
Err(err) => {
return error_response(
StatusCode::NOT_FOUND,
"NOT_FOUND",
format!("project file not found: {err}"),
origin,
);
}
};
if !meta.is_file() {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
"project file path is not a file",
origin,
);
}
let filename = download_name(&resolved.relative, "project-file");
binary_file_response(
ResponseBody::File(resolved.full_path),
content_type(Path::new(&filename)),
&filename,
origin,
)
}
async fn project_directory_download_response(
uri: &Uri,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
let resolved = match resolve_project_download_path(uri, &state).await {
Ok(resolved) => resolved,
Err(response) => return response.with_origin(origin),
};
let meta = match tokio::fs::metadata(&resolved.full_path).await {
Ok(meta) => meta,
Err(err) => {
return error_response(
StatusCode::NOT_FOUND,
"NOT_FOUND",
format!("project directory not found: {err}"),
origin,
);
}
};
if !meta.is_dir() {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
"project file path is not a directory",
origin,
);
}
let archive_name = format!(
"{}.zip",
download_name(&resolved.relative, &resolved.project_name)
);
let zip_path = match create_project_directory_zip(resolved).await {
Ok(path) => path,
Err(err) => {
return error_response(StatusCode::BAD_REQUEST, "INVALID", err.to_string(), origin);
}
};
binary_file_response(
ResponseBody::TempFile(zip_path),
"application/zip",
&archive_name,
origin,
)
}
struct ResolvedProjectPath {
root: PathBuf,
full_path: PathBuf,
relative: String,
project_name: String,
}
async fn resolve_project_download_path(
uri: &Uri,
state: &ServerState,
) -> Result<ResolvedProjectPath, ProjectPathResponseError> {
let params = query_params(uri).map_err(ProjectPathResponseError::bad_request)?;
let project_id =
required_query(&params, "projectId").map_err(ProjectPathResponseError::bad_request)?;
let relative = params.get("path").cloned().unwrap_or_default();
let project = resolve_project_readonly(&project_id, &state.app)
.await
.map_err(ProjectPathResponseError::dto)?;
let root = canonical_project_root_for_http(&project)
.await
.map_err(ProjectPathResponseError::app)?;
let relative = application::project::validate_project_relative_path(&relative)
.map_err(ProjectPathResponseError::app)?;
let full_path = confined_project_path_for_http(&root, &relative)
.await
.map_err(ProjectPathResponseError::app)?;
Ok(ResolvedProjectPath {
root,
full_path,
relative,
project_name: project.name,
})
}
async fn canonical_project_root_for_http(project: &Project) -> Result<PathBuf, AppError> {
tokio::fs::canonicalize(project.root.as_str())
.await
.map_err(|err| AppError::FileSystem(format!("{}: {err}", project.root.as_str())))
}
async fn confined_project_path_for_http(root: &Path, relative: &str) -> Result<PathBuf, AppError> {
let relative = application::project::validate_project_relative_path(relative)?;
if relative.split('/').any(|segment| segment == ".ideai") {
return Err(AppError::Invalid("project file path is hidden".to_owned()));
}
let candidate = if relative.is_empty() {
root.to_path_buf()
} else {
root.join(&relative)
};
let canonical = tokio::fs::canonicalize(&candidate)
.await
.map_err(|err| AppError::FileSystem(format!("{}: {err}", candidate.display())))?;
if !canonical.starts_with(root) {
return Err(AppError::Invalid(
"project file path escapes project root".to_owned(),
));
}
Ok(canonical)
}
enum ProjectPathResponseError {
BadRequest(String),
Dto(ErrorDto),
App(AppError),
}
impl ProjectPathResponseError {
fn bad_request(err: String) -> Self {
Self::BadRequest(err)
}
fn dto(err: ErrorDto) -> Self {
Self::Dto(err)
}
fn app(err: AppError) -> Self {
Self::App(err)
}
fn with_origin(self, origin: Option<&str>) -> Response<ResponseBody> {
match self {
Self::BadRequest(err) => {
error_response(StatusCode::BAD_REQUEST, "INVALID", err, origin)
}
Self::Dto(err) => error_dto_response(error_status(&err), err, origin),
Self::App(err) => {
let err = ErrorDto::from(err);
error_dto_response(error_status(&err), err, origin)
}
}
}
}
async fn create_project_directory_zip(resolved: ResolvedProjectPath) -> Result<PathBuf, AppError> {
let zip_path = std::env::temp_dir().join(format!("idea-project-files-{}.zip", Uuid::new_v4()));
let archive_root = download_name(&resolved.relative, &resolved.project_name);
let root = resolved.root;
let source = resolved.full_path;
let zip_target = zip_path.clone();
tokio::task::spawn_blocking(move || {
let file = std::fs::File::create(&zip_target)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", zip_target.display())))?;
let mut zip = zip::ZipWriter::new(file);
zip_directory(&mut zip, &root, &source, &archive_root)?;
zip.finish()
.map_err(|err| AppError::FileSystem(format!("zip finish failed: {err}")))?;
Ok::<PathBuf, AppError>(zip_target)
})
.await
.map_err(|err| AppError::Process(format!("zip task failed: {err}")))?
}
fn zip_directory(
zip: &mut zip::ZipWriter<std::fs::File>,
root: &Path,
directory: &Path,
archive_root: &str,
) -> Result<(), AppError> {
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut stack = vec![directory.to_path_buf()];
while let Some(current) = stack.pop() {
let entries = std::fs::read_dir(&current)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", current.display())))?;
for entry in entries {
let entry = entry
.map_err(|err| AppError::FileSystem(format!("{}: {err}", current.display())))?;
let name = entry.file_name().to_string_lossy().into_owned();
if name == ".ideai" {
continue;
}
let path = entry.path();
let symlink_meta = std::fs::symlink_metadata(&path)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
if symlink_meta.file_type().is_symlink() {
let canonical = std::fs::canonicalize(&path)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
if !canonical.starts_with(root) {
return Err(AppError::Invalid(
"project directory contains a symlink escaping project root".to_owned(),
));
}
return Err(AppError::Invalid(
"project directory symlinks are not downloadable".to_owned(),
));
}
let meta = std::fs::metadata(&path)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
let canonical = std::fs::canonicalize(&path)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
if !canonical.starts_with(root) {
return Err(AppError::Invalid(
"project directory entry escapes project root".to_owned(),
));
}
let rel = path
.strip_prefix(directory)
.map_err(|_| AppError::Invalid("project directory entry is invalid".to_owned()))?;
let archive_name = archive_path(archive_root, rel)?;
if meta.is_dir() {
zip.add_directory(format!("{archive_name}/"), options)
.map_err(|err| {
AppError::FileSystem(format!("zip add directory failed: {err}"))
})?;
stack.push(path);
} else if meta.is_file() {
zip.start_file(archive_name, options)
.map_err(|err| AppError::FileSystem(format!("zip add file failed: {err}")))?;
let mut file = std::fs::File::open(&path)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
std::io::copy(&mut file, zip)
.map_err(|err| AppError::FileSystem(format!("zip write failed: {err}")))?;
}
}
}
Ok(())
}
fn archive_path(prefix: &str, relative: &Path) -> Result<String, AppError> {
let mut parts = vec![sanitize_download_component(prefix)];
for component in relative.components() {
match component {
std::path::Component::Normal(part) => {
let part = part.to_string_lossy();
if part == ".ideai" {
return Err(AppError::Invalid("project file path is hidden".to_owned()));
}
parts.push(part.into_owned());
}
_ => {
return Err(AppError::Invalid(
"project archive path is invalid".to_owned(),
))
}
}
}
Ok(parts.join("/"))
}
fn binary_file_response(
body: ResponseBody,
content_type: &'static str,
filename: &str,
origin: Option<&str>,
) -> Response<ResponseBody> {
let mut response = Response::new(body);
*response.status_mut() = StatusCode::OK;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
response.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!(
"attachment; filename=\"{}\"",
filename.replace('"', "")
))
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
);
add_cors_headers(&mut response, origin);
response
}
fn query_params(uri: &Uri) -> Result<HashMap<String, String>, String> {
let mut params = HashMap::new();
let Some(query) = uri.query() else {
return Ok(params);
};
for pair in query.split('&').filter(|pair| !pair.is_empty()) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
params.insert(percent_decode(key)?, percent_decode(value)?);
}
Ok(params)
}
fn required_query(params: &HashMap<String, String>, key: &str) -> Result<String, String> {
params
.get(key)
.filter(|value| !value.is_empty())
.cloned()
.ok_or_else(|| format!("missing query parameter: {key}"))
}
fn percent_decode(raw: &str) -> Result<String, String> {
let mut bytes = Vec::with_capacity(raw.len());
let mut chars = raw.as_bytes().iter().copied();
while let Some(byte) = chars.next() {
if byte == b'%' {
let hi = chars
.next()
.ok_or_else(|| "invalid percent encoding".to_owned())?;
let lo = chars
.next()
.ok_or_else(|| "invalid percent encoding".to_owned())?;
bytes.push((hex_value(hi)? << 4) | hex_value(lo)?);
} else if byte == b'+' {
bytes.push(b' ');
} else {
bytes.push(byte);
}
}
String::from_utf8(bytes).map_err(|_| "query parameter is not UTF-8".to_owned())
}
fn hex_value(byte: u8) -> Result<u8, String> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err("invalid percent encoding".to_owned()),
}
}
fn download_name(relative: &str, fallback: &str) -> String {
relative
.rsplit('/')
.find(|part| !part.is_empty())
.map(sanitize_download_component)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| sanitize_download_component(fallback))
}
fn sanitize_download_component(raw: &str) -> String {
let sanitized: String = raw
.chars()
.map(|ch| match ch {
'/' | '\\' | '"' | '\r' | '\n' | '\t' => '_',
ch if ch.is_control() => '_',
ch => ch,
})
.collect();
let sanitized = sanitized.trim_matches('.').trim().to_owned();
if sanitized.is_empty() {
"project".to_owned()
} else {
sanitized
}
}
async fn list_devices_response(
state: Arc<ServerState>,
device: &AuthenticatedDevice,
@ -4484,7 +5003,7 @@ fn json_response(
origin: Option<&str>,
) -> Response<ResponseBody> {
let body = serde_json::to_vec(value).expect("JSON value serializes");
let mut response = Response::new(Full::new(Bytes::from(body)));
let mut response = Response::new(ResponseBody::bytes(Bytes::from(body)));
*response.status_mut() = status;
response
.headers_mut()
@ -4518,8 +5037,18 @@ fn error_response(
)
}
fn error_status(error: &ErrorDto) -> StatusCode {
match error.code.as_str() {
"NOT_FOUND" => StatusCode::NOT_FOUND,
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
"CONFLICT" => StatusCode::CONFLICT,
"INVALID" => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn cors_response(status: StatusCode, origin: Option<&str>) -> Response<ResponseBody> {
let mut response = Response::new(Full::new(Bytes::new()));
let mut response = Response::new(ResponseBody::empty());
*response.status_mut() = status;
add_cors_headers(&mut response, origin);
response
@ -5381,7 +5910,7 @@ mod tests {
async fn response_json(response: Response<ResponseBody>) -> (StatusCode, Value, HeaderMap) {
let status = response.status();
let headers = response.headers().clone();
let body = response.into_body().collect().await.unwrap().to_bytes();
let body = response.into_body().into_bytes().await;
let value = if body.is_empty() {
Value::Null
} else {
@ -5455,7 +5984,7 @@ mod tests {
async fn response_bytes(response: Response<ResponseBody>) -> (StatusCode, Bytes, HeaderMap) {
let status = response.status();
let headers = response.headers().clone();
let body = response.into_body().collect().await.unwrap().to_bytes();
let body = response.into_body().into_bytes().await;
(status, body, headers)
}
@ -7723,6 +8252,220 @@ mod tests {
assert!(body["root"].is_string());
}
#[tokio::test]
async fn authorized_project_files_lists_relative_paths_and_hides_ideai() {
let state = state();
let project_id = create_project_for_test(&state, "Project Files").await;
let project = resolve_project_readonly(&project_id, &state.app)
.await
.expect("project resolves");
std::fs::create_dir_all(format!("{}/src", project.root.as_str())).unwrap();
std::fs::write(
format!("{}/src/main.rs", project.root.as_str()),
"fn main() {}\n",
)
.unwrap();
std::fs::write(format!("{}/README.md", project.root.as_str()), "readme").unwrap();
std::fs::create_dir_all(format!("{}/.ideai", project.root.as_str())).unwrap();
std::fs::write(
format!("{}/.ideai/secret.txt", project.root.as_str()),
"secret",
)
.unwrap();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files?projectId={project_id}&path="),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["path"], "");
let entries = body["entries"].as_array().unwrap();
assert!(entries
.iter()
.any(|entry| entry["name"] == "src" && entry["kind"] == "directory"));
assert!(entries
.iter()
.any(|entry| entry["path"] == "README.md" && entry["kind"] == "file"));
assert!(!entries.iter().any(|entry| entry["name"] == ".ideai"));
}
#[tokio::test]
async fn authorized_project_files_rejects_traversal_and_hidden_paths() {
let state = state();
let project_id = create_project_for_test(&state, "Project Files Security").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (traversal_status, traversal_body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files?projectId={project_id}&path=../"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
let (hidden_status, hidden_body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files?projectId={project_id}&path=.ideai"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(traversal_status, StatusCode::BAD_REQUEST);
assert_eq!(traversal_body["code"], "INVALID");
assert_eq!(hidden_status, StatusCode::BAD_REQUEST);
assert_eq!(hidden_body["code"], "INVALID");
}
#[tokio::test]
async fn authorized_project_file_download_requires_file_and_returns_binary() {
let state = state();
let project_id = create_project_for_test(&state, "Project File Download").await;
let project = resolve_project_readonly(&project_id, &state.app)
.await
.expect("project resolves");
std::fs::create_dir_all(format!("{}/src", project.root.as_str())).unwrap();
std::fs::write(
format!("{}/src/main.rs", project.root.as_str()),
"fn main() {}\n",
)
.unwrap();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, body, headers) = response_bytes(
request(
Arc::clone(&state),
Method::GET,
&format!(
"/api/project-files/download-file?projectId={project_id}&path=src/main.rs"
),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
let (dir_status, dir_body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files/download-file?projectId={project_id}&path=src"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, Bytes::from_static(b"fn main() {}\n"));
assert!(headers
.get(CONTENT_DISPOSITION)
.unwrap()
.to_str()
.unwrap()
.contains("main.rs"));
assert_eq!(dir_status, StatusCode::BAD_REQUEST);
assert_eq!(dir_body["code"], "INVALID");
}
#[tokio::test]
async fn authorized_project_directory_download_returns_zip_and_requires_directory() {
let state = state();
let project_id = create_project_for_test(&state, "Project Directory Download").await;
let project = resolve_project_readonly(&project_id, &state.app)
.await
.expect("project resolves");
std::fs::create_dir_all(format!("{}/src/nested", project.root.as_str())).unwrap();
std::fs::write(
format!("{}/src/nested/lib.rs", project.root.as_str()),
"pub fn x() {}\n",
)
.unwrap();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, body, headers) = response_bytes(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files/download-directory?projectId={project_id}&path=src"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
let (file_status, file_body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files/download-directory?projectId={project_id}&path=src/nested/lib.rs"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body.starts_with(b"PK"));
assert_eq!(
headers.get(CONTENT_TYPE).unwrap(),
HeaderValue::from_static("application/zip")
);
assert_eq!(file_status, StatusCode::BAD_REQUEST);
assert_eq!(file_body["code"], "INVALID");
}
#[cfg(unix)]
#[tokio::test]
async fn authorized_project_files_rejects_symlink_escaping_root() {
let state = state();
let project_id = create_project_for_test(&state, "Project Files Symlink").await;
let project = resolve_project_readonly(&project_id, &state.app)
.await
.expect("project resolves");
let outside = std::env::temp_dir().join(format!("idea-outside-{}", Uuid::new_v4()));
std::fs::write(&outside, "outside").unwrap();
std::os::unix::fs::symlink(&outside, format!("{}/outside-link", project.root.as_str()))
.unwrap();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!(
"/api/project-files/download-file?projectId={project_id}&path=outside-link"
),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "INVALID");
let _ = std::fs::remove_file(outside);
}
#[tokio::test]
async fn authorized_get_project_work_state_returns_tauri_contract() {
let state = state();