feat(web): panneau Fichiers — navigation projet + téléchargements fichier/dossier ZIP — #211
This commit is contained in:
@ -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(¶ms, "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(¶ms, "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(¤t)
|
||||
.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();
|
||||
|
||||
Reference in New Issue
Block a user