Skip to main content

greentic_deployer/credentials/
rules_export.rs

1//! Rules-pack writer for the bootstrap flow.
2//!
3//! When a deployer's [`super::DeployerCredentials::bootstrap`] succeeds,
4//! it returns a [`RulesPack`] — a bag of IaC files (Terraform / OpenTofu
5//! HCL, kubectl YAML, Helm values, Pulumi / Bicep, anything the deployer
6//! wants) that the customer's admin can review and apply offline so the
7//! same minimum-privilege roles/policies/SAs/secrets-paths exist on
8//! whichever target environment they govern.
9//!
10//! The writer lays the entries down under `<env_root>/rules/` with a
11//! per-pack subdirectory keyed by the deployer's `PackDescriptor`. Each
12//! entry's `filename` is treated as path-relative and rejected if it
13//! escapes the per-pack subdir (no `..`, no absolute, no symlink
14//! components — same posture as the bundle extractors hardened in
15//! P0.4). An `index.json` summary lands alongside so a reviewer can see
16//! every rendered artifact at a glance.
17//!
18//! Files are written atomically (NamedTempFile → flush → fsync(parent)
19//! pattern, same shape as [`crate::environment::atomic_write_bytes`]).
20
21use 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    /// Relative filename under `<env_root>/rules/<deployer-path>/`.
30    /// Rejected if it contains `..`, is absolute, or otherwise tries to
31    /// escape the per-pack subdir.
32    pub filename: String,
33    /// File contents. Format is implicit in the filename's extension
34    /// (e.g. `.tf` → Terraform HCL).
35    pub content: String,
36    /// Optional one-line description for the `index.json` summary.
37    #[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
74/// Write `pack` to `<env_root>/rules/<deployer-path>/` and return the
75/// env-relative path to the directory (for `CredentialsBootstrap.rules_pack_ref`).
76///
77/// Empty packs return a path to an empty directory — kept structural so
78/// the bootstrap doc always has a `rules_pack_ref` regardless of whether
79/// the deployer emitted any IaC. An empty pack is honest about
80/// deployers (like local-process) that have nothing to apply offline;
81/// they should prefer [`super::BootstrapError::NotApplicable`] over an
82/// empty pack, but the writer accepts both.
83pub 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    // P0.4-equivalent posture: reject any existing symlink component under
92    // env_root before creating directories or writing files. Without this
93    // check, a pre-existing symlink at `env_root/rules/` or
94    // `env_root/rules/<deployer-path>` would cause `create_dir_all` to
95    // succeed (following the symlink) and writes would land outside the
96    // per-pack rules directory.
97    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            // `.` is the only no-op we allow; anything else (`..`,
127            // `RootDir`, `Prefix`) is rejected.
128            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
142// `assert_no_symlink_ancestors` lives in `crate::path_safety` — the shared
143// home for P0.4-style symlink-TOCTOU defenses so future extractors and
144// writers consult the same gate. Local mapping of its typed error into
145// `RulesExportError` is in `map_path_safety` below.
146
147fn 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
155/// Delegate to the canonical atomic-write helper in
156/// `crate::environment::atomic_write` (the same NamedTempFile → flush →
157/// sync_all → persist → fsync(parent) pipeline the store uses for env
158/// state). Map the typed error into [`RulesExportError`] so callers get
159/// the rules-pack error envelope.
160fn 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    /// Regression: if `<env_root>/rules` already exists as a symlink
333    /// pointing outside the env root, `write_rules_pack` must reject
334    /// instead of following the symlink and writing outside the env.
335    #[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        // Verify nothing was written to the escape target.
361        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}