use super::suggest::PolicySuggestion;
use assay_common::atomic_write::write_new;
use std::path::{Path, PathBuf};
pub fn write_yaml(s: &PolicySuggestion) -> anyhow::Result<String> {
require_empty_extends(s)?;
let mut out = String::new();
out.push_str("# Generated by `assay sandbox --profile`\n");
out.push_str(&format!("api_version: {}\n", s.api_version));
out.push_str("extends: []\n");
out.push_str("fs:\n");
out.push_str(" allow:\n");
for p in &s.fs.allow {
out.push_str(&format!(" - \"{}\"\n", escape(p)));
}
if s.fs.deny.is_empty() {
out.push_str(" deny: []\n");
} else {
out.push_str(" deny:\n");
for p in &s.fs.deny {
out.push_str(&format!(" - \"{}\"\n", escape(p)));
}
}
out.push_str("net:\n");
if s.net.allow.is_empty() {
out.push_str(" allow: []\n");
} else {
out.push_str(" allow:\n");
for p in &s.net.allow {
out.push_str(&format!(" - \"{}\"\n", escape(p)));
}
}
out.push_str(" deny:\n");
for p in &s.net.deny {
out.push_str(&format!(" - \"{}\"\n", escape(p)));
}
out.push_str("env:\n");
out.push_str(" allow:\n");
for k in &s.env.allow {
out.push_str(&format!(" - \"{}\"\n", escape(k)));
}
out.push_str("processes:\n");
out.push_str(" allow:\n");
for x in &s.processes.allow {
out.push_str(&format!(" - \"{}\"\n", escape(x)));
}
out.push_str("meta:\n");
out.push_str(" counters:\n");
for (k, v) in &s.meta.counters {
out.push_str(&format!(" \"{}\": {}\n", escape(k), v));
}
if !s.meta.notes.is_empty() {
out.push_str(" notes:\n");
for n in &s.meta.notes {
out.push_str(&format!(" - \"{}\"\n", escape(n)));
}
}
Ok(out)
}
fn escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub fn write_json(s: &PolicySuggestion) -> anyhow::Result<String> {
require_empty_extends(s)?;
Ok(serde_json::to_string_pretty(s)?)
}
fn require_empty_extends(s: &PolicySuggestion) -> anyhow::Result<()> {
if !s.extends.is_empty() {
anyhow::bail!("generated sandbox policies do not support non-empty extends");
}
Ok(())
}
pub fn save_atomic(path: &Path, content: &str) -> anyhow::Result<()> {
if path.exists() {
let meta = std::fs::symlink_metadata(path)?;
if meta.file_type().is_symlink() {
anyhow::bail!("Refusing to write to symlink: {}", path.display());
}
if meta.is_file() {
std::fs::remove_file(path)?;
}
}
let parent = usable_parent(path);
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow::anyhow!("{} must end with a UTF-8 file name", path.display()))?;
write_new(&parent, file_name, content.as_bytes())
.map(|_| ())
.map_err(|error| {
anyhow::anyhow!("failed to atomically write {}: {error}", path.display())
})?;
Ok(())
}
fn usable_parent(path: &Path) -> PathBuf {
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
}
}
#[cfg(test)]
mod tests {
use super::{save_atomic, write_json, write_yaml};
use crate::profile::suggest::PolicySuggestion;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[test]
fn generated_formats_refuse_nonempty_extends() {
let mut suggestion = PolicySuggestion::default();
suggestion.extends.push("builtin-default".to_string());
let yaml_error = write_yaml(&suggestion).expect_err("YAML must reject composition");
let json_error = write_json(&suggestion).expect_err("JSON must reject composition");
assert_eq!(
yaml_error.to_string(),
"generated sandbox policies do not support non-empty extends"
);
assert_eq!(
json_error.to_string(),
"generated sandbox policies do not support non-empty extends"
);
}
#[test]
fn save_atomic_overwrites_existing_regular_file() {
let dir = tempfile::tempdir().expect("tempdir");
let output = dir.path().join("profile.yaml");
fs::write(&output, "old").expect("seed old");
save_atomic(&output, "new content\n").expect("overwrite succeeds");
assert_eq!(
fs::read_to_string(&output).expect("read output"),
"new content\n"
);
}
#[cfg(unix)]
#[test]
fn save_atomic_refuses_symlink_targets() {
let dir = tempfile::tempdir().expect("tempdir");
let outside = tempfile::tempdir().expect("outside");
let outside_file = outside.path().join("outside.txt");
fs::write(&outside_file, "outside").expect("seed outside");
let output = dir.path().join("profile.yaml");
symlink(&outside_file, &output).expect("create symlink");
let error = save_atomic(&output, "new content\n").expect_err("symlink must be rejected");
assert!(
error.to_string().contains("Refusing to write to symlink"),
"unexpected error: {error:#}"
);
assert_eq!(
fs::read_to_string(&outside_file).expect("read outside"),
"outside"
);
}
}