ralph_workflow/agents/ccs_env/
traits.rs1pub trait CcsEnvironment {
9 fn get_var(&self, name: &str) -> Option<String>;
10
11 fn home_dir(&self) -> Option<PathBuf>;
12}
13
14pub 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
25pub struct CcsDirEntry {
27 pub path: std::path::PathBuf,
28 pub file_name: String,
29 pub is_file: bool,
30}
31
32#[derive(Debug, serde::Deserialize)]
36pub(crate) struct CcsConfigJson {
37 pub(crate) profiles: std::collections::HashMap<String, String>,
38}
39
40#[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
105const 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
118pub(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 #[cfg(windows)]
134 {
135 if name.starts_with('=') {
136 return false;
137 }
138 }
139 true
140}
141
142pub(crate) fn is_safe_env_var_value(value: &str) -> bool {
144 if value.contains('\0') || value.contains('\n') || value.contains('\r') {
146 return false;
147 }
148 if value.contains('`') {
150 return false;
151 }
152 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}