1use anda_core::{
11 BoxError, RequestMeta, ToolGroupInfo, platform_text_encoding, text_encoding_for_label,
12 text_encoding_label, text_from_bytes_with_encoding,
13};
14use encoding_rs::Encoding;
15use std::{
16 ffi::OsString,
17 fmt,
18 fs::{Metadata, Permissions},
19 path::{Component, Path, PathBuf},
20};
21use tokio::io::AsyncWriteExt;
22
23mod edit;
24mod read;
25mod search;
26mod write;
27
28pub use edit::*;
29pub use read::*;
30pub use search::*;
31pub use write::*;
32
33pub const FS_TOOL_GROUP_ID: &str = "fs_workspace";
35
36pub fn fs_tool_group_info() -> ToolGroupInfo {
42 ToolGroupInfo {
43 id: FS_TOOL_GROUP_ID.to_string(),
44 title: "Filesystem workspace".to_string(),
45 description: "Read, search, edit, and write files within the agent's sandboxed workspace directories.".to_string(),
46 instructions: Some(
47 "These tools share one set of sandboxed workspace directories; paths are workspace-relative and access outside the workspace is denied. Typical flow: use `search_file` to locate content and `read_file` to inspect it (paging large files with offset/limit), then `edit_file` for targeted in-place changes or `write_file` to create or replace a whole file.".to_string(),
48 ),
49 }
50}
51
52pub(crate) const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
53
54pub(crate) const MAX_INLINE_CONTENT_BYTES: usize = 256 * 1024;
57
58pub(crate) const UTF8_ENCODING: &str = "utf8";
59pub(crate) const BASE64_ENCODING: &str = "base64";
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub(crate) struct DecodedFileText {
63 pub(crate) text: String,
64 pub(crate) encoding: String,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub(crate) enum FileTextEncodeError {
69 UnsupportedEncoding,
70 UnmappableCharacters,
71}
72
73impl fmt::Display for FileTextEncodeError {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 Self::UnsupportedEncoding => f.write_str("unsupported text encoding"),
77 Self::UnmappableCharacters => f.write_str(
78 "content contains characters not representable in the requested encoding",
79 ),
80 }
81 }
82}
83
84impl std::error::Error for FileTextEncodeError {}
85
86#[derive(Debug, Clone)]
87pub(crate) struct ResolvedFilePath {
88 pub(crate) workspace: PathBuf,
89 pub(crate) path: PathBuf,
90}
91
92pub(crate) fn normalize_workspaces<I>(workspaces: I) -> Vec<PathBuf>
93where
94 I: IntoIterator<Item = PathBuf>,
95{
96 let mut normalized = Vec::new();
97 for workspace in workspaces {
98 push_workspace(&mut normalized, workspace);
99 }
100
101 normalized
102}
103
104pub(crate) fn tool_workspaces(meta: &RequestMeta, defaults: &[PathBuf]) -> Vec<PathBuf> {
105 let mut workspaces = Vec::new();
106
107 if let Some(workspace) = meta.get_extra_as::<PathBuf>("workspace") {
108 push_workspace(&mut workspaces, workspace);
109 } else if let Some(extra_workspaces) = meta.get_extra_as::<Vec<PathBuf>>("workspace") {
110 for workspace in extra_workspaces {
111 push_workspace(&mut workspaces, workspace);
112 }
113 }
114
115 if let Some(workspace) = meta.get_extra_as::<PathBuf>("workspaces") {
116 push_workspace(&mut workspaces, workspace);
117 } else if let Some(extra_workspaces) = meta.get_extra_as::<Vec<PathBuf>>("workspaces") {
118 for workspace in extra_workspaces {
119 push_workspace(&mut workspaces, workspace);
120 }
121 }
122
123 for workspace in defaults {
124 push_workspace(&mut workspaces, workspace.clone());
125 }
126
127 workspaces
128}
129
130pub(crate) fn format_workspaces(workspaces: &[PathBuf]) -> String {
131 if workspaces.is_empty() {
132 return "<none>".to_string();
133 }
134
135 workspaces
136 .iter()
137 .map(|workspace| workspace.display().to_string())
138 .collect::<Vec<_>>()
139 .join(", ")
140}
141
142fn push_workspace(workspaces: &mut Vec<PathBuf>, workspace: PathBuf) {
143 if workspace.as_os_str().is_empty() {
144 return;
145 }
146
147 if !workspaces.iter().any(|existing| existing == &workspace) {
148 workspaces.push(workspace);
149 }
150}
151
152pub(crate) async fn resolve_read_path_in_workspaces(
153 workspaces: &[PathBuf],
154 user_path: &str,
155) -> Result<ResolvedFilePath, BoxError> {
156 let mut errors = Vec::new();
157
158 for workspace in workspaces {
159 match resolve_read_path(workspace, user_path).await {
160 Ok(path) => {
161 return Ok(ResolvedFilePath {
162 workspace: workspace.clone(),
163 path,
164 });
165 }
166 Err(err) => errors.push(format!("{}: {err}", workspace.display())),
167 }
168 }
169
170 Err(workspace_access_error(
171 "Path",
172 "requested_path",
173 user_path,
174 workspaces,
175 errors,
176 ))
177}
178
179pub(crate) async fn resolve_write_path_in_workspaces(
180 workspaces: &[PathBuf],
181 user_path: &str,
182) -> Result<ResolvedFilePath, BoxError> {
183 let requested_path = Path::new(user_path);
184
185 if requested_path.is_relative() {
186 for workspace in workspaces {
187 let candidate_path = workspace.join(requested_path);
188 match tokio::fs::symlink_metadata(&candidate_path).await {
189 Ok(_) => {
190 let path = resolve_write_path(workspace, user_path).await?;
191 return Ok(ResolvedFilePath {
192 workspace: workspace.clone(),
193 path,
194 });
195 }
196 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
197 Err(err) => {
198 return Err(format!(
199 "Failed to inspect file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
200 workspace.display(),
201 user_path,
202 candidate_path.display()
203 )
204 .into());
205 }
206 }
207 }
208 }
209
210 let mut errors = Vec::new();
211 for workspace in workspaces {
212 match resolve_write_path(workspace, user_path).await {
213 Ok(path) => {
214 return Ok(ResolvedFilePath {
215 workspace: workspace.clone(),
216 path,
217 });
218 }
219 Err(err) => errors.push(format!("{}: {err}", workspace.display())),
220 }
221 }
222
223 Err(workspace_access_error(
224 "Path",
225 "requested_path",
226 user_path,
227 workspaces,
228 errors,
229 ))
230}
231
232pub(crate) fn workspace_access_error(
233 subject: &str,
234 request_label: &str,
235 requested_value: &str,
236 workspaces: &[PathBuf],
237 errors: Vec<String>,
238) -> BoxError {
239 let details = if errors.is_empty() {
240 String::new()
241 } else {
242 format!("; errors: {}", errors.join("; "))
243 };
244
245 format!(
246 "{subject} is not accessible from any configured workspace ({request_label}: {}, workspaces: [{}]){}",
247 requested_value,
248 format_workspaces(workspaces),
249 details
250 )
251 .into()
252}
253
254pub async fn resolve_read_path(workspace: &Path, user_path: &str) -> Result<PathBuf, BoxError> {
256 let resolved_workspace = resolve_workspace_path(workspace).await?;
257 let requested_path = Path::new(user_path);
258 let path = workspace.join(requested_path);
259
260 if !path_contains_parent_reference(requested_path) {
261 ensure_path_in_workspace_namespace(workspace, &resolved_workspace, &path)?;
262
263 return tokio::fs::canonicalize(&path)
264 .await
265 .map_err(|err| {
266 format!(
267 "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
268 workspace.display(),
269 requested_path.display(),
270 path.display()
271 )
272 .into()
273 });
274 }
275
276 let resolved_path = tokio::fs::canonicalize(&path)
277 .await
278 .map_err(|err| {
279 format!(
280 "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
281 workspace.display(),
282 requested_path.display(),
283 path.display()
284 )
285 })?;
286
287 ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
288
289 Ok(resolved_path)
290}
291
292pub async fn resolve_write_path(workspace: &Path, user_path: &str) -> Result<PathBuf, BoxError> {
294 let resolved_workspace = resolve_workspace_path(workspace).await?;
295 let path = workspace.join(user_path);
296
297 match tokio::fs::symlink_metadata(&path).await {
298 Ok(meta) => {
299 if meta.file_type().is_symlink() {
300 return Err(format!(
301 "Writing to symbolic links is not allowed (workspace: {}, path: {})",
302 workspace.display(),
303 path.display()
304 )
305 .into());
306 }
307
308 let resolved_path = tokio::fs::canonicalize(&path)
309 .await
310 .map_err(|err| {
311 format!(
312 "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
313 workspace.display(),
314 user_path,
315 path.display()
316 )
317 })?;
318 ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
319
320 Ok(resolved_path)
321 }
322 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
323 let (existing_ancestor, missing_components) = nearest_existing_ancestor(&path).await?;
324 let resolved_ancestor = tokio::fs::canonicalize(&existing_ancestor)
325 .await
326 .map_err(|err| {
327 format!(
328 "Failed to resolve file path ancestor (workspace: {}, requested_path: {}, ancestor_path: {}): {err}",
329 workspace.display(),
330 user_path,
331 existing_ancestor.display()
332 )
333 })?;
334 ensure_path_in_workspace(&resolved_workspace, &resolved_ancestor)?;
335
336 Ok(missing_components
337 .into_iter()
338 .rev()
339 .fold(resolved_ancestor, |acc, component| acc.join(component)))
340 }
341 Err(err) => Err(format!(
342 "Failed to inspect file path (workspace: {}, path: {}): {err}",
343 workspace.display(),
344 path.display()
345 )
346 .into()),
347 }
348}
349
350pub(crate) async fn resolve_workspace_path(workspace: &Path) -> Result<PathBuf, BoxError> {
351 tokio::fs::canonicalize(workspace).await.map_err(|err| {
352 format!(
353 "Failed to resolve workspace path (workspace: {}): {err}",
354 workspace.display()
355 )
356 .into()
357 })
358}
359
360pub(crate) fn ensure_path_in_workspace(
361 resolved_workspace: &Path,
362 resolved_path: &Path,
363) -> Result<(), BoxError> {
364 if !resolved_path.starts_with(resolved_workspace) {
365 return Err(format!(
366 "Access to paths outside the workspace is not allowed (resolved_workspace: {}, resolved_path: {})",
367 resolved_workspace.display(),
368 resolved_path.display()
369 )
370 .into());
371 }
372
373 Ok(())
374}
375
376pub(crate) fn path_contains_parent_reference(path: &Path) -> bool {
378 path.components()
379 .any(|component| matches!(component, Component::ParentDir))
380}
381
382pub(crate) fn ensure_path_in_workspace_namespace(
384 workspace: &Path,
385 resolved_workspace: &Path,
386 requested_path: &Path,
387) -> Result<(), BoxError> {
388 if requested_path.starts_with(workspace) || requested_path.starts_with(resolved_workspace) {
389 return Ok(());
390 }
391
392 Err(format!(
393 "Access to paths outside the workspace is not allowed (workspace: {}, resolved_workspace: {}, requested_path: {})",
394 workspace.display(),
395 resolved_workspace.display(),
396 requested_path.display()
397 )
398 .into())
399}
400
401pub(crate) fn default_write_encoding() -> String {
403 UTF8_ENCODING.to_string()
404}
405
406pub(crate) fn decode_file_text(bytes: Vec<u8>) -> Result<DecodedFileText, Vec<u8>> {
407 decode_file_text_with_fallback(bytes, platform_text_encoding())
408}
409
410fn decode_file_text_with_fallback(
411 bytes: Vec<u8>,
412 fallback_encoding: Option<&'static Encoding>,
413) -> Result<DecodedFileText, Vec<u8>> {
414 let bytes = match String::from_utf8(bytes) {
416 Ok(text) => {
417 return Ok(DecodedFileText {
418 text,
419 encoding: UTF8_ENCODING.to_string(),
420 });
421 }
422 Err(err) => err.into_bytes(),
423 };
424
425 let Some(encoding) = fallback_encoding else {
426 return Err(bytes);
427 };
428 if encoding.name() == "UTF-8" {
429 return Err(bytes);
430 }
431
432 let text = match text_from_bytes_with_encoding(&bytes, Some(encoding)) {
433 Some(text) => text.into_owned(),
434 None => return Err(bytes),
435 };
436 if !is_text_like(&text) {
437 return Err(bytes);
438 }
439
440 Ok(DecodedFileText {
441 text,
442 encoding: text_encoding_label(encoding),
443 })
444}
445
446pub(crate) fn encode_file_text(
447 content: &str,
448 encoding_label: &str,
449) -> Result<Vec<u8>, FileTextEncodeError> {
450 let encoding =
451 text_encoding_for_label(encoding_label).ok_or(FileTextEncodeError::UnsupportedEncoding)?;
452 let (bytes, _, had_errors) = encoding.encode(content);
453 if had_errors {
454 return Err(FileTextEncodeError::UnmappableCharacters);
455 }
456 Ok(bytes.into_owned())
457}
458
459fn is_text_like(text: &str) -> bool {
460 text.chars()
461 .all(|ch| matches!(ch, '\n' | '\r' | '\t' | '\u{000c}') || !ch.is_control())
462}
463
464pub(crate) fn truncate_inline_text(content: &mut String, max_bytes: usize) -> bool {
468 if content.len() <= max_bytes {
469 return false;
470 }
471
472 let end = crate::grapheme_safe_cutoff(content, max_bytes);
474 let cut = match content[..end].rfind('\n') {
475 Some(idx) if idx > 0 => idx + 1,
477 _ => end,
478 };
479 content.truncate(cut);
480 true
481}
482
483pub(crate) fn has_multiple_hard_links(metadata: &Metadata) -> bool {
488 link_count(metadata) > 1
489}
490
491pub(crate) fn ensure_regular_file(
492 metadata: &Metadata,
493 path: &Path,
494 hard_link_error: &str,
495) -> Result<(), BoxError> {
496 if has_multiple_hard_links(metadata) {
497 return Err(format!("{} (path: {})", hard_link_error, path.display()).into());
498 }
499
500 if !metadata.is_file() {
501 return Err(format!(
502 "Path does not point to a regular file (path: {})",
503 path.display()
504 )
505 .into());
506 }
507
508 Ok(())
509}
510
511pub(crate) fn ensure_file_size_within_limit(
512 metadata: &Metadata,
513 path: &Path,
514 max_size_bytes: u64,
515) -> Result<(), BoxError> {
516 if metadata.len() > max_size_bytes {
517 return Err(format!(
518 "File size {} exceeds maximum allowed size of {} bytes (path: {})",
519 metadata.len(),
520 max_size_bytes,
521 path.display()
522 )
523 .into());
524 }
525
526 Ok(())
527}
528
529#[cfg(unix)]
530fn link_count(metadata: &Metadata) -> u64 {
531 use std::os::unix::fs::MetadataExt;
532 metadata.nlink()
533}
534
535#[cfg(windows)]
536fn link_count(_metadata: &Metadata) -> u64 {
537 1
541}
542
543#[cfg(not(any(unix, windows)))]
544fn link_count(_metadata: &Metadata) -> u64 {
545 1
546}
547
548pub async fn atomic_write_file(
550 target_path: &Path,
551 data: &[u8],
552 existing_permissions: Option<&Permissions>,
553) -> Result<(), BoxError> {
554 let temp_path =
555 write_temp_file_for_atomic_replace(target_path, data, existing_permissions).await?;
556
557 if let Err(err) = commit_atomic_replace(&temp_path, target_path).await {
558 let _ = tokio::fs::remove_file(&temp_path).await;
559 return Err(err);
560 }
561
562 Ok(())
563}
564
565pub(crate) async fn write_temp_file_for_atomic_replace(
566 target_path: &Path,
567 data: &[u8],
568 existing_permissions: Option<&Permissions>,
569) -> Result<PathBuf, BoxError> {
570 for _ in 0..16 {
571 let temp_path = atomic_temp_path(target_path)?;
572 let mut file = match tokio::fs::OpenOptions::new()
573 .create_new(true)
574 .write(true)
575 .open(&temp_path)
576 .await
577 {
578 Ok(file) => file,
579 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
580 Err(err) => {
581 return Err(format!(
582 "Failed to create temporary file (target_path: {}, temp_path: {}): {err}",
583 target_path.display(),
584 temp_path.display()
585 )
586 .into());
587 }
588 };
589
590 let write_result = async {
591 file.write_all(data)
592 .await
593 .map_err(|err| {
594 format!(
595 "Failed to write temporary file (target_path: {}, temp_path: {}): {err}",
596 target_path.display(),
597 temp_path.display()
598 )
599 })?;
600
601 if let Some(permissions) = existing_permissions {
602 tokio::fs::set_permissions(&temp_path, permissions.clone())
603 .await
604 .map_err(|err| {
605 format!(
606 "Failed to apply file permissions (target_path: {}, temp_path: {}): {err}",
607 target_path.display(),
608 temp_path.display()
609 )
610 })?;
611 }
612
613 file.sync_all()
614 .await
615 .map_err(|err| {
616 format!(
617 "Failed to sync temporary file (target_path: {}, temp_path: {}): {err}",
618 target_path.display(),
619 temp_path.display()
620 )
621 })?;
622
623 Ok::<(), BoxError>(())
624 }
625 .await;
626 drop(file);
627
628 if let Err(err) = write_result {
629 let _ = tokio::fs::remove_file(&temp_path).await;
630 return Err(err);
631 }
632
633 return Ok(temp_path);
634 }
635
636 Err(format!(
637 "Failed to allocate unique temporary file for atomic write (target_path: {})",
638 target_path.display()
639 )
640 .into())
641}
642
643pub(crate) async fn commit_atomic_replace(
644 temp_path: &Path,
645 target_path: &Path,
646) -> Result<(), BoxError> {
647 tokio::fs::rename(temp_path, target_path)
648 .await
649 .map_err(|err| {
650 format!(
651 "Failed to atomically replace file (temp_path: {}, target_path: {}): {err}",
652 temp_path.display(),
653 target_path.display()
654 )
655 .into()
656 })
657}
658
659fn atomic_temp_path(target_path: &Path) -> Result<PathBuf, BoxError> {
660 let parent = target_path.parent().ok_or_else(|| {
661 format!(
662 "Failed to determine parent directory for write target (target_path: {})",
663 target_path.display()
664 )
665 })?;
666 let file_name = target_path.file_name().ok_or_else(|| {
667 format!(
668 "Failed to determine file name for write target (target_path: {})",
669 target_path.display()
670 )
671 })?;
672
673 let mut temp_name = OsString::from(".");
674 temp_name.push(file_name);
675 temp_name.push(format!(".anda-tmp-{:016x}", rand::random::<u64>()));
676
677 Ok(parent.join(temp_name))
678}
679
680pub(crate) async fn nearest_existing_ancestor(
682 path: &Path,
683) -> Result<(PathBuf, Vec<OsString>), BoxError> {
684 let mut current = path.to_path_buf();
685 let mut missing_components = Vec::new();
686
687 loop {
688 match tokio::fs::symlink_metadata(¤t).await {
689 Ok(_) => return Ok((current, missing_components)),
690 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
691 let file_name = current.file_name().ok_or_else(|| {
692 format!(
693 "Access to paths outside the workspace is not allowed while resolving ancestor (requested_path: {}, current_path: {})",
694 path.display(),
695 current.display()
696 )
697 })?;
698 missing_components.push(file_name.to_os_string());
699 current = current
700 .parent()
701 .ok_or_else(|| {
702 format!(
703 "Access to paths outside the workspace is not allowed while resolving ancestor (requested_path: {}, current_path: {})",
704 path.display(),
705 current.display()
706 )
707 })?
708 .to_path_buf();
709 }
710 Err(err) => {
711 return Err(format!(
712 "Failed to inspect file path while resolving ancestor (requested_path: {}, current_path: {}): {err}",
713 path.display(),
714 current.display()
715 )
716 .into())
717 }
718 }
719 }
720}
721
722pub(crate) fn normalize_relative_path(path: &Path) -> String {
723 let value = path
724 .to_string_lossy()
725 .replace(std::path::MAIN_SEPARATOR, "/");
726 if value.is_empty() {
727 ".".to_string()
728 } else {
729 value
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736 use anda_core::RequestMeta;
737 use serde_json::json;
738
739 fn temp_dir(name: &str) -> PathBuf {
740 std::env::temp_dir().join(format!("anda-fs-{name}-{:016x}", rand::random::<u64>()))
741 }
742
743 #[test]
744 fn fs_tools_form_one_capability_group() {
745 use crate::context::BaseCtx;
746 use anda_core::ToolSet;
747 use std::sync::Arc;
748
749 let workspace = PathBuf::from("/tmp/anda-fs-group");
750 let mut tools = ToolSet::<BaseCtx>::new();
751 tools
752 .add(Arc::new(ReadFileTool::new(workspace.clone())))
753 .unwrap();
754 tools
755 .add(Arc::new(WriteFileTool::new(workspace.clone())))
756 .unwrap();
757 tools
758 .add(Arc::new(EditFileTool::new(workspace.clone())))
759 .unwrap();
760 tools.add(Arc::new(SearchFileTool::new(workspace))).unwrap();
761
762 let groups = tools.groups();
763 assert_eq!(groups.len(), 1);
764 assert_eq!(groups[0].id, FS_TOOL_GROUP_ID);
765 assert_eq!(
767 groups[0].members,
768 vec![
769 "edit_file".to_string(),
770 "read_file".to_string(),
771 "search_file".to_string(),
772 "write_file".to_string(),
773 ]
774 );
775 assert!(groups[0].instructions.is_some());
776 }
777
778 #[test]
779 fn workspace_helpers_normalize_dedupe_and_report_empty_sets() {
780 let first = PathBuf::from("/tmp/one");
781 let second = PathBuf::from("/tmp/two");
782 let third = PathBuf::from("/tmp/three");
783 let fourth = PathBuf::from("/tmp/four");
784
785 assert_eq!(
786 normalize_workspaces(vec![
787 PathBuf::new(),
788 first.clone(),
789 first.clone(),
790 second.clone()
791 ]),
792 vec![first.clone(), second.clone()]
793 );
794 assert_eq!(format_workspaces(&[]), "<none>");
795 assert_eq!(
796 workspace_access_error("Path", "requested_path", "file.txt", &[], Vec::new())
797 .to_string(),
798 "Path is not accessible from any configured workspace (requested_path: file.txt, workspaces: [<none>])"
799 );
800
801 let mut meta = RequestMeta::default();
802 meta.extra.insert(
803 "workspace".to_string(),
804 json!([first, "", second.clone(), second]),
805 );
806 meta.extra
807 .insert("workspaces".to_string(), json!(third.clone()));
808
809 let workspaces = tool_workspaces(&meta, &[third, fourth.clone()]);
810 assert_eq!(
811 workspaces,
812 vec![
813 PathBuf::from("/tmp/one"),
814 PathBuf::from("/tmp/two"),
815 PathBuf::from("/tmp/three"),
816 fourth
817 ]
818 );
819 }
820
821 #[test]
822 fn file_text_encoding_decodes_legacy_text_and_rejects_binary() {
823 let gbk = vec![0xd6, 0xd0, 0xce, 0xc4, b'.', b't', b'x', b't', b'\n'];
824
825 let decoded = decode_file_text_with_fallback(gbk.clone(), Some(encoding_rs::GBK)).unwrap();
826 assert_eq!(
827 decoded,
828 DecodedFileText {
829 text: "中文.txt\n".to_string(),
830 encoding: "gbk".to_string(),
831 }
832 );
833
834 let utf8 = decode_file_text_with_fallback(
835 "中文.txt\n".as_bytes().to_vec(),
836 Some(encoding_rs::GBK),
837 )
838 .unwrap();
839 assert_eq!(utf8.text, "中文.txt\n");
840 assert_eq!(utf8.encoding, UTF8_ENCODING);
841
842 let binary = vec![0xff, 0x00, 0x81, 0x7f];
843 assert_eq!(
844 decode_file_text_with_fallback(binary.clone(), Some(encoding_rs::GBK)).unwrap_err(),
845 binary
846 );
847 }
848
849 #[test]
850 fn file_text_encoding_encodes_legacy_text() {
851 let gbk = vec![0xd6, 0xd0, 0xce, 0xc4, b'.', b't', b'x', b't', b'\n'];
852
853 assert_eq!(encode_file_text("中文.txt\n", "gbk").unwrap(), gbk);
854 assert_eq!(
855 encode_file_text("hello", "utf-8").unwrap(),
856 b"hello".to_vec()
857 );
858 assert_eq!(
859 encode_file_text("hello", "not-an-encoding").unwrap_err(),
860 FileTextEncodeError::UnsupportedEncoding
861 );
862 }
863
864 #[test]
865 fn truncate_inline_text_prefers_line_then_char_boundaries() {
866 let mut text = "short".to_string();
867 assert!(!truncate_inline_text(&mut text, 10));
868 assert_eq!(text, "short");
869
870 let mut text = "line one\nline two\nline three".to_string();
871 assert!(truncate_inline_text(&mut text, 20));
872 assert_eq!(text, "line one\nline two\n");
873
874 let mut text = "中文内容没有换行".to_string();
876 assert!(truncate_inline_text(&mut text, 10));
877 assert_eq!(text, "中文内");
878
879 let mut text = "\nabcdefghijklmnop".to_string();
881 assert!(truncate_inline_text(&mut text, 8));
882 assert_eq!(text, "\nabcdefg");
883
884 let family = "👨👩👧👦";
888 let mut text = family.repeat(3); assert!(truncate_inline_text(&mut text, 60));
890 assert_eq!(text, family.repeat(2));
891 }
892
893 #[test]
894 fn file_metadata_guards_reject_non_regular_large_and_hardlinked_files() {
895 let root = temp_dir("metadata");
896 std::fs::create_dir_all(&root).unwrap();
897 let file = root.join("file.txt");
898 std::fs::write(&file, b"abcd").unwrap();
899
900 let file_meta = std::fs::metadata(&file).unwrap();
901 ensure_file_size_within_limit(&file_meta, &file, 4).unwrap();
902 assert!(
903 ensure_file_size_within_limit(&file_meta, &file, 3)
904 .unwrap_err()
905 .to_string()
906 .contains("exceeds maximum")
907 );
908
909 let dir_meta = std::fs::symlink_metadata(&root).unwrap();
910 assert!(
911 ensure_regular_file(&dir_meta, &root, "hard links blocked")
912 .unwrap_err()
913 .to_string()
914 .contains("Path does not point to a regular file")
915 || ensure_regular_file(&dir_meta, &root, "hard links blocked")
916 .unwrap_err()
917 .to_string()
918 .contains("hard links blocked")
919 );
920
921 #[cfg(unix)]
922 {
923 let link = root.join("link.txt");
924 std::fs::hard_link(&file, &link).unwrap();
925 let linked_meta = std::fs::metadata(&file).unwrap();
926 assert!(has_multiple_hard_links(&linked_meta));
927 assert!(
928 ensure_regular_file(&linked_meta, &file, "hard links blocked")
929 .unwrap_err()
930 .to_string()
931 .contains("hard links blocked")
932 );
933 }
934
935 let _ = std::fs::remove_dir_all(root);
936 }
937
938 #[tokio::test(flavor = "current_thread")]
939 async fn resolve_helpers_cover_parent_paths_missing_tails_and_errors() {
940 let root = temp_dir("resolve");
941 tokio::fs::create_dir_all(root.join("dir")).await.unwrap();
942 tokio::fs::write(root.join("dir/file.txt"), b"ok")
943 .await
944 .unwrap();
945
946 let parent_read = resolve_read_path(&root, "dir/../dir/file.txt")
947 .await
948 .unwrap();
949 assert_eq!(
950 parent_read,
951 tokio::fs::canonicalize(root.join("dir/file.txt"))
952 .await
953 .unwrap()
954 );
955
956 let canonical_root = tokio::fs::canonicalize(&root).await.unwrap();
957 let write_path = resolve_write_path(&root, "new/nested/file.txt")
958 .await
959 .unwrap();
960 assert_eq!(write_path, canonical_root.join("new/nested/file.txt"));
961
962 let selected = resolve_write_path_in_workspaces(
963 &[root.join("missing"), root.clone()],
964 "new/nested/file.txt",
965 )
966 .await
967 .unwrap();
968 assert_eq!(selected.workspace, root);
969 assert!(selected.path.ends_with("new/nested/file.txt"));
970
971 let read_err = resolve_read_path_in_workspaces(&[], "missing.txt")
972 .await
973 .unwrap_err();
974 assert!(read_err.to_string().contains("workspaces: [<none>]"));
975
976 let missing_workspace = resolve_workspace_path(Path::new("/definitely/missing/anda"))
977 .await
978 .unwrap_err();
979 assert!(
980 missing_workspace
981 .to_string()
982 .contains("Failed to resolve workspace path")
983 );
984
985 assert!(path_contains_parent_reference(Path::new("a/../b")));
986 assert!(!path_contains_parent_reference(Path::new("a/b")));
987 assert!(
988 ensure_path_in_workspace_namespace(
989 Path::new("/tmp/work"),
990 Path::new("/tmp/work"),
991 Path::new("/tmp/other/file.txt"),
992 )
993 .unwrap_err()
994 .to_string()
995 .contains("outside the workspace")
996 );
997
998 let _ = tokio::fs::remove_dir_all(selected.workspace).await;
999 }
1000
1001 #[tokio::test(flavor = "current_thread")]
1002 async fn atomic_write_helpers_commit_cleanup_and_path_formatting() {
1003 let root = temp_dir("atomic");
1004 tokio::fs::create_dir_all(&root).await.unwrap();
1005 let target = root.join("file.txt");
1006
1007 atomic_write_file(&target, b"first", None).await.unwrap();
1008 assert_eq!(tokio::fs::read(&target).await.unwrap(), b"first");
1009
1010 let permissions = tokio::fs::metadata(&target).await.unwrap().permissions();
1011 atomic_write_file(&target, b"second", Some(&permissions))
1012 .await
1013 .unwrap();
1014 assert_eq!(tokio::fs::read(&target).await.unwrap(), b"second");
1015
1016 let temp = write_temp_file_for_atomic_replace(&target, b"third", None)
1017 .await
1018 .unwrap();
1019 assert!(
1020 temp.file_name()
1021 .unwrap()
1022 .to_string_lossy()
1023 .contains(".anda-tmp-")
1024 );
1025 commit_atomic_replace(&temp, &target).await.unwrap();
1026 assert_eq!(tokio::fs::read(&target).await.unwrap(), b"third");
1027
1028 let missing_temp = root.join("missing-temp");
1029 assert!(
1030 commit_atomic_replace(&missing_temp, &target)
1031 .await
1032 .unwrap_err()
1033 .to_string()
1034 .contains("Failed to atomically replace file")
1035 );
1036 assert!(
1037 atomic_write_file(&root, b"cannot replace a directory", None)
1038 .await
1039 .unwrap_err()
1040 .to_string()
1041 .contains("Failed to atomically replace file")
1042 );
1043 assert!(
1044 write_temp_file_for_atomic_replace(&root.join("missing/file.txt"), b"bad", None)
1045 .await
1046 .unwrap_err()
1047 .to_string()
1048 .contains("Failed to create temporary file")
1049 );
1050 assert!(
1051 write_temp_file_for_atomic_replace(Path::new(""), b"bad", None)
1052 .await
1053 .unwrap_err()
1054 .to_string()
1055 .contains("Failed to determine")
1056 );
1057
1058 let (ancestor, missing) = nearest_existing_ancestor(&root.join("a/b/c.txt"))
1059 .await
1060 .unwrap();
1061 assert_eq!(ancestor, root);
1062 assert_eq!(missing.len(), 3);
1063 assert!(
1064 nearest_existing_ancestor(Path::new(""))
1065 .await
1066 .unwrap_err()
1067 .to_string()
1068 .contains("outside the workspace")
1069 );
1070 assert_eq!(normalize_relative_path(Path::new("")), ".");
1071 assert_eq!(normalize_relative_path(Path::new("a/b")), "a/b");
1072 assert_eq!(default_write_encoding(), UTF8_ENCODING);
1073
1074 let _ = tokio::fs::remove_dir_all(root).await;
1075 }
1076
1077 #[tokio::test(flavor = "current_thread")]
1078 async fn deterministic_error_branches_for_read_and_metadata_guards() {
1079 let root = temp_dir("fs-errors");
1080 tokio::fs::create_dir_all(&root).await.unwrap();
1081
1082 let err = resolve_read_path(&root, "missing/../missing.txt")
1083 .await
1084 .unwrap_err();
1085 assert!(err.to_string().contains("Failed to resolve file path"));
1086
1087 #[cfg(unix)]
1088 {
1089 use std::os::unix::fs::symlink;
1090
1091 let link = root.join("link");
1092 symlink(root.join("missing-target"), &link).unwrap();
1093 let meta = std::fs::symlink_metadata(&link).unwrap();
1094 assert!(
1095 ensure_regular_file(&meta, &link, "hard links blocked")
1096 .unwrap_err()
1097 .to_string()
1098 .contains("Path does not point to a regular file")
1099 );
1100 }
1101
1102 let _ = tokio::fs::remove_dir_all(root).await;
1103 }
1104}