| 1 | use std::io::Read as _; |
| 2 | use std::path::{Component, Path as FsPath, PathBuf}; |
| 3 | |
| 4 | use axum::Json; |
| 5 | use axum::extract::{Query, State}; |
| 6 | use axum::http::StatusCode; |
| 7 | use base64::Engine as _; |
| 8 | use serde::{Deserialize, Serialize}; |
| 9 | use sha2::{Digest, Sha256}; |
| 10 | |
| 11 | use crate::dependencies::Git; |
| 12 | use crate::snapshot::is_git_metadata_name; |
| 13 | |
| 14 | use super::{ApiError, RuntimeApiState}; |
| 15 | |
| 16 | #[derive(Deserialize)] |
| 17 | #[serde(deny_unknown_fields)] |
| 18 | pub(super) struct WorkspaceFileSearchQuery { |
| 19 | #[serde(default)] |
| 20 | query: String, |
| 21 | limit: Option<usize>, |
| 22 | } |
| 23 | |
| 24 | #[derive(Debug, Serialize)] |
| 25 | pub(super) struct WorkspaceFileSearchResponse { |
| 26 | paths: Vec<String>, |
| 27 | } |
| 28 | |
| 29 | /// Read-only suggestions: never use the process cwd or a caller-selected root. |
| 30 | pub(super) async fn workspace_file_search( |
| 31 | State(state): State<RuntimeApiState>, |
| 32 | Query(query): Query<WorkspaceFileSearchQuery>, |
| 33 | ) -> Result<Json<WorkspaceFileSearchResponse>, ApiError> { |
| 34 | if query.query.len() > 256 { |
| 35 | return Err(ApiError::bad_request( |
| 36 | "query must be at most 256 UTF-8 bytes", |
| 37 | )); |
| 38 | } |
| 39 | let limit = query.limit.unwrap_or(20); |
| 40 | if !(1..=100).contains(&limit) { |
| 41 | return Err(ApiError::bad_request("limit must be between 1 and 100")); |
| 42 | } |
| 43 | if query.query.trim().is_empty() { |
| 44 | return Ok(Json(WorkspaceFileSearchResponse { paths: Vec::new() })); |
| 45 | } |
| 46 | let paths = tokio::task::spawn_blocking(move || { |
| 47 | collect_workspace_file_suggestions(&state.workspace, &query.query, limit) |
| 48 | }) |
| 49 | .await |
| 50 | .map_err(|_| ApiError::internal("workspace file search failed"))??; |
| 51 | Ok(Json(WorkspaceFileSearchResponse { paths })) |
| 52 | } |
| 53 | |
| 54 | fn collect_workspace_file_suggestions( |
| 55 | root: &FsPath, |
| 56 | query: &str, |
| 57 | limit: usize, |
| 58 | ) -> Result<Vec<String>, ApiError> { |
| 59 | use crate::working_set::{Workspace, rank_completion_candidates}; |
| 60 | |
| 61 | let root = root |
| 62 | .canonicalize() |
| 63 | .map_err(|_| ApiError::internal("workspace is unavailable"))?; |
| 64 | let workspace = Workspace::with_cwd(root.clone(), None); |
| 65 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); |
| 66 | // Reuse the composer's bounded discovery and ignore policy, without its |
| 67 | // divergent-cwd pass or symlink-directory traversal. No persistent index. |
| 68 | let candidates = workspace |
| 69 | .completion_discovery_candidates(20_000, &|| std::time::Instant::now() >= deadline); |
| 70 | Ok( |
| 71 | rank_completion_candidates(&candidates, query, candidates.len()) |
| 72 | .into_iter() |
| 73 | .filter(|candidate| { |
| 74 | let path = FsPath::new(candidate); |
| 75 | path.components() |
| 76 | .all(|component| matches!(component, std::path::Component::Normal(_))) |
| 77 | && root |
| 78 | .join(path) |
| 79 | .canonicalize() |
| 80 | .is_ok_and(|resolved| resolved.starts_with(&root) && resolved.is_file()) |
| 81 | }) |
| 82 | .take(limit) |
| 83 | .collect(), |
| 84 | ) |
| 85 | } |
| 86 | |
| 87 | #[derive(Debug, Serialize)] |
| 88 | pub(super) struct WorkspaceStatusResponse { |
| 89 | pub(super) workspace: PathBuf, |
| 90 | pub(super) git_repo: bool, |
| 91 | pub(super) branch: Option<String>, |
| 92 | pub(super) head: Option<String>, |
| 93 | pub(super) dirty: bool, |
| 94 | pub(super) staged: usize, |
| 95 | pub(super) unstaged: usize, |
| 96 | pub(super) untracked: usize, |
| 97 | pub(super) ahead: Option<u32>, |
| 98 | pub(super) behind: Option<u32>, |
| 99 | } |
| 100 | |
| 101 | #[derive(Debug, Default)] |
| 102 | pub(super) struct WorkspaceGitMetadata { |
| 103 | pub(super) branch: Option<String>, |
| 104 | pub(super) head: Option<String>, |
| 105 | pub(super) dirty: bool, |
| 106 | } |
| 107 | |
| 108 | /// Status is several blocking `git` spawns, so it runs off the async workers |
| 109 | /// (#6149) like every other git read on this surface. It deliberately keeps |
| 110 | /// normal Git filters and untracked settings rather than the full review |
| 111 | /// command, so its counts match `git status` and the operator writes (see |
| 112 | /// `git.rs`), but it never runs a repository-configured helper: fsmonitor and |
| 113 | /// hooks are off, as on every read-only review path (see `run_git`). |
| 114 | pub(super) async fn workspace_status( |
| 115 | State(state): State<RuntimeApiState>, |
| 116 | ) -> Result<Json<WorkspaceStatusResponse>, ApiError> { |
| 117 | let workspace = state.workspace.clone(); |
| 118 | tokio::task::spawn_blocking(move || collect_workspace_status(&workspace)) |
| 119 | .await |
| 120 | .map(Json) |
| 121 | .map_err(|_| ApiError::internal("workspace status failed")) |
| 122 | } |
| 123 | |
| 124 | pub(super) fn collect_workspace_status(workspace: &FsPath) -> WorkspaceStatusResponse { |
| 125 | let mut status = WorkspaceStatusResponse { |
| 126 | workspace: workspace.to_path_buf(), |
| 127 | git_repo: false, |
| 128 | branch: None, |
| 129 | head: None, |
| 130 | dirty: false, |
| 131 | staged: 0, |
| 132 | unstaged: 0, |
| 133 | untracked: 0, |
| 134 | ahead: None, |
| 135 | behind: None, |
| 136 | }; |
| 137 | |
| 138 | let Some(repo_check) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]) else { |
| 139 | return status; |
| 140 | }; |
| 141 | if repo_check.trim() != "true" { |
| 142 | return status; |
| 143 | } |
| 144 | |
| 145 | status.git_repo = true; |
| 146 | let metadata = collect_workspace_git_metadata(workspace); |
| 147 | status.branch = metadata.branch; |
| 148 | status.head = metadata.head; |
| 149 | status.dirty = metadata.dirty; |
| 150 | |
| 151 | if let Some(porcelain) = run_git(workspace, &["status", "--porcelain=v1"]) { |
| 152 | for line in porcelain.lines() { |
| 153 | if line.starts_with("??") { |
| 154 | status.untracked += 1; |
| 155 | continue; |
| 156 | } |
| 157 | let chars: Vec<char> = line.chars().collect(); |
| 158 | if chars.len() >= 2 { |
| 159 | if chars[0] != ' ' { |
| 160 | status.staged += 1; |
| 161 | } |
| 162 | if chars[1] != ' ' { |
| 163 | status.unstaged += 1; |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | if let Some(counts) = run_git( |
| 170 | workspace, |
| 171 | &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], |
| 172 | ) { |
| 173 | let mut parts = counts.split_whitespace(); |
| 174 | if let (Some(behind), Some(ahead)) = (parts.next(), parts.next()) { |
| 175 | status.behind = behind.parse::<u32>().ok(); |
| 176 | status.ahead = ahead.parse::<u32>().ok(); |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | status |
| 181 | } |
| 182 | |
| 183 | pub(super) fn collect_workspace_git_metadata(workspace: &FsPath) -> WorkspaceGitMetadata { |
| 184 | let Some(repo_check) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]) else { |
| 185 | return WorkspaceGitMetadata::default(); |
| 186 | }; |
| 187 | if repo_check.trim() != "true" { |
| 188 | return WorkspaceGitMetadata::default(); |
| 189 | } |
| 190 | |
| 191 | WorkspaceGitMetadata { |
| 192 | branch: current_git_branch(workspace), |
| 193 | head: current_git_head(workspace), |
| 194 | dirty: run_git(workspace, &["status", "--porcelain=v1"]) |
| 195 | .is_some_and(|porcelain| !porcelain.trim().is_empty()), |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | /// Read-only git for status and metadata. `Git::review_base` disables |
| 200 | /// `core.fsmonitor`, `core.hooksPath`, lazy fetch and replace objects: a |
| 201 | /// status poll must not execute a helper the repository's config names. |
| 202 | fn run_git(workspace: &FsPath, args: &[&str]) -> Option<String> { |
| 203 | let mut command = Git::review_base(workspace).ok()?; |
| 204 | let output = command.args(args).output().ok()?; |
| 205 | if !output.status.success() { |
| 206 | return None; |
| 207 | } |
| 208 | String::from_utf8(output.stdout).ok() |
| 209 | } |
| 210 | |
| 211 | fn current_git_branch(workspace: &FsPath) -> Option<String> { |
| 212 | let repo_check = run_git(workspace, &["rev-parse", "--is-inside-work-tree"])?; |
| 213 | if repo_check.trim() != "true" { |
| 214 | return None; |
| 215 | } |
| 216 | let branch = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"])?; |
| 217 | let branch = branch.trim(); |
| 218 | if branch.is_empty() { |
| 219 | return None; |
| 220 | } |
| 221 | if branch != "HEAD" { |
| 222 | return Some(branch.to_string()); |
| 223 | } |
| 224 | let short_hash = run_git(workspace, &["rev-parse", "--short", "HEAD"])?; |
| 225 | let short_hash = short_hash.trim(); |
| 226 | (!short_hash.is_empty()).then(|| format!("detached@{short_hash}")) |
| 227 | } |
| 228 | |
| 229 | fn current_git_head(workspace: &FsPath) -> Option<String> { |
| 230 | let head = run_git(workspace, &["rev-parse", "--short", "HEAD"])?; |
| 231 | let head = head.trim(); |
| 232 | (!head.is_empty()).then(|| head.to_string()) |
| 233 | } |
| 234 | |
| 235 | // --------------------------------------------------------------------------- |
| 236 | // Workspace files (#6163): a bounded directory listing, bounded reads that |
| 237 | // carry a content revision, and revision-checked writes. The server's |
| 238 | // configured workspace is the only root and every path is workspace-relative |
| 239 | // with `/` separators. Symlinks are never followed, `.git` is never served, |
| 240 | // and there is no second file store: bytes go straight to the workspace |
| 241 | // through the same confined opener Fleet artifacts use. |
| 242 | // --------------------------------------------------------------------------- |
| 243 | |
| 244 | const FILE_LIST_LIMIT_DEFAULT: usize = 200; |
| 245 | const FILE_LIST_LIMIT_MAX: usize = 2_000; |
| 246 | pub(super) const FILE_READ_LIMIT_DEFAULT: usize = 256 * 1024; |
| 247 | pub(super) const FILE_READ_LIMIT_MAX: usize = 4 * 1024 * 1024; |
| 248 | /// Files above this size are not served at all: the revision is a digest of |
| 249 | /// the whole file, and a Files browser should not page through larger blobs. |
| 250 | pub(super) const FILE_SERVE_MAX_BYTES: u64 = 16 * 1024 * 1024; |
| 251 | pub(super) const FILE_WRITE_MAX_BYTES: usize = 4 * 1024 * 1024; |
| 252 | /// Request-body ceiling for the write route: the content cap plus headroom for |
| 253 | /// base64 expansion and the JSON envelope, so an oversized `content` reaches |
| 254 | /// the handler and gets a 413 with a message instead of a dropped connection. |
| 255 | pub(super) const FILE_WRITE_BODY_LIMIT_BYTES: usize = FILE_WRITE_MAX_BYTES * 4 / 3 + 64 * 1024; |
| 256 | const FILE_PATH_MAX_BYTES: usize = 4_096; |
| 257 | |
| 258 | #[derive(Deserialize)] |
| 259 | #[serde(deny_unknown_fields)] |
| 260 | pub(super) struct WorkspaceFilesListQuery { |
| 261 | #[serde(default)] |
| 262 | path: String, |
| 263 | limit: Option<usize>, |
| 264 | } |
| 265 | |
| 266 | #[derive(Debug, Serialize)] |
| 267 | pub(super) struct WorkspaceFileEntry { |
| 268 | name: String, |
| 269 | path: String, |
| 270 | /// `file`, `directory`, `symlink` (listed by name, never followed) or `other`. |
| 271 | kind: &'static str, |
| 272 | #[serde(skip_serializing_if = "Option::is_none")] |
| 273 | size: Option<u64>, |
| 274 | #[serde(skip_serializing_if = "Option::is_none")] |
| 275 | modified: Option<String>, |
| 276 | } |
| 277 | |
| 278 | #[derive(Debug, Serialize)] |
| 279 | pub(super) struct WorkspaceFilesListResponse { |
| 280 | path: String, |
| 281 | entries: Vec<WorkspaceFileEntry>, |
| 282 | truncated: bool, |
| 283 | } |
| 284 | |
| 285 | #[derive(Deserialize)] |
| 286 | #[serde(deny_unknown_fields)] |
| 287 | pub(super) struct WorkspaceFileReadQuery { |
| 288 | path: String, |
| 289 | offset: Option<usize>, |
| 290 | limit: Option<usize>, |
| 291 | } |
| 292 | |
| 293 | #[derive(Debug, Serialize)] |
| 294 | pub(super) struct WorkspaceFileReadResponse { |
| 295 | path: String, |
| 296 | size: u64, |
| 297 | /// SHA-256 of the whole file, not of the returned window. |
| 298 | revision: String, |
| 299 | #[serde(skip_serializing_if = "Option::is_none")] |
| 300 | modified: Option<String>, |
| 301 | offset: usize, |
| 302 | bytes: usize, |
| 303 | truncated: bool, |
| 304 | encoding: &'static str, |
| 305 | content: String, |
| 306 | } |
| 307 | |
| 308 | #[derive(Deserialize)] |
| 309 | #[serde(deny_unknown_fields)] |
| 310 | pub(super) struct WorkspaceFileWriteRequest { |
| 311 | path: String, |
| 312 | content: String, |
| 313 | /// `utf-8` (default) or `base64`. |
| 314 | #[serde(default)] |
| 315 | encoding: Option<String>, |
| 316 | /// Required to overwrite an existing file; must be absent for a new one. |
| 317 | #[serde(default)] |
| 318 | expected_revision: Option<String>, |
| 319 | } |
| 320 | |
| 321 | #[derive(Debug, Serialize)] |
| 322 | pub(super) struct WorkspaceFileWriteResponse { |
| 323 | path: String, |
| 324 | size: u64, |
| 325 | revision: String, |
| 326 | created: bool, |
| 327 | written_at: String, |
| 328 | } |
| 329 | |
| 330 | /// Bytes of one confined file plus the facts a client needs to reason about |
| 331 | /// them: total size, the whole-file revision and the modification time. |
| 332 | pub(super) struct ConfinedFileBytes { |
| 333 | pub(super) size: u64, |
| 334 | pub(super) revision: String, |
| 335 | pub(super) modified: Option<String>, |
| 336 | pub(super) bytes: Vec<u8>, |
| 337 | } |
| 338 | |
| 339 | pub(super) fn parse_read_window( |
| 340 | offset: Option<usize>, |
| 341 | limit: Option<usize>, |
| 342 | ) -> Result<(usize, usize), ApiError> { |
| 343 | let limit = limit.unwrap_or(FILE_READ_LIMIT_DEFAULT); |
| 344 | if !(1..=FILE_READ_LIMIT_MAX).contains(&limit) { |
| 345 | return Err(ApiError::bad_request(format!( |
| 346 | "limit must be between 1 and {FILE_READ_LIMIT_MAX} bytes" |
| 347 | ))); |
| 348 | } |
| 349 | Ok((offset.unwrap_or(0), limit)) |
| 350 | } |
| 351 | |
| 352 | /// Byte window `[offset, offset + limit)` clamped to the content. |
| 353 | pub(super) fn read_window(bytes: &[u8], offset: usize, limit: usize) -> (&[u8], bool) { |
| 354 | let start = offset.min(bytes.len()); |
| 355 | let end = start.saturating_add(limit).min(bytes.len()); |
| 356 | (&bytes[start..end], end < bytes.len()) |
| 357 | } |
| 358 | |
| 359 | /// Text windows come back as UTF-8; anything with a NUL byte, invalid UTF-8, |
| 360 | /// or a window that splits a multi-byte character comes back as base64. |
| 361 | pub(super) fn encode_window(window: &[u8]) -> (&'static str, String) { |
| 362 | if !window.contains(&0) |
| 363 | && let Ok(text) = std::str::from_utf8(window) |
| 364 | { |
| 365 | return ("utf-8", text.to_string()); |
| 366 | } |
| 367 | ( |
| 368 | "base64", |
| 369 | base64::engine::general_purpose::STANDARD.encode(window), |
| 370 | ) |
| 371 | } |
| 372 | |
| 373 | pub(super) fn content_revision(bytes: &[u8]) -> String { |
| 374 | Sha256::digest(bytes) |
| 375 | .iter() |
| 376 | .map(|byte| format!("{byte:02x}")) |
| 377 | .collect() |
| 378 | } |
| 379 | |
| 380 | fn rfc3339(time: std::time::SystemTime) -> String { |
| 381 | chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339() |
| 382 | } |
| 383 | |
| 384 | pub(super) fn map_fs_error(error: std::io::Error, what: &str) -> ApiError { |
| 385 | use std::io::ErrorKind; |
| 386 | match error.kind() { |
| 387 | ErrorKind::NotFound => ApiError::not_found(format!("{what} not found")), |
| 388 | ErrorKind::PermissionDenied => ApiError::forbidden(format!("{what} is not readable")), |
| 389 | ErrorKind::InvalidInput | ErrorKind::InvalidData => ApiError::forbidden(format!( |
| 390 | "{what} must be a regular, non-linked path inside the workspace" |
| 391 | )), |
| 392 | ErrorKind::Unsupported => { |
| 393 | ApiError::not_implemented("confined file access is unavailable on this platform") |
| 394 | } |
| 395 | _ => ApiError::internal(format!("{what} access failed: {error}")), |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | /// A workspace-relative request path. Empty and `.` mean the root, which only |
| 400 | /// listing accepts. Absolute paths, `..`, backslashes and `.git` are refused. |
| 401 | pub(super) fn relative_request_path(raw: &str, allow_root: bool) -> Result<PathBuf, ApiError> { |
| 402 | let trimmed = raw.trim(); |
| 403 | if trimmed.len() > FILE_PATH_MAX_BYTES { |
| 404 | return Err(ApiError::bad_request(format!( |
| 405 | "path must be at most {FILE_PATH_MAX_BYTES} UTF-8 bytes" |
| 406 | ))); |
| 407 | } |
| 408 | if trimmed.contains('\\') { |
| 409 | return Err(ApiError::bad_request("path must use / separators")); |
| 410 | } |
| 411 | if trimmed.starts_with('/') || FsPath::new(trimmed).is_absolute() { |
| 412 | return Err(ApiError::bad_request("path must be workspace-relative")); |
| 413 | } |
| 414 | let trimmed = trimmed.trim_end_matches('/'); |
| 415 | if trimmed.is_empty() || trimmed == "." { |
| 416 | return if allow_root { |
| 417 | Ok(PathBuf::new()) |
| 418 | } else { |
| 419 | Err(ApiError::bad_request("path is required")) |
| 420 | }; |
| 421 | } |
| 422 | let path = PathBuf::from(trimmed); |
| 423 | if !crate::fleet::files::path_is_confined(&path) { |
| 424 | return Err(ApiError::bad_request( |
| 425 | "path must be workspace-relative without . or .. components", |
| 426 | )); |
| 427 | } |
| 428 | if path |
| 429 | .components() |
| 430 | .any(|component| matches!(component, Component::Normal(name) if is_git_metadata_name(name))) |
| 431 | { |
| 432 | return Err(ApiError::forbidden("the .git directory is not served")); |
| 433 | } |
| 434 | Ok(path) |
| 435 | } |
| 436 | |
| 437 | pub(super) fn canonical_workspace(workspace: &FsPath) -> Result<PathBuf, ApiError> { |
| 438 | workspace |
| 439 | .canonicalize() |
| 440 | .map_err(|_| ApiError::internal("workspace is unavailable")) |
| 441 | } |
| 442 | |
| 443 | fn relative_display(path: &FsPath) -> String { |
| 444 | path.components() |
| 445 | .filter_map(|component| match component { |
| 446 | Component::Normal(name) => Some(name.to_string_lossy().into_owned()), |
| 447 | _ => None, |
| 448 | }) |
| 449 | .collect::<Vec<_>>() |
| 450 | .join("/") |
| 451 | } |
| 452 | |
| 453 | /// Walk `relative` from the root one component at a time, refusing any link |
| 454 | /// or reparse point on the way, and confirm the result is a directory that |
| 455 | /// still resolves inside the root. |
| 456 | fn confined_directory(root: &FsPath, relative: &FsPath) -> Result<PathBuf, ApiError> { |
| 457 | let mut directory = root.to_path_buf(); |
| 458 | for component in relative.components() { |
| 459 | directory.push(component); |
| 460 | let metadata = std::fs::symlink_metadata(&directory) |
| 461 | .map_err(|error| map_fs_error(error, "directory"))?; |
| 462 | if metadata.file_type().is_symlink() |
| 463 | || crate::plugins::metadata_is_link_or_reparse(&metadata) |
| 464 | { |
| 465 | return Err(ApiError::forbidden("symlinks are not followed")); |
| 466 | } |
| 467 | if !metadata.is_dir() { |
| 468 | return Err(ApiError::bad_request("path is not a directory")); |
| 469 | } |
| 470 | } |
| 471 | let resolved = directory |
| 472 | .canonicalize() |
| 473 | .map_err(|error| map_fs_error(error, "directory"))?; |
| 474 | if !resolved.starts_with(root) { |
| 475 | return Err(ApiError::forbidden("path resolves outside the workspace")); |
| 476 | } |
| 477 | Ok(directory) |
| 478 | } |
| 479 | |
| 480 | pub(super) async fn workspace_files_list( |
| 481 | State(state): State<RuntimeApiState>, |
| 482 | Query(query): Query<WorkspaceFilesListQuery>, |
| 483 | ) -> Result<Json<WorkspaceFilesListResponse>, ApiError> { |
| 484 | let relative = relative_request_path(&query.path, true)?; |
| 485 | let limit = query.limit.unwrap_or(FILE_LIST_LIMIT_DEFAULT); |
| 486 | if !(1..=FILE_LIST_LIMIT_MAX).contains(&limit) { |
| 487 | return Err(ApiError::bad_request(format!( |
| 488 | "limit must be between 1 and {FILE_LIST_LIMIT_MAX}" |
| 489 | ))); |
| 490 | } |
| 491 | let workspace = state.workspace.clone(); |
| 492 | tokio::task::spawn_blocking(move || list_workspace_directory(&workspace, &relative, limit)) |
| 493 | .await |
| 494 | .map_err(|_| ApiError::internal("workspace listing failed"))? |
| 495 | .map(Json) |
| 496 | } |
| 497 | |
| 498 | fn list_workspace_directory( |
| 499 | workspace: &FsPath, |
| 500 | relative: &FsPath, |
| 501 | limit: usize, |
| 502 | ) -> Result<WorkspaceFilesListResponse, ApiError> { |
| 503 | let root = canonical_workspace(workspace)?; |
| 504 | let directory = confined_directory(&root, relative)?; |
| 505 | let mut entries = Vec::new(); |
| 506 | let read_dir = |
| 507 | std::fs::read_dir(&directory).map_err(|error| map_fs_error(error, "directory"))?; |
| 508 | for entry in read_dir { |
| 509 | let entry = entry.map_err(|error| map_fs_error(error, "directory"))?; |
| 510 | let Some(name) = entry.file_name().to_str().map(str::to_owned) else { |
| 511 | continue; |
| 512 | }; |
| 513 | if is_git_metadata_name(std::ffi::OsStr::new(&name)) { |
| 514 | continue; |
| 515 | } |
| 516 | let Ok(file_type) = entry.file_type() else { |
| 517 | continue; |
| 518 | }; |
| 519 | let metadata = entry.metadata().ok(); |
| 520 | let is_link = file_type.is_symlink() |
| 521 | || metadata |
| 522 | .as_ref() |
| 523 | .is_some_and(crate::plugins::metadata_is_link_or_reparse); |
| 524 | let kind = if is_link { |
| 525 | "symlink" |
| 526 | } else if file_type.is_dir() { |
| 527 | "directory" |
| 528 | } else if file_type.is_file() { |
| 529 | "file" |
| 530 | } else { |
| 531 | "other" |
| 532 | }; |
| 533 | let file_metadata = (kind == "file").then_some(metadata).flatten(); |
| 534 | entries.push(WorkspaceFileEntry { |
| 535 | path: relative_display(&relative.join(&name)), |
| 536 | name, |
| 537 | kind, |
| 538 | size: file_metadata.as_ref().map(std::fs::Metadata::len), |
| 539 | modified: file_metadata |
| 540 | .as_ref() |
| 541 | .and_then(|metadata| metadata.modified().ok()) |
| 542 | .map(rfc3339), |
| 543 | }); |
| 544 | } |
| 545 | entries.sort_by(|left, right| { |
| 546 | (left.kind != "directory") |
| 547 | .cmp(&(right.kind != "directory")) |
| 548 | .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) |
| 549 | .then_with(|| left.name.cmp(&right.name)) |
| 550 | }); |
| 551 | let truncated = entries.len() > limit; |
| 552 | entries.truncate(limit); |
| 553 | Ok(WorkspaceFilesListResponse { |
| 554 | path: relative_display(relative), |
| 555 | entries, |
| 556 | truncated, |
| 557 | }) |
| 558 | } |
| 559 | |
| 560 | pub(super) fn open_confined_file( |
| 561 | root: &FsPath, |
| 562 | relative: &FsPath, |
| 563 | create: bool, |
| 564 | ) -> Result<crate::fleet::files::WorkspaceFile, ApiError> { |
| 565 | crate::fleet::files::WorkspaceFile::open(root, relative, create) |
| 566 | .map_err(|error| map_fs_error(error, "file")) |
| 567 | } |
| 568 | |
| 569 | /// Read one confined file completely (bounded by `FILE_SERVE_MAX_BYTES`) so |
| 570 | /// the revision always describes the whole file. |
| 571 | pub(super) fn read_confined_bytes( |
| 572 | file: &crate::fleet::files::WorkspaceFile, |
| 573 | ) -> Result<ConfinedFileBytes, ApiError> { |
| 574 | let mut handle = file |
| 575 | .open_file() |
| 576 | .map_err(|error| map_fs_error(error, "file"))?; |
| 577 | let metadata = handle |
| 578 | .metadata() |
| 579 | .map_err(|error| map_fs_error(error, "file"))?; |
| 580 | if metadata.len() > FILE_SERVE_MAX_BYTES { |
| 581 | return Err(ApiError::payload_too_large(format!( |
| 582 | "file is larger than the {FILE_SERVE_MAX_BYTES}-byte serving limit" |
| 583 | ))); |
| 584 | } |
| 585 | let mut bytes = Vec::with_capacity(metadata.len() as usize); |
| 586 | handle |
| 587 | .by_ref() |
| 588 | .take(FILE_SERVE_MAX_BYTES + 1) |
| 589 | .read_to_end(&mut bytes) |
| 590 | .map_err(|error| map_fs_error(error, "file"))?; |
| 591 | if bytes.len() as u64 > FILE_SERVE_MAX_BYTES { |
| 592 | return Err(ApiError::payload_too_large(format!( |
| 593 | "file grew past the {FILE_SERVE_MAX_BYTES}-byte serving limit while it was read" |
| 594 | ))); |
| 595 | } |
| 596 | Ok(ConfinedFileBytes { |
| 597 | size: bytes.len() as u64, |
| 598 | revision: content_revision(&bytes), |
| 599 | modified: metadata.modified().ok().map(rfc3339), |
| 600 | bytes, |
| 601 | }) |
| 602 | } |
| 603 | |
| 604 | /// Refuse links and non-files before the confined opener runs, so a client |
| 605 | /// sees a precise status instead of a generic confinement error. |
| 606 | pub(super) fn precheck_file_target( |
| 607 | root: &FsPath, |
| 608 | relative: &FsPath, |
| 609 | ) -> Result<Option<std::fs::Metadata>, ApiError> { |
| 610 | if let Some(parent) = relative |
| 611 | .parent() |
| 612 | .filter(|parent| !parent.as_os_str().is_empty()) |
| 613 | { |
| 614 | // Every directory on the way must be a real directory; a link would |
| 615 | // otherwise surface as a confinement error from the opener. |
| 616 | confined_directory(root, parent).or_else(|error| { |
| 617 | if error.status == StatusCode::NOT_FOUND { |
| 618 | Ok(PathBuf::new()) |
| 619 | } else { |
| 620 | Err(error) |
| 621 | } |
| 622 | })?; |
| 623 | } |
| 624 | match std::fs::symlink_metadata(root.join(relative)) { |
| 625 | Ok(metadata) => { |
| 626 | if metadata.file_type().is_symlink() |
| 627 | || crate::plugins::metadata_is_link_or_reparse(&metadata) |
| 628 | { |
| 629 | return Err(ApiError::forbidden("symlinks are not followed")); |
| 630 | } |
| 631 | if metadata.is_dir() { |
| 632 | return Err(ApiError::bad_request("path is a directory")); |
| 633 | } |
| 634 | Ok(Some(metadata)) |
| 635 | } |
| 636 | Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), |
| 637 | Err(error) => Err(map_fs_error(error, "file")), |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | pub(super) async fn workspace_file_read( |
| 642 | State(state): State<RuntimeApiState>, |
| 643 | Query(query): Query<WorkspaceFileReadQuery>, |
| 644 | ) -> Result<Json<WorkspaceFileReadResponse>, ApiError> { |
| 645 | let relative = relative_request_path(&query.path, false)?; |
| 646 | let (offset, limit) = parse_read_window(query.offset, query.limit)?; |
| 647 | let workspace = state.workspace.clone(); |
| 648 | tokio::task::spawn_blocking(move || { |
| 649 | let root = canonical_workspace(&workspace)?; |
| 650 | if precheck_file_target(&root, &relative)?.is_none() { |
| 651 | return Err(ApiError::not_found("file not found")); |
| 652 | } |
| 653 | let file = open_confined_file(&root, &relative, false)?; |
| 654 | let read = read_confined_bytes(&file)?; |
| 655 | let (window, truncated) = read_window(&read.bytes, offset, limit); |
| 656 | let (encoding, content) = encode_window(window); |
| 657 | Ok(WorkspaceFileReadResponse { |
| 658 | path: relative_display(&relative), |
| 659 | size: read.size, |
| 660 | revision: read.revision, |
| 661 | modified: read.modified, |
| 662 | offset: offset.min(read.bytes.len()), |
| 663 | bytes: window.len(), |
| 664 | truncated, |
| 665 | encoding, |
| 666 | content, |
| 667 | }) |
| 668 | }) |
| 669 | .await |
| 670 | .map_err(|_| ApiError::internal("workspace file read failed"))? |
| 671 | .map(Json) |
| 672 | } |
| 673 | |
| 674 | pub(super) async fn workspace_file_write( |
| 675 | State(state): State<RuntimeApiState>, |
| 676 | Json(request): Json<WorkspaceFileWriteRequest>, |
| 677 | ) -> Result<(StatusCode, Json<WorkspaceFileWriteResponse>), ApiError> { |
| 678 | let relative = relative_request_path(&request.path, false)?; |
| 679 | let bytes = match request.encoding.as_deref().unwrap_or("utf-8") { |
| 680 | "utf-8" => request.content.into_bytes(), |
| 681 | "base64" => base64::engine::general_purpose::STANDARD |
| 682 | .decode(request.content.as_bytes()) |
| 683 | .map_err(|_| ApiError::bad_request("content is not valid base64"))?, |
| 684 | _ => return Err(ApiError::bad_request("encoding must be utf-8 or base64")), |
| 685 | }; |
| 686 | if bytes.len() > FILE_WRITE_MAX_BYTES { |
| 687 | return Err(ApiError::payload_too_large(format!( |
| 688 | "content must be at most {FILE_WRITE_MAX_BYTES} bytes" |
| 689 | ))); |
| 690 | } |
| 691 | let expected_revision = request |
| 692 | .expected_revision |
| 693 | .map(|revision| revision.trim().to_ascii_lowercase()) |
| 694 | .filter(|revision| !revision.is_empty()); |
| 695 | let workspace = state.workspace.clone(); |
| 696 | tokio::task::spawn_blocking(move || { |
| 697 | write_workspace_file(&workspace, &relative, &bytes, expected_revision.as_deref()) |
| 698 | }) |
| 699 | .await |
| 700 | .map_err(|_| ApiError::internal("workspace file write failed"))? |
| 701 | } |
| 702 | |
| 703 | fn write_workspace_file( |
| 704 | workspace: &FsPath, |
| 705 | relative: &FsPath, |
| 706 | bytes: &[u8], |
| 707 | expected_revision: Option<&str>, |
| 708 | ) -> Result<(StatusCode, Json<WorkspaceFileWriteResponse>), ApiError> { |
| 709 | let root = canonical_workspace(workspace)?; |
| 710 | let created = precheck_file_target(&root, relative)?.is_none(); |
| 711 | match (created, expected_revision) { |
| 712 | (true, Some(_)) => { |
| 713 | return Err(ApiError::conflict( |
| 714 | "expected_revision was given but the file does not exist", |
| 715 | )); |
| 716 | } |
| 717 | (false, None) => { |
| 718 | return Err(ApiError::conflict( |
| 719 | "expected_revision is required to overwrite an existing file; read it first", |
| 720 | )); |
| 721 | } |
| 722 | _ => {} |
| 723 | } |
| 724 | // Parents are created only for a new file, and only through the confined |
| 725 | // opener, which refuses links at every component. This is the user's own |
| 726 | // file, not a private store: keep its mode on edit, follow the umask on |
| 727 | // creation. |
| 728 | let file = crate::fleet::files::WorkspaceFile::open_shared(&root, relative, created) |
| 729 | .map_err(|error| map_fs_error(error, "file"))?; |
| 730 | if let Some(expected) = expected_revision { |
| 731 | let current = read_confined_bytes(&file)?; |
| 732 | if current.revision != expected { |
| 733 | return Err(ApiError::conflict(format!( |
| 734 | "file changed since it was read; current revision is {}", |
| 735 | current.revision |
| 736 | ))); |
| 737 | } |
| 738 | } |
| 739 | file.replace(bytes) |
| 740 | .map_err(|error| map_fs_error(error, "file"))?; |
| 741 | Ok(( |
| 742 | if created { |
| 743 | StatusCode::CREATED |
| 744 | } else { |
| 745 | StatusCode::OK |
| 746 | }, |
| 747 | Json(WorkspaceFileWriteResponse { |
| 748 | path: relative_display(relative), |
| 749 | size: bytes.len() as u64, |
| 750 | revision: content_revision(bytes), |
| 751 | created, |
| 752 | written_at: chrono::Utc::now().to_rfc3339(), |
| 753 | }), |
| 754 | )) |
| 755 | } |
| 756 | |
| 757 | // ── Effective instruction sources (#6168) ──────────────────────────────── |
| 758 | |
| 759 | #[derive(Debug, Serialize)] |
| 760 | pub(super) struct WorkspaceInstructionSource { |
| 761 | /// `project` | `rule` | `global` | `fragment` | `configured` | |
| 762 | /// `constitution` | `ignored`. |
| 763 | kind: &'static str, |
| 764 | /// `loaded` | `shadowed` | `skipped` | `missing`. |
| 765 | status: &'static str, |
| 766 | scope_dir: PathBuf, |
| 767 | path: PathBuf, |
| 768 | /// Workspace-relative spelling when the path sits under the workspace. |
| 769 | relative_path: Option<String>, |
| 770 | exists: bool, |
| 771 | bytes: Option<u64>, |
| 772 | warning: Option<String>, |
| 773 | } |
| 774 | |
| 775 | #[derive(Debug, Serialize)] |
| 776 | pub(super) struct WorkspaceInstructionsResponse { |
| 777 | workspace: PathBuf, |
| 778 | sources: Vec<WorkspaceInstructionSource>, |
| 779 | /// Foreign instruction formats the operator opted into. |
| 780 | foreign_imports: Vec<String>, |
| 781 | /// True when no file-based instructions exist anywhere and the |
| 782 | /// prompt carries the ephemeral generated context instead. |
| 783 | generated_fallback: bool, |
| 784 | /// Aggregate byte ceiling applied across instructions and rules at |
| 785 | /// render (`project_context::MAX_PROJECT_INSTRUCTION_BYTES`). |
| 786 | aggregate_budget_bytes: usize, |
| 787 | /// Warnings from the real load pass — includes assembly-level notices |
| 788 | /// (unimported foreign formats, ignored WHALE.md, constitution parse) |
| 789 | /// alongside what per-source `warning` fields already carry. |
| 790 | warnings: Vec<String>, |
| 791 | } |
| 792 | |
| 793 | fn instruction_source_kind_name( |
| 794 | kind: crate::project_context::InstructionSourceKind, |
| 795 | ) -> &'static str { |
| 796 | use crate::project_context::InstructionSourceKind as Kind; |
| 797 | match kind { |
| 798 | Kind::Project => "project", |
| 799 | Kind::Rule => "rule", |
| 800 | Kind::Global => "global", |
| 801 | Kind::Fragment => "fragment", |
| 802 | Kind::Configured => "configured", |
| 803 | Kind::Constitution => "constitution", |
| 804 | Kind::Ignored => "ignored", |
| 805 | } |
| 806 | } |
| 807 | |
| 808 | fn instruction_source_status_name( |
| 809 | status: crate::project_context::InstructionSourceStatus, |
| 810 | ) -> &'static str { |
| 811 | use crate::project_context::InstructionSourceStatus as Status; |
| 812 | match status { |
| 813 | Status::Loaded => "loaded", |
| 814 | Status::Shadowed => "shadowed", |
| 815 | Status::Skipped => "skipped", |
| 816 | Status::Missing => "missing", |
| 817 | } |
| 818 | } |
| 819 | |
| 820 | /// Read-only listing of the effective instruction sources for this |
| 821 | /// workspace (#6168): repository-root → workspace chain candidates, rules |
| 822 | /// files, the global fallback layer, opted-in foreign fragments, configured |
| 823 | /// `instructions = [...]` files, and the constitution — each with the status |
| 824 | /// the prompt loaders give it. Edits go through the workspace file routes. |
| 825 | pub(super) async fn workspace_instructions( |
| 826 | State(state): State<RuntimeApiState>, |
| 827 | ) -> Result<Json<WorkspaceInstructionsResponse>, ApiError> { |
| 828 | let workspace = state.workspace.clone(); |
| 829 | let home = crate::config::effective_home_dir(); |
| 830 | let configured = state.config.read().instructions_paths(); |
| 831 | |
| 832 | let (sources, generated_fallback, warnings, workspace_root) = |
| 833 | tokio::task::spawn_blocking(move || { |
| 834 | let sources = crate::project_context::project_instruction_sources( |
| 835 | &workspace, |
| 836 | home.as_deref(), |
| 837 | &configured, |
| 838 | ); |
| 839 | // The real load pass supplies assembly-level warnings and tells us |
| 840 | // whether the ephemeral generated context is what the prompt |
| 841 | // carries. Cached — this is the same call the engine makes. |
| 842 | let ctx = crate::project_context::load_project_context_with_parents(&workspace); |
| 843 | let generated_fallback = ctx.instructions.is_some() && ctx.source_path.is_none(); |
| 844 | // `project_instruction_sources` canonicalizes the workspace (the |
| 845 | // `/var` → `/private/var` class of alias), so relative paths must |
| 846 | // be computed against the canonical spelling or every strip fails. |
| 847 | // It rides this closure rather than the async body because |
| 848 | // `canonicalize` is a blocking syscall, and one on a Tokio worker |
| 849 | // is one too many (#6149). |
| 850 | let workspace_root = |
| 851 | std::fs::canonicalize(&workspace).unwrap_or_else(|_| workspace.clone()); |
| 852 | (sources, generated_fallback, ctx.warnings, workspace_root) |
| 853 | }) |
| 854 | .await |
| 855 | .map_err(|_| ApiError::internal("instruction source listing failed"))?; |
| 856 | Ok(Json(WorkspaceInstructionsResponse { |
| 857 | workspace: workspace_root.clone(), |
| 858 | sources: sources |
| 859 | .into_iter() |
| 860 | .map(|source| WorkspaceInstructionSource { |
| 861 | kind: instruction_source_kind_name(source.kind), |
| 862 | status: instruction_source_status_name(source.status), |
| 863 | // Forward slashes on every platform. `display()` emits the |
| 864 | // native separator, which would make a wire field describing a |
| 865 | // repo-relative path differ between a Windows and a Unix host |
| 866 | // and force every client to branch on the server's OS. The |
| 867 | // absolute `path` below stays native, because that one is only |
| 868 | // meaningful on the machine that produced it. |
| 869 | relative_path: source |
| 870 | .path |
| 871 | .strip_prefix(&workspace_root) |
| 872 | .ok() |
| 873 | .map(|relative| { |
| 874 | relative |
| 875 | .components() |
| 876 | .map(|part| part.as_os_str().to_string_lossy()) |
| 877 | .collect::<Vec<_>>() |
| 878 | .join("/") |
| 879 | }), |
| 880 | scope_dir: source.scope_dir, |
| 881 | path: source.path, |
| 882 | exists: source.exists, |
| 883 | bytes: source.bytes, |
| 884 | warning: source.warning, |
| 885 | }) |
| 886 | .collect(), |
| 887 | foreign_imports: crate::project_context::foreign_instruction_imports() |
| 888 | .keys() |
| 889 | .into_iter() |
| 890 | .map(str::to_string) |
| 891 | .collect(), |
| 892 | generated_fallback, |
| 893 | aggregate_budget_bytes: crate::project_context::MAX_PROJECT_INSTRUCTION_BYTES, |
| 894 | warnings, |
| 895 | })) |
| 896 | } |
| 897 | |
| 898 | #[cfg(test)] |
| 899 | mod tests { |
| 900 | use super::*; |
| 901 | #[cfg(unix)] |
| 902 | use crate::dependencies::ExternalTool as _; |
| 903 | |
| 904 | #[cfg(unix)] |
| 905 | #[test] |
| 906 | fn workspace_status_does_not_run_the_repository_fsmonitor() -> anyhow::Result<()> { |
| 907 | use std::os::unix::fs::PermissionsExt; |
| 908 | let tmp = tempfile::tempdir()?; |
| 909 | let repo = tmp.path().join("repo"); |
| 910 | std::fs::create_dir_all(&repo)?; |
| 911 | let git = |args: &[&str]| Git::status(args, &repo).is_ok_and(|status| status.success()); |
| 912 | if !git(&["init", "-q"]) { |
| 913 | return Ok(()); |
| 914 | } |
| 915 | std::fs::write(repo.join("file.txt"), "hello\n")?; |
| 916 | assert!(git(&["add", "file.txt"])); |
| 917 | assert!(git(&[ |
| 918 | "-c", |
| 919 | "user.name=t", |
| 920 | "-c", |
| 921 | "user.email=t@example.invalid", |
| 922 | "commit", |
| 923 | "-qm", |
| 924 | "init", |
| 925 | ])); |
| 926 | std::fs::write(repo.join("file.txt"), "hello\nworld\n")?; |
| 927 | let marker = tmp.path().join("ran"); |
| 928 | let hook = tmp.path().join("fsmonitor.sh"); |
| 929 | std::fs::write( |
| 930 | &hook, |
| 931 | format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()), |
| 932 | )?; |
| 933 | std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))?; |
| 934 | assert!(git(&["config", "core.fsmonitor", &hook.to_string_lossy()])); |
| 935 | |
| 936 | let status = collect_workspace_status(&repo); |
| 937 | assert!(status.git_repo); |
| 938 | assert_eq!(status.unstaged, 1); |
| 939 | assert!(!marker.exists(), "the repository's fsmonitor hook ran"); |
| 940 | Ok(()) |
| 941 | } |
| 942 | |
| 943 | #[test] |
| 944 | fn git_metadata_directory_is_refused_in_any_letter_case() { |
| 945 | for raw in [ |
| 946 | ".git", |
| 947 | ".git/config", |
| 948 | ".GIT/config", |
| 949 | ".Git/hooks/pre-commit", |
| 950 | "sub/.gIt/HEAD", |
| 951 | ] { |
| 952 | let error = relative_request_path(raw, false).expect_err(raw); |
| 953 | assert_eq!(error.status, StatusCode::FORBIDDEN, "{raw}"); |
| 954 | } |
| 955 | for raw in [".github/workflows/ci.yml", "a.git/b", ".gitignore", "git"] { |
| 956 | assert!(relative_request_path(raw, false).is_ok(), "{raw}"); |
| 957 | } |
| 958 | } |
| 959 | |
| 960 | #[test] |
| 961 | fn listing_hides_git_metadata_directory_in_any_letter_case() -> anyhow::Result<()> { |
| 962 | let tmp = tempfile::tempdir()?; |
| 963 | std::fs::create_dir_all(tmp.path().join(".Git"))?; |
| 964 | std::fs::write(tmp.path().join("kept.txt"), "kept")?; |
| 965 | let listing = list_workspace_directory(tmp.path(), FsPath::new(""), 100) |
| 966 | .map_err(|error| anyhow::anyhow!(error.message))?; |
| 967 | let names: Vec<&str> = listing |
| 968 | .entries |
| 969 | .iter() |
| 970 | .map(|entry| entry.name.as_str()) |
| 971 | .collect(); |
| 972 | assert_eq!(names, ["kept.txt"]); |
| 973 | Ok(()) |
| 974 | } |
| 975 | |
| 976 | #[test] |
| 977 | fn workspace_file_search_reuses_discovery_ignores() -> anyhow::Result<()> { |
| 978 | let tmp = tempfile::tempdir()?; |
| 979 | let root = tmp.path(); |
| 980 | std::fs::create_dir_all(root.join(".git"))?; |
| 981 | std::fs::create_dir_all(root.join(".agents"))?; |
| 982 | std::fs::create_dir_all(root.join("target"))?; |
| 983 | std::fs::write(root.join(".gitignore"), ".agents/\ntarget/\nlocal.rs\n")?; |
| 984 | std::fs::write(root.join(".ignore"), "blocked.rs\n")?; |
| 985 | std::fs::write(root.join(".deepseekignore"), "private.rs\n")?; |
| 986 | for name in [ |
| 987 | "local.rs", |
| 988 | "blocked.rs", |
| 989 | "private.rs", |
| 990 | ".agents/guide.rs", |
| 991 | "target/build.rs", |
| 992 | ] { |
| 993 | std::fs::write(root.join(name), "not returned")?; |
| 994 | } |
| 995 | let paths = collect_workspace_file_suggestions(root, ".rs", 100) |
| 996 | .map_err(|error| anyhow::anyhow!(error.message))?; |
| 997 | assert_eq!(paths, [".agents/guide.rs", "local.rs"]); |
| 998 | Ok(()) |
| 999 | } |
| 1000 | |
| 1001 | #[cfg(unix)] |
| 1002 | #[test] |
| 1003 | fn workspace_file_search_contains_symlinks_and_excludes_other_workspaces() -> anyhow::Result<()> |
| 1004 | { |
| 1005 | use std::os::unix::fs::symlink; |
| 1006 | |
| 1007 | let tmp = tempfile::tempdir()?; |
| 1008 | let root = tmp.path().join("workspace"); |
| 1009 | let other = tmp.path().join("workspace-other"); |
| 1010 | std::fs::create_dir_all(&root)?; |
| 1011 | std::fs::create_dir_all(&other)?; |
| 1012 | std::fs::write(root.join("inside.rs"), "inside")?; |
| 1013 | std::fs::write(other.join("outside.rs"), "outside")?; |
| 1014 | symlink(root.join("inside.rs"), root.join("alias.rs"))?; |
| 1015 | symlink(other.join("outside.rs"), root.join("escape.rs"))?; |
| 1016 | symlink(&other, root.join("external-directory"))?; |
| 1017 | symlink(&other, root.join(".agents"))?; |
| 1018 | symlink(other.join("missing.rs"), root.join("broken.rs"))?; |
| 1019 | symlink(&root, root.join("loop"))?; |
| 1020 | let paths = collect_workspace_file_suggestions(&root, ".rs", 100) |
| 1021 | .map_err(|error| anyhow::anyhow!(error.message))?; |
| 1022 | assert_eq!(paths, ["alias.rs", "inside.rs"]); |
| 1023 | Ok(()) |
| 1024 | } |
| 1025 | } |
| 1026 |