Skip to main content

codex_mobile_bridge/
workspace.rs

1use 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}