Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions src-tauri/src/git_attribution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ struct RemoteTemplate {
}

pub(crate) async fn attribution_for_file(workspace_root: &Path, relative: &str) -> GitAttribution {
let file_path = match resolve_existing_workspace_file_path(workspace_root, relative) {
// Never follow a symlink that escapes the workspace here — attribution must
// not become a way to read external files without the trust gate. In-workspace
// symlinks still resolve (allow_external = false still follows within root);
// a symlink pointing outside the repo has no meaningful git blame anyway.
let file_path = match resolve_existing_workspace_file_path(workspace_root, relative, false) {
Ok(path) => path,
Err(error) => return unsupported(relative, workspace_error_reason(error)),
};
Expand Down Expand Up @@ -524,7 +528,9 @@ fn workspace_error_reason(error: WorkspaceError) -> String {
WorkspaceError::NotAFile => "Path is not a file".to_string(),
WorkspaceError::OutsideWorkspace => "File is outside the workspace".to_string(),
WorkspaceError::InvalidPath => "File path is not supported".to_string(),
WorkspaceError::SymlinkUnsupported => "Symbolic links are not supported".to_string(),
WorkspaceError::SymlinkOutsideWorkspace => {
"Symbolic link points outside the workspace".to_string()
}
WorkspaceError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => {
"File does not exist".to_string()
}
Expand Down
13 changes: 10 additions & 3 deletions src-tauri/src/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ fn search_indexed_files_with_expansion(
visibility.show_dotfiles,
visibility.show_generated_internal,
visibility.show_gitignored_files,
false,
) {
Ok(entries) => entries,
Err(error) if !directory.is_empty() && stale_indexed_directory_error(&error) => {
Expand Down Expand Up @@ -688,6 +689,7 @@ async fn directory(
query.show_dotfiles.unwrap_or(false),
query.show_generated_internal.unwrap_or(false),
query.show_gitignored_files.unwrap_or(false),
false,
)?;
state
.workspace_index
Expand Down Expand Up @@ -715,6 +717,7 @@ async fn read_file(
&workspace_root,
&query.path,
max_open_bytes,
false,
)?)
}

Expand Down Expand Up @@ -747,6 +750,7 @@ async fn write_file(
&request.path,
&request.contents,
request.expected_modified_ms,
false,
)?;
refresh_indexed_entry(&state.workspace_index, &workspace_root, &request.path)?;
Ok(StatusCode::NO_CONTENT)
Expand All @@ -760,7 +764,7 @@ async fn create_file(
) -> Result<StatusCode, ApiError> {
require_bearer_auth(&headers, &state.mcp_token)?;
let workspace_root = resolved.workspace_root.read().await.clone();
create_workspace_file(&workspace_root, &request.path)?;
create_workspace_file(&workspace_root, &request.path, false)?;
refresh_indexed_entry(&state.workspace_index, &workspace_root, &request.path)?;
Ok(StatusCode::CREATED)
}
Expand All @@ -773,7 +777,7 @@ async fn create_folder(
) -> Result<StatusCode, ApiError> {
require_bearer_auth(&headers, &state.mcp_token)?;
let workspace_root = resolved.workspace_root.read().await.clone();
create_workspace_folder(&workspace_root, &request.path)?;
create_workspace_folder(&workspace_root, &request.path, false)?;
refresh_indexed_entry(&state.workspace_index, &workspace_root, &request.path)?;
Ok(StatusCode::CREATED)
}
Expand Down Expand Up @@ -842,7 +846,7 @@ async fn rename_file(
) -> Result<StatusCode, ApiError> {
require_bearer_auth(&headers, &state.mcp_token)?;
let workspace_root = resolved.workspace_root.read().await.clone();
rename_workspace_file(&workspace_root, &request.from_path, &request.to_path)?;
rename_workspace_file(&workspace_root, &request.from_path, &request.to_path, false)?;
state
.workspace_index
.remove_path(&workspace_root, &request.from_path)?;
Expand Down Expand Up @@ -1553,6 +1557,9 @@ mod tests {
depth: path.matches('/').count(),
size: 0,
modified_ms: Some(1),
is_symlink: false,
is_external: false,
symlink_target: None,
}
}

Expand Down
66 changes: 60 additions & 6 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,10 @@ struct PersistedWorkspaceUiState {
selected_path: Option<String>,
#[serde(default)]
sidebar_width: Option<usize>,
// "Trust for workspace" decision for following symlinks whose target escapes
// the workspace root; persisted so it survives restart.
#[serde(default)]
trust_external_symlinks: bool,
updated_at: u128,
}

Expand All @@ -429,6 +433,8 @@ struct WorkspaceUiStatePayload {
active_file: Option<String>,
selected_path: Option<String>,
sidebar_width: Option<usize>,
#[serde(default)]
trust_external_symlinks: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -681,6 +687,7 @@ async fn list_directory(
show_dotfiles: bool,
show_generated_internal: bool,
show_gitignored_files: bool,
allow_external_symlinks: Option<bool>,
) -> Result<Vec<workspace::FileEntry>, CommandError> {
let workspace_root = workspace_root_for_window(&state, &window).await;
let entries = workspace_directory_entries(
Expand All @@ -689,6 +696,7 @@ async fn list_directory(
show_dotfiles,
show_generated_internal,
show_gitignored_files,
allow_external_symlinks.unwrap_or(false),
)
.map_err(CommandError::from)?;
state
Expand Down Expand Up @@ -766,6 +774,8 @@ fn search_indexed_files_with_expansion(
visibility.show_dotfiles,
visibility.show_generated_internal,
visibility.show_gitignored_files,
// Background quick-open indexing never follows external symlinks.
false,
) {
Ok(entries) => entries,
Err(error) if !directory.is_empty() && stale_indexed_directory_error(&error) => {
Expand Down Expand Up @@ -831,6 +841,7 @@ async fn read_file(
state: State<'_, AppState>,
path: String,
max_open_bytes: Option<u64>,
allow_external_symlinks: Option<bool>,
) -> Result<String, CommandError> {
let workspace_root = workspace_root_for_window(&state, &window).await;
let max_open_bytes = max_open_bytes
Expand All @@ -845,7 +856,13 @@ async fn read_file(
MIN_MAX_OPEN_FILE_KB.saturating_mul(1024),
MAX_MAX_OPEN_FILE_KB.saturating_mul(1024),
);
read_workspace_file(&workspace_root, &path, max_open_bytes).map_err(CommandError::from)
read_workspace_file(
&workspace_root,
&path,
max_open_bytes,
allow_external_symlinks.unwrap_or(false),
)
.map_err(CommandError::from)
}

#[tauri::command]
Expand Down Expand Up @@ -875,10 +892,17 @@ async fn write_file(
path: String,
contents: String,
expected_modified_ms: Option<u128>,
allow_external_symlinks: Option<bool>,
) -> Result<(), CommandError> {
let workspace_root = workspace_root_for_window(&state, &window).await;
write_workspace_file(&workspace_root, &path, &contents, expected_modified_ms)
.map_err(CommandError::from)?;
write_workspace_file(
&workspace_root,
&path,
&contents,
expected_modified_ms,
allow_external_symlinks.unwrap_or(false),
)
.map_err(CommandError::from)?;
refresh_indexed_entry(&state.workspace_index, &workspace_root, &path)?;
Ok(())
}
Expand All @@ -888,9 +912,15 @@ async fn create_file(
window: tauri::Window,
state: State<'_, AppState>,
path: String,
allow_external_symlinks: Option<bool>,
) -> Result<(), CommandError> {
let workspace_root = workspace_root_for_window(&state, &window).await;
create_workspace_file(&workspace_root, &path).map_err(CommandError::from)?;
create_workspace_file(
&workspace_root,
&path,
allow_external_symlinks.unwrap_or(false),
)
.map_err(CommandError::from)?;
refresh_indexed_entry(&state.workspace_index, &workspace_root, &path)?;
Ok(())
}
Expand All @@ -900,9 +930,15 @@ async fn create_folder(
window: tauri::Window,
state: State<'_, AppState>,
path: String,
allow_external_symlinks: Option<bool>,
) -> Result<(), CommandError> {
let workspace_root = workspace_root_for_window(&state, &window).await;
create_workspace_folder(&workspace_root, &path).map_err(CommandError::from)?;
create_workspace_folder(
&workspace_root,
&path,
allow_external_symlinks.unwrap_or(false),
)
.map_err(CommandError::from)?;
refresh_indexed_entry(&state.workspace_index, &workspace_root, &path)?;
Ok(())
}
Expand All @@ -913,9 +949,16 @@ async fn rename_file(
state: State<'_, AppState>,
from_path: String,
to_path: String,
allow_external_symlinks: Option<bool>,
) -> Result<(), CommandError> {
let workspace_root = workspace_root_for_window(&state, &window).await;
rename_workspace_file(&workspace_root, &from_path, &to_path).map_err(CommandError::from)?;
rename_workspace_file(
&workspace_root,
&from_path,
&to_path,
allow_external_symlinks.unwrap_or(false),
)
.map_err(CommandError::from)?;
state
.workspace_index
.remove_path(&workspace_root, &from_path)?;
Expand Down Expand Up @@ -1169,6 +1212,7 @@ async fn update_ui_state(
active_file: workspace.active_file.take(),
selected_path: workspace.selected_path.take(),
sidebar_width: workspace.sidebar_width,
trust_external_symlinks: workspace.trust_external_symlinks,
updated_at: now_ms(),
};
ui_state
Expand Down Expand Up @@ -2152,6 +2196,7 @@ fn rebuild_app_menu(app: &tauri::AppHandle, state: &AppState) -> Result<(), Comm
.cut()
.copy()
.paste()
.select_all()
.build()
.map_err(|error| CommandError::Recent(error.to_string()))?;
let go_to_definition = MenuItemBuilder::with_id("go_to_definition", "Go to Definition")
Expand Down Expand Up @@ -2303,6 +2348,7 @@ fn workspace_ui_snapshot_for_root(
active_file: workspace.active_file.clone(),
selected_path: workspace.selected_path.clone(),
sidebar_width: workspace.sidebar_width,
trust_external_symlinks: workspace.trust_external_symlinks,
})
.unwrap_or_default();
Ok(PersistedUiSnapshot { view, workspace })
Expand Down Expand Up @@ -2341,6 +2387,7 @@ fn sanitize_workspace_ui_state(state: WorkspaceUiStatePayload) -> WorkspaceUiSta
active_file,
selected_path,
sidebar_width,
trust_external_symlinks: state.trust_external_symlinks,
}
}

Expand Down Expand Up @@ -3144,6 +3191,7 @@ mod tests {
active_file: None,
selected_path: None,
sidebar_width: None,
trust_external_symlinks: false,
updated_at: 1,
},
PersistedWorkspaceUiState {
Expand All @@ -3153,6 +3201,7 @@ mod tests {
active_file: None,
selected_path: None,
sidebar_width: None,
trust_external_symlinks: false,
updated_at: 2,
},
],
Expand Down Expand Up @@ -3241,6 +3290,7 @@ mod tests {
active_file: Some("src/App.tsx".to_string()),
selected_path: Some("/tmp".to_string()),
sidebar_width: Some(9_999),
trust_external_symlinks: false,
});

assert_eq!(workspace.expanded_folders, vec!["src".to_string()]);
Expand Down Expand Up @@ -3282,6 +3332,7 @@ mod tests {
active_file: workspace.active_file,
selected_path: workspace.selected_path,
sidebar_width: workspace.sidebar_width,
trust_external_symlinks: workspace.trust_external_symlinks,
updated_at: 123,
}],
};
Expand Down Expand Up @@ -3585,6 +3636,9 @@ mod tests {
depth: path.matches('/').count(),
size: 0,
modified_ms: Some(1),
is_symlink: false,
is_external: false,
symlink_target: None,
}
}

Expand Down
Loading
Loading