1use anda_core::{BoxError, RequestMeta};
2use std::{
3 ffi::OsString,
4 fs::{Metadata, Permissions},
5 path::{Component, Path, PathBuf},
6};
7use tokio::io::AsyncWriteExt;
8
9mod edit;
10mod read;
11mod search;
12mod write;
13
14pub use edit::*;
15pub use read::*;
16pub use search::*;
17pub use write::*;
18
19pub(crate) const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
20
21pub(crate) const UTF8_ENCODING: &str = "utf8";
22pub(crate) const BASE64_ENCODING: &str = "base64";
23
24#[derive(Debug, Clone)]
25pub(crate) struct ResolvedFilePath {
26 pub(crate) workspace: PathBuf,
27 pub(crate) path: PathBuf,
28}
29
30pub(crate) fn normalize_workspaces<I>(workspaces: I) -> Vec<PathBuf>
31where
32 I: IntoIterator<Item = PathBuf>,
33{
34 let mut normalized = Vec::new();
35 for workspace in workspaces {
36 push_workspace(&mut normalized, workspace);
37 }
38
39 normalized
40}
41
42pub(crate) fn tool_workspaces(meta: &RequestMeta, defaults: &[PathBuf]) -> Vec<PathBuf> {
43 let mut workspaces = Vec::new();
44
45 if let Some(workspace) = meta.get_extra_as::<PathBuf>("workspace") {
46 push_workspace(&mut workspaces, workspace);
47 } else if let Some(extra_workspaces) = meta.get_extra_as::<Vec<PathBuf>>("workspace") {
48 for workspace in extra_workspaces {
49 push_workspace(&mut workspaces, workspace);
50 }
51 }
52
53 if let Some(workspace) = meta.get_extra_as::<PathBuf>("workspaces") {
54 push_workspace(&mut workspaces, workspace);
55 } else if let Some(extra_workspaces) = meta.get_extra_as::<Vec<PathBuf>>("workspaces") {
56 for workspace in extra_workspaces {
57 push_workspace(&mut workspaces, workspace);
58 }
59 }
60
61 for workspace in defaults {
62 push_workspace(&mut workspaces, workspace.clone());
63 }
64
65 workspaces
66}
67
68pub(crate) fn format_workspaces(workspaces: &[PathBuf]) -> String {
69 if workspaces.is_empty() {
70 return "<none>".to_string();
71 }
72
73 workspaces
74 .iter()
75 .map(|workspace| workspace.display().to_string())
76 .collect::<Vec<_>>()
77 .join(", ")
78}
79
80fn push_workspace(workspaces: &mut Vec<PathBuf>, workspace: PathBuf) {
81 if workspace.as_os_str().is_empty() {
82 return;
83 }
84
85 if !workspaces.iter().any(|existing| existing == &workspace) {
86 workspaces.push(workspace);
87 }
88}
89
90pub(crate) async fn resolve_read_path_in_workspaces(
91 workspaces: &[PathBuf],
92 user_path: &str,
93) -> Result<ResolvedFilePath, BoxError> {
94 let mut errors = Vec::new();
95
96 for workspace in workspaces {
97 match resolve_read_path(workspace, user_path).await {
98 Ok(path) => {
99 return Ok(ResolvedFilePath {
100 workspace: workspace.clone(),
101 path,
102 });
103 }
104 Err(err) => errors.push(format!("{}: {err}", workspace.display())),
105 }
106 }
107
108 Err(workspace_access_error(
109 "Path",
110 "requested_path",
111 user_path,
112 workspaces,
113 errors,
114 ))
115}
116
117pub(crate) async fn resolve_write_path_in_workspaces(
118 workspaces: &[PathBuf],
119 user_path: &str,
120) -> Result<ResolvedFilePath, BoxError> {
121 let requested_path = Path::new(user_path);
122
123 if requested_path.is_relative() {
124 for workspace in workspaces {
125 let candidate_path = workspace.join(requested_path);
126 match tokio::fs::symlink_metadata(&candidate_path).await {
127 Ok(_) => {
128 let path = resolve_write_path(workspace, user_path).await?;
129 return Ok(ResolvedFilePath {
130 workspace: workspace.clone(),
131 path,
132 });
133 }
134 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
135 Err(err) => {
136 return Err(format!(
137 "Failed to inspect file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
138 workspace.display(),
139 user_path,
140 candidate_path.display()
141 )
142 .into());
143 }
144 }
145 }
146 }
147
148 let mut errors = Vec::new();
149 for workspace in workspaces {
150 match resolve_write_path(workspace, user_path).await {
151 Ok(path) => {
152 return Ok(ResolvedFilePath {
153 workspace: workspace.clone(),
154 path,
155 });
156 }
157 Err(err) => errors.push(format!("{}: {err}", workspace.display())),
158 }
159 }
160
161 Err(workspace_access_error(
162 "Path",
163 "requested_path",
164 user_path,
165 workspaces,
166 errors,
167 ))
168}
169
170pub(crate) fn workspace_access_error(
171 subject: &str,
172 request_label: &str,
173 requested_value: &str,
174 workspaces: &[PathBuf],
175 errors: Vec<String>,
176) -> BoxError {
177 let details = if errors.is_empty() {
178 String::new()
179 } else {
180 format!("; errors: {}", errors.join("; "))
181 };
182
183 format!(
184 "{subject} is not accessible from any configured workspace ({request_label}: {}, workspaces: [{}]){}",
185 requested_value,
186 format_workspaces(workspaces),
187 details
188 )
189 .into()
190}
191
192pub async fn resolve_read_path(workspace: &Path, user_path: &str) -> Result<PathBuf, BoxError> {
194 let resolved_workspace = resolve_workspace_path(workspace).await?;
195 let requested_path = Path::new(user_path);
196 let path = workspace.join(requested_path);
197
198 if !path_contains_parent_reference(requested_path) {
199 ensure_path_in_workspace_namespace(workspace, &resolved_workspace, &path)?;
200
201 return tokio::fs::canonicalize(&path)
202 .await
203 .map_err(|err| {
204 format!(
205 "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
206 workspace.display(),
207 requested_path.display(),
208 path.display()
209 )
210 .into()
211 });
212 }
213
214 let resolved_path = tokio::fs::canonicalize(&path)
215 .await
216 .map_err(|err| {
217 format!(
218 "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
219 workspace.display(),
220 requested_path.display(),
221 path.display()
222 )
223 })?;
224
225 ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
226
227 Ok(resolved_path)
228}
229
230pub async fn resolve_write_path(workspace: &Path, user_path: &str) -> Result<PathBuf, BoxError> {
232 let resolved_workspace = resolve_workspace_path(workspace).await?;
233 let path = workspace.join(user_path);
234
235 match tokio::fs::symlink_metadata(&path).await {
236 Ok(meta) => {
237 if meta.file_type().is_symlink() {
238 return Err(format!(
239 "Writing to symbolic links is not allowed (workspace: {}, path: {})",
240 workspace.display(),
241 path.display()
242 )
243 .into());
244 }
245
246 let resolved_path = tokio::fs::canonicalize(&path)
247 .await
248 .map_err(|err| {
249 format!(
250 "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
251 workspace.display(),
252 user_path,
253 path.display()
254 )
255 })?;
256 ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
257
258 Ok(resolved_path)
259 }
260 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
261 let (existing_ancestor, missing_components) = nearest_existing_ancestor(&path).await?;
262 let resolved_ancestor = tokio::fs::canonicalize(&existing_ancestor)
263 .await
264 .map_err(|err| {
265 format!(
266 "Failed to resolve file path ancestor (workspace: {}, requested_path: {}, ancestor_path: {}): {err}",
267 workspace.display(),
268 user_path,
269 existing_ancestor.display()
270 )
271 })?;
272 ensure_path_in_workspace(&resolved_workspace, &resolved_ancestor)?;
273
274 Ok(missing_components
275 .into_iter()
276 .rev()
277 .fold(resolved_ancestor, |acc, component| acc.join(component)))
278 }
279 Err(err) => Err(format!(
280 "Failed to inspect file path (workspace: {}, path: {}): {err}",
281 workspace.display(),
282 path.display()
283 )
284 .into()),
285 }
286}
287
288pub(crate) async fn resolve_workspace_path(workspace: &Path) -> Result<PathBuf, BoxError> {
289 tokio::fs::canonicalize(workspace).await.map_err(|err| {
290 format!(
291 "Failed to resolve workspace path (workspace: {}): {err}",
292 workspace.display()
293 )
294 .into()
295 })
296}
297
298pub(crate) fn ensure_path_in_workspace(
299 resolved_workspace: &Path,
300 resolved_path: &Path,
301) -> Result<(), BoxError> {
302 if !resolved_path.starts_with(resolved_workspace) {
303 return Err(format!(
304 "Access to paths outside the workspace is not allowed (resolved_workspace: {}, resolved_path: {})",
305 resolved_workspace.display(),
306 resolved_path.display()
307 )
308 .into());
309 }
310
311 Ok(())
312}
313
314pub(crate) fn path_contains_parent_reference(path: &Path) -> bool {
316 path.components()
317 .any(|component| matches!(component, Component::ParentDir))
318}
319
320pub(crate) fn ensure_path_in_workspace_namespace(
322 workspace: &Path,
323 resolved_workspace: &Path,
324 requested_path: &Path,
325) -> Result<(), BoxError> {
326 if requested_path.starts_with(workspace) || requested_path.starts_with(resolved_workspace) {
327 return Ok(());
328 }
329
330 Err(format!(
331 "Access to paths outside the workspace is not allowed (workspace: {}, resolved_workspace: {}, requested_path: {})",
332 workspace.display(),
333 resolved_workspace.display(),
334 requested_path.display()
335 )
336 .into())
337}
338
339pub(crate) fn default_write_encoding() -> String {
341 UTF8_ENCODING.to_string()
342}
343
344pub(crate) fn has_multiple_hard_links(metadata: &Metadata) -> bool {
349 link_count(metadata) > 1
350}
351
352pub(crate) fn ensure_regular_file(
353 metadata: &Metadata,
354 path: &Path,
355 hard_link_error: &str,
356) -> Result<(), BoxError> {
357 if has_multiple_hard_links(metadata) {
358 return Err(format!("{} (path: {})", hard_link_error, path.display()).into());
359 }
360
361 if !metadata.is_file() {
362 return Err(format!(
363 "Path does not point to a regular file (path: {})",
364 path.display()
365 )
366 .into());
367 }
368
369 Ok(())
370}
371
372pub(crate) fn ensure_file_size_within_limit(
373 metadata: &Metadata,
374 path: &Path,
375 max_size_bytes: u64,
376) -> Result<(), BoxError> {
377 if metadata.len() > max_size_bytes {
378 return Err(format!(
379 "File size {} exceeds maximum allowed size of {} bytes (path: {})",
380 metadata.len(),
381 max_size_bytes,
382 path.display()
383 )
384 .into());
385 }
386
387 Ok(())
388}
389
390#[cfg(unix)]
391fn link_count(metadata: &Metadata) -> u64 {
392 use std::os::unix::fs::MetadataExt;
393 metadata.nlink()
394}
395
396#[cfg(windows)]
397fn link_count(_metadata: &Metadata) -> u64 {
398 1
402}
403
404#[cfg(not(any(unix, windows)))]
405fn link_count(_metadata: &Metadata) -> u64 {
406 1
407}
408
409pub async fn atomic_write_file(
411 target_path: &Path,
412 data: &[u8],
413 existing_permissions: Option<&Permissions>,
414) -> Result<(), BoxError> {
415 let temp_path =
416 write_temp_file_for_atomic_replace(target_path, data, existing_permissions).await?;
417
418 if let Err(err) = commit_atomic_replace(&temp_path, target_path).await {
419 let _ = tokio::fs::remove_file(&temp_path).await;
420 return Err(err);
421 }
422
423 Ok(())
424}
425
426pub(crate) async fn write_temp_file_for_atomic_replace(
427 target_path: &Path,
428 data: &[u8],
429 existing_permissions: Option<&Permissions>,
430) -> Result<PathBuf, BoxError> {
431 for _ in 0..16 {
432 let temp_path = atomic_temp_path(target_path)?;
433 let mut file = match tokio::fs::OpenOptions::new()
434 .create_new(true)
435 .write(true)
436 .open(&temp_path)
437 .await
438 {
439 Ok(file) => file,
440 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
441 Err(err) => {
442 return Err(format!(
443 "Failed to create temporary file (target_path: {}, temp_path: {}): {err}",
444 target_path.display(),
445 temp_path.display()
446 )
447 .into());
448 }
449 };
450
451 let write_result = async {
452 file.write_all(data)
453 .await
454 .map_err(|err| {
455 format!(
456 "Failed to write temporary file (target_path: {}, temp_path: {}): {err}",
457 target_path.display(),
458 temp_path.display()
459 )
460 })?;
461
462 if let Some(permissions) = existing_permissions {
463 tokio::fs::set_permissions(&temp_path, permissions.clone())
464 .await
465 .map_err(|err| {
466 format!(
467 "Failed to apply file permissions (target_path: {}, temp_path: {}): {err}",
468 target_path.display(),
469 temp_path.display()
470 )
471 })?;
472 }
473
474 file.sync_all()
475 .await
476 .map_err(|err| {
477 format!(
478 "Failed to sync temporary file (target_path: {}, temp_path: {}): {err}",
479 target_path.display(),
480 temp_path.display()
481 )
482 })?;
483
484 Ok::<(), BoxError>(())
485 }
486 .await;
487 drop(file);
488
489 if let Err(err) = write_result {
490 let _ = tokio::fs::remove_file(&temp_path).await;
491 return Err(err);
492 }
493
494 return Ok(temp_path);
495 }
496
497 Err(format!(
498 "Failed to allocate unique temporary file for atomic write (target_path: {})",
499 target_path.display()
500 )
501 .into())
502}
503
504pub(crate) async fn commit_atomic_replace(
505 temp_path: &Path,
506 target_path: &Path,
507) -> Result<(), BoxError> {
508 tokio::fs::rename(temp_path, target_path)
509 .await
510 .map_err(|err| {
511 format!(
512 "Failed to atomically replace file (temp_path: {}, target_path: {}): {err}",
513 temp_path.display(),
514 target_path.display()
515 )
516 .into()
517 })
518}
519
520fn atomic_temp_path(target_path: &Path) -> Result<PathBuf, BoxError> {
521 let parent = target_path.parent().ok_or_else(|| {
522 format!(
523 "Failed to determine parent directory for write target (target_path: {})",
524 target_path.display()
525 )
526 })?;
527 let file_name = target_path.file_name().ok_or_else(|| {
528 format!(
529 "Failed to determine file name for write target (target_path: {})",
530 target_path.display()
531 )
532 })?;
533
534 let mut temp_name = OsString::from(".");
535 temp_name.push(file_name);
536 temp_name.push(format!(".anda-tmp-{:016x}", rand::random::<u64>()));
537
538 Ok(parent.join(temp_name))
539}
540
541pub(crate) async fn nearest_existing_ancestor(
543 path: &Path,
544) -> Result<(PathBuf, Vec<OsString>), BoxError> {
545 let mut current = path.to_path_buf();
546 let mut missing_components = Vec::new();
547
548 loop {
549 match tokio::fs::symlink_metadata(¤t).await {
550 Ok(_) => return Ok((current, missing_components)),
551 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
552 let file_name = current.file_name().ok_or_else(|| {
553 format!(
554 "Access to paths outside the workspace is not allowed while resolving ancestor (requested_path: {}, current_path: {})",
555 path.display(),
556 current.display()
557 )
558 })?;
559 missing_components.push(file_name.to_os_string());
560 current = current
561 .parent()
562 .ok_or_else(|| {
563 format!(
564 "Access to paths outside the workspace is not allowed while resolving ancestor (requested_path: {}, current_path: {})",
565 path.display(),
566 current.display()
567 )
568 })?
569 .to_path_buf();
570 }
571 Err(err) => {
572 return Err(format!(
573 "Failed to inspect file path while resolving ancestor (requested_path: {}, current_path: {}): {err}",
574 path.display(),
575 current.display()
576 )
577 .into())
578 }
579 }
580 }
581}
582
583pub(crate) fn normalize_relative_path(path: &Path) -> String {
584 let value = path
585 .to_string_lossy()
586 .replace(std::path::MAIN_SEPARATOR, "/");
587 if value.is_empty() {
588 ".".to_string()
589 } else {
590 value
591 }
592}