Skip to main content

ralph_workflow/agents/ccs_env/
traits.rs

1// ============================================================================
2// Environment and Filesystem Traits for Testability
3// ============================================================================
4
5/// Trait for accessing CCS-related environment variables.
6///
7/// This trait enables dependency injection for testing without global state pollution.
8pub trait CcsEnvironment {
9    fn get_var(&self, name: &str) -> Option<String>;
10
11    fn home_dir(&self) -> Option<PathBuf>;
12}
13
14/// Trait for CCS filesystem operations.
15///
16/// This trait abstracts filesystem access for testability.
17pub trait CcsFilesystem {
18    fn exists(&self, path: &std::path::Path) -> bool;
19
20    fn read_to_string(&self, path: &std::path::Path) -> std::io::Result<String>;
21
22    fn read_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<CcsDirEntry>>;
23}
24
25/// Directory entry for `CcsFilesystem`.
26pub struct CcsDirEntry {
27    pub path: std::path::PathBuf,
28    pub file_name: String,
29    pub is_file: bool,
30}
31
32/// Subset of CCS' legacy `~/.ccs/config.json` format.
33///
34/// Source (CCS): `dist/types/config.d.ts` and `dist/utils/config-manager.js`.
35#[derive(Debug, serde::Deserialize)]
36pub(crate) struct CcsConfigJson {
37    pub(crate) profiles: std::collections::HashMap<String, String>,
38}
39
40/// Errors that can occur when loading CCS environment variables.
41#[derive(Debug, thiserror::Error)]
42pub enum CcsEnvVarsError {
43    #[error("Invalid CCS profile name '{profile}' (must be non-empty)")]
44    InvalidProfile { profile: String },
45    #[error("Could not determine home directory for CCS settings")]
46    MissingHomeDir,
47    #[error("No CCS settings file found for profile '{profile}' in {ccs_dir}")]
48    ProfileNotFound {
49        profile: String,
50        ccs_dir: std::path::PathBuf,
51    },
52    #[error("Failed to read CCS config at {path}: {source}")]
53    ReadConfig {
54        path: std::path::PathBuf,
55        source: std::io::Error,
56    },
57    #[error("Failed to parse CCS config JSON at {path}: {source}")]
58    ParseConfigJson {
59        path: std::path::PathBuf,
60        source: serde_json::Error,
61    },
62    #[error("Failed to read CCS settings file at {path}: {source}")]
63    ReadFile {
64        path: std::path::PathBuf,
65        source: std::io::Error,
66    },
67    #[error("Failed to parse CCS settings JSON at {path}: {source}")]
68    ParseJson {
69        path: std::path::PathBuf,
70        source: serde_json::Error,
71    },
72    #[error("Could not find an environment-variable map in CCS settings JSON at {path}")]
73    MissingEnv { path: std::path::PathBuf },
74    #[error("CCS settings JSON at {path} contains invalid env var name '{key}'")]
75    InvalidEnvVarName {
76        path: std::path::PathBuf,
77        key: String,
78    },
79    #[error("CCS settings JSON at {path} has non-string env value for key '{key}'")]
80    NonStringEnvVarValue {
81        path: std::path::PathBuf,
82        key: String,
83    },
84    #[error(
85        "CCS settings JSON at {path} contains dangerous env var '{key}' (not allowed from external config)"
86    )]
87    DangerousEnvVar {
88        path: std::path::PathBuf,
89        key: String,
90    },
91    #[error("CCS settings JSON at {path} contains unsafe env value for key '{key}'")]
92    UnsafeEnvVarValue {
93        path: std::path::PathBuf,
94        key: String,
95    },
96    #[error(
97        "CCS config at {path} contains unsafe settings path '{settings_path}' (path traversal not allowed)"
98    )]
99    UnsafeSettingsPath {
100        path: std::path::PathBuf,
101        settings_path: String,
102    },
103}
104
105/// List of dangerous environment variable names that should not be allowed from external config.
106const DANGEROUS_ENV_VAR_NAMES: &[&str] = &[
107    "LD_PRELOAD",
108    "LD_LIBRARY_PATH",
109    "DYLD_INSERT_LIBRARIES",
110    "DYLD_LIBRARY_PATH",
111    "IFS",
112    "PATH",
113    "SHELL",
114    "ENV",
115    "BASH_ENV",
116];
117
118/// Check if an environment variable name is dangerous (could be used for injection).
119pub(crate) fn is_dangerous_env_var_name(name: &str) -> bool {
120    DANGEROUS_ENV_VAR_NAMES
121        .iter()
122        .any(|&dangerous| name.eq_ignore_ascii_case(dangerous))
123}
124
125pub(crate) fn is_valid_env_var_name_portable(name: &str) -> bool {
126    if name.is_empty() {
127        return false;
128    }
129    if name.contains('\0') || name.contains('=') {
130        return false;
131    }
132    // On Windows, environment variable names cannot start with '='.
133    #[cfg(windows)]
134    {
135        if name.starts_with('=') {
136            return false;
137        }
138    }
139    true
140}
141
142/// Validate environment variable value for safety.
143pub(crate) fn is_safe_env_var_value(value: &str) -> bool {
144    // Reject null bytes and newlines (could be used for injection)
145    if value.contains('\0') || value.contains('\n') || value.contains('\r') {
146        return false;
147    }
148    // Reject backticks (command substitution in shells)
149    if value.contains('`') {
150        return false;
151    }
152    // Allow most other characters
153    true
154}
155
156pub(crate) fn derive_ccs_profile_name_from_filename(filename: &str) -> Option<String> {
157    filename
158        .strip_suffix(".settings.json")
159        .or_else(|| filename.strip_suffix(".setting.json"))
160        .or_else(|| filename.strip_suffix(".json"))
161        .map(std::string::ToString::to_string)
162}
163
164pub(crate) fn is_ccs_settings_filename(name: &str) -> bool {
165    name.ends_with(".settings.json") || name.ends_with(".setting.json")
166}
167
168pub(crate) fn is_safe_profile_filename_stem(stem: &str) -> bool {
169    !stem.is_empty()
170        && stem
171            .chars()
172            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
173}
174
175pub(crate) fn list_ccs_json_files_with_fs(
176    fs: &dyn CcsFilesystem,
177    ccs_dir: &std::path::Path,
178) -> Result<Vec<std::path::PathBuf>, std::io::Error> {
179    fs.read_dir(ccs_dir).map(|entries| {
180        entries
181            .into_iter()
182            .filter(|entry| entry.is_file)
183            .filter(|entry| {
184                std::path::Path::new(&entry.file_name)
185                    .extension()
186                    .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
187            })
188            .map(|entry| entry.path)
189            .collect()
190    })
191}
192
193pub(crate) fn ccs_home_dir_with_env(env: &dyn CcsEnvironment) -> Option<std::path::PathBuf> {
194    env.get_var("CCS_HOME")
195        .map(std::path::PathBuf::from)
196        .or_else(|| env.home_dir())
197}
198
199pub(crate) fn ccs_dir_with_env(env: &dyn CcsEnvironment) -> Option<std::path::PathBuf> {
200    ccs_home_dir_with_env(env).map(|home| home.join(".ccs"))
201}
202
203pub(crate) fn ccs_config_json_path_with_env(
204    env: &dyn CcsEnvironment,
205) -> Option<std::path::PathBuf> {
206    env.get_var("CCS_CONFIG")
207        .map(std::path::PathBuf::from)
208        .or_else(|| ccs_dir_with_env(env).map(|d| d.join("config.json")))
209}
210
211pub(crate) fn ccs_config_yaml_path_with_env(
212    env: &dyn CcsEnvironment,
213) -> Option<std::path::PathBuf> {
214    ccs_dir_with_env(env).map(|d| d.join("config.yaml"))
215}
216
217pub(crate) fn load_ccs_profiles_from_config_json_with_deps(
218    env: &dyn CcsEnvironment,
219    fs: &dyn CcsFilesystem,
220) -> Result<std::collections::HashMap<String, String>, CcsEnvVarsError> {
221    let Some(path) = ccs_config_json_path_with_env(env) else {
222        return Err(CcsEnvVarsError::MissingHomeDir);
223    };
224    if !fs.exists(&path) {
225        return Ok(std::collections::HashMap::new());
226    }
227    let content = fs
228        .read_to_string(&path)
229        .map_err(|source| CcsEnvVarsError::ReadConfig {
230            path: path.clone(),
231            source,
232        })?;
233    let parsed: CcsConfigJson =
234        serde_json::from_str(&content).map_err(|source| CcsEnvVarsError::ParseConfigJson {
235            path: path.clone(),
236            source,
237        })?;
238    Ok(parsed.profiles)
239}