codex_mobile_bridge/
workspace.rs1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use uuid::Uuid;
6
7use crate::bridge_protocol::WorkspaceRecord;
8
9pub fn canonicalize_directory(path: &Path) -> Result<PathBuf> {
10 let canonical = fs::canonicalize(path)
11 .with_context(|| format!("工作区目录不存在或不可访问: {}", path.display()))?;
12
13 if !canonical.is_dir() {
14 bail!("工作区必须是目录: {}", canonical.display());
15 }
16
17 Ok(canonical)
18}
19
20pub fn resolve_workspace_path(root: &Path, relative_path: Option<&str>) -> Result<PathBuf> {
21 let raw_relative = relative_path.unwrap_or(".").trim();
22 let normalized = if raw_relative.is_empty() {
23 "."
24 } else {
25 raw_relative
26 };
27
28 let candidate = root.join(normalized);
29 let canonical = fs::canonicalize(&candidate)
30 .with_context(|| format!("目录不存在: {}", candidate.display()))?;
31
32 if !canonical.starts_with(root) {
33 bail!("路径越界,目标不在工作区内: {}", canonical.display());
34 }
35
36 if !canonical.is_dir() {
37 bail!("目标路径不是目录: {}", canonical.display());
38 }
39
40 Ok(canonical)
41}
42
43pub fn build_workspace_id(root: &Path) -> String {
44 let display = default_display_name(root);
45 let slug = slugify(&display);
46 let uuid = Uuid::new_v5(&Uuid::NAMESPACE_URL, root.to_string_lossy().as_bytes());
47 let short = uuid.simple().to_string();
48 format!("{}-{}", slug, &short[..8])
49}
50
51pub fn default_display_name(root: &Path) -> String {
52 root.file_name()
53 .and_then(|name| name.to_str())
54 .map(ToOwned::to_owned)
55 .filter(|value| !value.is_empty())
56 .unwrap_or_else(|| "workspace".to_string())
57}
58
59pub fn workspace_matches(workspace: &WorkspaceRecord, cwd: &str) -> bool {
60 let root = Path::new(&workspace.root_path);
61 let cwd_path = Path::new(cwd);
62 cwd_path.starts_with(root)
63}
64
65fn slugify(input: &str) -> String {
66 let mut slug = String::new();
67 let mut last_dash = false;
68
69 for ch in input.chars() {
70 let mapped = match ch {
71 'a'..='z' | '0'..='9' => Some(ch),
72 'A'..='Z' => Some(ch.to_ascii_lowercase()),
73 _ => None,
74 };
75
76 if let Some(value) = mapped {
77 slug.push(value);
78 last_dash = false;
79 continue;
80 }
81
82 if !last_dash {
83 slug.push('-');
84 last_dash = true;
85 }
86 }
87
88 let trimmed = slug.trim_matches('-').chars().take(32).collect::<String>();
89
90 trimmed.if_empty_then("workspace")
91}
92
93trait IfEmpty {
94 fn if_empty_then(self, fallback: &str) -> String;
95}
96
97impl IfEmpty for String {
98 fn if_empty_then(self, fallback: &str) -> String {
99 if self.is_empty() {
100 fallback.to_string()
101 } else {
102 self
103 }
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::{canonicalize_directory, resolve_workspace_path};
110 use std::fs;
111
112 #[test]
113 fn resolve_workspace_path_rejects_escape() {
114 let temp = tempfile_dir("workspace-escape");
115 let root = temp.join("root");
116 let child = root.join("child");
117 fs::create_dir_all(&child).unwrap();
118
119 let result = resolve_workspace_path(&root, Some("../"));
120 assert!(result.is_err());
121 }
122
123 #[test]
124 fn canonicalize_directory_accepts_existing_dir() {
125 let temp = tempfile_dir("workspace-ok");
126 fs::create_dir_all(&temp).unwrap();
127
128 let result = canonicalize_directory(&temp).unwrap();
129 assert!(result.is_dir());
130 }
131
132 fn tempfile_dir(name: &str) -> std::path::PathBuf {
133 let base = std::env::temp_dir().join(format!("{}-{}", name, uuid::Uuid::new_v4().simple()));
134 base
135 }
136}