greentic_deployer/credentials/
rules_export.rs1use std::path::{Component, Path, PathBuf};
22
23use greentic_deploy_spec::PackDescriptor;
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct RulesPackEntry {
29 pub filename: String,
33 pub content: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub description: Option<String>,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42pub struct RulesPack {
43 pub entries: Vec<RulesPackEntry>,
44}
45
46impl RulesPack {
47 pub fn empty() -> Self {
48 Self::default()
49 }
50
51 pub fn is_empty(&self) -> bool {
52 self.entries.is_empty()
53 }
54}
55
56#[derive(Debug, Error)]
57pub enum RulesExportError {
58 #[error("rules entry filename `{0}` is empty")]
59 EmptyFilename(String),
60 #[error("rules entry filename `{0}` escapes the per-pack subdir")]
61 UnsafeFilename(String),
62 #[error("symlink detected at `{path}` under env root — refusing to write through it")]
63 SymlinkAncestor { path: PathBuf },
64 #[error("io error on {path}: {source}")]
65 Io {
66 path: PathBuf,
67 #[source]
68 source: std::io::Error,
69 },
70 #[error("serializing rules index failed: {0}")]
71 Serialize(#[from] serde_json::Error),
72}
73
74pub fn write_rules_pack(
84 env_root: &Path,
85 deployer: &PackDescriptor,
86 pack: &RulesPack,
87) -> Result<PathBuf, RulesExportError> {
88 let pack_subdir = PathBuf::from("rules").join(deployer.path());
89 let pack_dir = env_root.join(&pack_subdir);
90
91 crate::path_safety::assert_no_symlink_ancestors(env_root, &pack_dir)
98 .map_err(map_path_safety)?;
99
100 create_dir_all(&pack_dir)?;
101
102 for entry in &pack.entries {
103 validate_filename(&entry.filename)?;
104 let target = pack_dir.join(&entry.filename);
105 atomic_write(&target, entry.content.as_bytes())?;
106 }
107
108 let index_path = pack_dir.join("index.json");
109 let index_json = serde_json::to_vec_pretty(&IndexFile::from(pack))?;
110 atomic_write(&index_path, &index_json)?;
111
112 Ok(pack_subdir)
113}
114
115fn validate_filename(name: &str) -> Result<(), RulesExportError> {
116 if name.is_empty() {
117 return Err(RulesExportError::EmptyFilename(name.to_string()));
118 }
119 let path = Path::new(name);
120 if path.is_absolute() {
121 return Err(RulesExportError::UnsafeFilename(name.to_string()));
122 }
123 for component in path.components() {
124 match component {
125 Component::Normal(_) => {}
126 Component::CurDir => {}
129 _ => return Err(RulesExportError::UnsafeFilename(name.to_string())),
130 }
131 }
132 Ok(())
133}
134
135fn create_dir_all(path: &Path) -> Result<(), RulesExportError> {
136 std::fs::create_dir_all(path).map_err(|source| RulesExportError::Io {
137 path: path.to_path_buf(),
138 source,
139 })
140}
141
142fn map_path_safety(e: crate::path_safety::PathSafetyError) -> RulesExportError {
148 use crate::path_safety::PathSafetyError;
149 match e {
150 PathSafetyError::SymlinkAncestor { path } => RulesExportError::SymlinkAncestor { path },
151 PathSafetyError::Io { path, source } => RulesExportError::Io { path, source },
152 }
153}
154
155fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), RulesExportError> {
161 use crate::environment::atomic_write::{AtomicWriteError, atomic_write_bytes};
162 atomic_write_bytes(path, bytes).map_err(|e| match e {
163 AtomicWriteError::Io { path, source } => RulesExportError::Io { path, source },
164 AtomicWriteError::NoParent(path) => RulesExportError::Io {
165 path,
166 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "no parent dir"),
167 },
168 AtomicWriteError::Persist { target, source } => RulesExportError::Io {
169 path: target,
170 source: source.error,
171 },
172 other => RulesExportError::Io {
173 path: path.to_path_buf(),
174 source: std::io::Error::other(other.to_string()),
175 },
176 })
177}
178
179#[derive(Serialize)]
180struct IndexFile {
181 schema: &'static str,
182 entries: Vec<IndexEntry>,
183}
184
185#[derive(Serialize)]
186struct IndexEntry {
187 filename: String,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 description: Option<String>,
190 bytes: usize,
191}
192
193impl From<&RulesPack> for IndexFile {
194 fn from(pack: &RulesPack) -> Self {
195 Self {
196 schema: "greentic.rules-pack.index.v1",
197 entries: pack
198 .entries
199 .iter()
200 .map(|e| IndexEntry {
201 filename: e.filename.clone(),
202 description: e.description.clone(),
203 bytes: e.content.len(),
204 })
205 .collect(),
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use tempfile::tempdir;
214
215 fn descriptor(raw: &str) -> PackDescriptor {
216 PackDescriptor::try_new(raw).expect("descriptor parses")
217 }
218
219 #[test]
220 fn writes_entries_and_index_under_deployer_path() {
221 let dir = tempdir().unwrap();
222 let pack = RulesPack {
223 entries: vec![
224 RulesPackEntry {
225 filename: "iam-policy.json".into(),
226 content: "{\"Version\":\"2012-10-17\"}".into(),
227 description: Some("min IAM policy".into()),
228 },
229 RulesPackEntry {
230 filename: "trust.tf".into(),
231 content: "resource \"aws_iam_role\" \"x\" {}".into(),
232 description: None,
233 },
234 ],
235 };
236 let rel = write_rules_pack(
237 dir.path(),
238 &descriptor("greentic.deployer.aws-ecs@1.0.0"),
239 &pack,
240 )
241 .unwrap();
242 assert_eq!(
243 rel,
244 PathBuf::from("rules").join("greentic.deployer.aws-ecs")
245 );
246
247 let pack_dir = dir.path().join(&rel);
248 assert!(pack_dir.join("iam-policy.json").exists());
249 assert!(pack_dir.join("trust.tf").exists());
250 let index: serde_json::Value =
251 serde_json::from_slice(&std::fs::read(pack_dir.join("index.json")).unwrap()).unwrap();
252 assert_eq!(index["schema"], "greentic.rules-pack.index.v1");
253 assert_eq!(index["entries"].as_array().unwrap().len(), 2);
254 }
255
256 #[test]
257 fn empty_pack_writes_only_index() {
258 let dir = tempdir().unwrap();
259 let rel = write_rules_pack(
260 dir.path(),
261 &descriptor("greentic.deployer.local-process@0.1.0"),
262 &RulesPack::empty(),
263 )
264 .unwrap();
265 let pack_dir = dir.path().join(rel);
266 assert!(pack_dir.join("index.json").exists());
267 let entries: Vec<_> = std::fs::read_dir(&pack_dir).unwrap().collect();
268 assert_eq!(
269 entries.len(),
270 1,
271 "only index.json should be written for an empty pack"
272 );
273 }
274
275 #[test]
276 fn rejects_dot_dot_filename() {
277 let dir = tempdir().unwrap();
278 let pack = RulesPack {
279 entries: vec![RulesPackEntry {
280 filename: "../escape.tf".into(),
281 content: "x".into(),
282 description: None,
283 }],
284 };
285 let err = write_rules_pack(
286 dir.path(),
287 &descriptor("greentic.deployer.aws-ecs@1.0.0"),
288 &pack,
289 )
290 .unwrap_err();
291 assert!(matches!(err, RulesExportError::UnsafeFilename(_)));
292 }
293
294 #[test]
295 fn rejects_absolute_filename() {
296 let dir = tempdir().unwrap();
297 let pack = RulesPack {
298 entries: vec![RulesPackEntry {
299 filename: "/etc/passwd".into(),
300 content: "x".into(),
301 description: None,
302 }],
303 };
304 let err = write_rules_pack(
305 dir.path(),
306 &descriptor("greentic.deployer.aws-ecs@1.0.0"),
307 &pack,
308 )
309 .unwrap_err();
310 assert!(matches!(err, RulesExportError::UnsafeFilename(_)));
311 }
312
313 #[test]
314 fn rejects_empty_filename() {
315 let dir = tempdir().unwrap();
316 let pack = RulesPack {
317 entries: vec![RulesPackEntry {
318 filename: "".into(),
319 content: "x".into(),
320 description: None,
321 }],
322 };
323 let err = write_rules_pack(
324 dir.path(),
325 &descriptor("greentic.deployer.aws-ecs@1.0.0"),
326 &pack,
327 )
328 .unwrap_err();
329 assert!(matches!(err, RulesExportError::EmptyFilename(_)));
330 }
331
332 #[cfg(unix)]
336 #[test]
337 fn rejects_symlink_rules_dir() {
338 let env_root = tempdir().unwrap();
339 let escape_target = tempdir().unwrap();
340 let rules_link = env_root.path().join("rules");
341 std::os::unix::fs::symlink(escape_target.path(), &rules_link).unwrap();
342
343 let pack = RulesPack {
344 entries: vec![RulesPackEntry {
345 filename: "policy.json".into(),
346 content: "{}".into(),
347 description: None,
348 }],
349 };
350 let err = write_rules_pack(
351 env_root.path(),
352 &descriptor("greentic.deployer.aws-ecs@1.0.0"),
353 &pack,
354 )
355 .unwrap_err();
356 assert!(
357 matches!(err, RulesExportError::SymlinkAncestor { .. }),
358 "got {err:?}"
359 );
360 assert!(
362 std::fs::read_dir(escape_target.path())
363 .unwrap()
364 .next()
365 .is_none(),
366 "escape target should be empty"
367 );
368 }
369}