use std::path::{Component, Path, PathBuf};
use greentic_deploy_spec::PackDescriptor;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RulesPackEntry {
pub filename: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RulesPack {
pub entries: Vec<RulesPackEntry>,
}
impl RulesPack {
pub fn empty() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, Error)]
pub enum RulesExportError {
#[error("rules entry filename `{0}` is empty")]
EmptyFilename(String),
#[error("rules entry filename `{0}` escapes the per-pack subdir")]
UnsafeFilename(String),
#[error("symlink detected at `{path}` under env root — refusing to write through it")]
SymlinkAncestor { path: PathBuf },
#[error("io error on {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("serializing rules index failed: {0}")]
Serialize(#[from] serde_json::Error),
}
pub fn write_rules_pack(
env_root: &Path,
deployer: &PackDescriptor,
pack: &RulesPack,
) -> Result<PathBuf, RulesExportError> {
let pack_subdir = PathBuf::from("rules").join(deployer.path());
let pack_dir = env_root.join(&pack_subdir);
crate::path_safety::assert_no_symlink_ancestors(env_root, &pack_dir)
.map_err(map_path_safety)?;
create_dir_all(&pack_dir)?;
for entry in &pack.entries {
validate_filename(&entry.filename)?;
let target = pack_dir.join(&entry.filename);
atomic_write(&target, entry.content.as_bytes())?;
}
let index_path = pack_dir.join("index.json");
let index_json = serde_json::to_vec_pretty(&IndexFile::from(pack))?;
atomic_write(&index_path, &index_json)?;
Ok(pack_subdir)
}
fn validate_filename(name: &str) -> Result<(), RulesExportError> {
if name.is_empty() {
return Err(RulesExportError::EmptyFilename(name.to_string()));
}
let path = Path::new(name);
if path.is_absolute() {
return Err(RulesExportError::UnsafeFilename(name.to_string()));
}
for component in path.components() {
match component {
Component::Normal(_) => {}
Component::CurDir => {}
_ => return Err(RulesExportError::UnsafeFilename(name.to_string())),
}
}
Ok(())
}
fn create_dir_all(path: &Path) -> Result<(), RulesExportError> {
std::fs::create_dir_all(path).map_err(|source| RulesExportError::Io {
path: path.to_path_buf(),
source,
})
}
fn map_path_safety(e: crate::path_safety::PathSafetyError) -> RulesExportError {
use crate::path_safety::PathSafetyError;
match e {
PathSafetyError::SymlinkAncestor { path } => RulesExportError::SymlinkAncestor { path },
PathSafetyError::Io { path, source } => RulesExportError::Io { path, source },
}
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), RulesExportError> {
use crate::environment::atomic_write::{AtomicWriteError, atomic_write_bytes};
atomic_write_bytes(path, bytes).map_err(|e| match e {
AtomicWriteError::Io { path, source } => RulesExportError::Io { path, source },
AtomicWriteError::NoParent(path) => RulesExportError::Io {
path,
source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "no parent dir"),
},
AtomicWriteError::Persist { target, source } => RulesExportError::Io {
path: target,
source: source.error,
},
other => RulesExportError::Io {
path: path.to_path_buf(),
source: std::io::Error::other(other.to_string()),
},
})
}
#[derive(Serialize)]
struct IndexFile {
schema: &'static str,
entries: Vec<IndexEntry>,
}
#[derive(Serialize)]
struct IndexEntry {
filename: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
bytes: usize,
}
impl From<&RulesPack> for IndexFile {
fn from(pack: &RulesPack) -> Self {
Self {
schema: "greentic.rules-pack.index.v1",
entries: pack
.entries
.iter()
.map(|e| IndexEntry {
filename: e.filename.clone(),
description: e.description.clone(),
bytes: e.content.len(),
})
.collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn descriptor(raw: &str) -> PackDescriptor {
PackDescriptor::try_new(raw).expect("descriptor parses")
}
#[test]
fn writes_entries_and_index_under_deployer_path() {
let dir = tempdir().unwrap();
let pack = RulesPack {
entries: vec![
RulesPackEntry {
filename: "iam-policy.json".into(),
content: "{\"Version\":\"2012-10-17\"}".into(),
description: Some("min IAM policy".into()),
},
RulesPackEntry {
filename: "trust.tf".into(),
content: "resource \"aws_iam_role\" \"x\" {}".into(),
description: None,
},
],
};
let rel = write_rules_pack(
dir.path(),
&descriptor("greentic.deployer.aws-ecs@1.0.0"),
&pack,
)
.unwrap();
assert_eq!(
rel,
PathBuf::from("rules").join("greentic.deployer.aws-ecs")
);
let pack_dir = dir.path().join(&rel);
assert!(pack_dir.join("iam-policy.json").exists());
assert!(pack_dir.join("trust.tf").exists());
let index: serde_json::Value =
serde_json::from_slice(&std::fs::read(pack_dir.join("index.json")).unwrap()).unwrap();
assert_eq!(index["schema"], "greentic.rules-pack.index.v1");
assert_eq!(index["entries"].as_array().unwrap().len(), 2);
}
#[test]
fn empty_pack_writes_only_index() {
let dir = tempdir().unwrap();
let rel = write_rules_pack(
dir.path(),
&descriptor("greentic.deployer.local-process@0.1.0"),
&RulesPack::empty(),
)
.unwrap();
let pack_dir = dir.path().join(rel);
assert!(pack_dir.join("index.json").exists());
let entries: Vec<_> = std::fs::read_dir(&pack_dir).unwrap().collect();
assert_eq!(
entries.len(),
1,
"only index.json should be written for an empty pack"
);
}
#[test]
fn rejects_dot_dot_filename() {
let dir = tempdir().unwrap();
let pack = RulesPack {
entries: vec![RulesPackEntry {
filename: "../escape.tf".into(),
content: "x".into(),
description: None,
}],
};
let err = write_rules_pack(
dir.path(),
&descriptor("greentic.deployer.aws-ecs@1.0.0"),
&pack,
)
.unwrap_err();
assert!(matches!(err, RulesExportError::UnsafeFilename(_)));
}
#[test]
fn rejects_absolute_filename() {
let dir = tempdir().unwrap();
let pack = RulesPack {
entries: vec![RulesPackEntry {
filename: "/etc/passwd".into(),
content: "x".into(),
description: None,
}],
};
let err = write_rules_pack(
dir.path(),
&descriptor("greentic.deployer.aws-ecs@1.0.0"),
&pack,
)
.unwrap_err();
assert!(matches!(err, RulesExportError::UnsafeFilename(_)));
}
#[test]
fn rejects_empty_filename() {
let dir = tempdir().unwrap();
let pack = RulesPack {
entries: vec![RulesPackEntry {
filename: "".into(),
content: "x".into(),
description: None,
}],
};
let err = write_rules_pack(
dir.path(),
&descriptor("greentic.deployer.aws-ecs@1.0.0"),
&pack,
)
.unwrap_err();
assert!(matches!(err, RulesExportError::EmptyFilename(_)));
}
#[cfg(unix)]
#[test]
fn rejects_symlink_rules_dir() {
let env_root = tempdir().unwrap();
let escape_target = tempdir().unwrap();
let rules_link = env_root.path().join("rules");
std::os::unix::fs::symlink(escape_target.path(), &rules_link).unwrap();
let pack = RulesPack {
entries: vec![RulesPackEntry {
filename: "policy.json".into(),
content: "{}".into(),
description: None,
}],
};
let err = write_rules_pack(
env_root.path(),
&descriptor("greentic.deployer.aws-ecs@1.0.0"),
&pack,
)
.unwrap_err();
assert!(
matches!(err, RulesExportError::SymlinkAncestor { .. }),
"got {err:?}"
);
assert!(
std::fs::read_dir(escape_target.path())
.unwrap()
.next()
.is_none(),
"escape target should be empty"
);
}
}