1use anyhow::{Context, Result, anyhow};
2use assay_common::exports::{EventRecordExport, ProcessTreeExport};
3use serde::Serialize;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[cfg(unix)]
8use std::os::unix::fs::PermissionsExt;
9#[cfg(unix)]
10use std::os::unix::io::{AsRawFd, FromRawFd};
11#[cfg(unix)]
12use nix::fcntl::{open, openat, OFlag, renameat};
13#[cfg(unix)]
14use nix::sys::stat::{Mode, fchmod};
15
16use uuid::Uuid;
17
18#[cfg(not(unix))]
19trait PermissionsExt {} #[derive(Debug, Serialize)]
22pub struct IncidentBundle {
23 pub metadata: IncidentMetadata,
24 pub tree: ProcessTreeExport,
25 pub events: Vec<EventRecordExport>,
26}
27
28#[derive(Debug, Serialize)]
29pub struct IncidentMetadata {
30 pub timestamp: String,
31 pub session_id: String,
32 pub kernel_version: String,
33 pub assay_version: String,
34}
35
36pub struct IncidentBuilder {
37 bundle: IncidentBundle,
38}
39
40impl IncidentBuilder {
41 pub fn new(session_id: String) -> Self {
42 let now = chrono::Utc::now().to_rfc3339();
43
44 let kernel_version = std::fs::read_to_string("/proc/version")
46 .unwrap_or_else(|_| "unknown".to_string())
47 .trim()
48 .to_string();
49
50 Self {
51 bundle: IncidentBundle {
52 metadata: IncidentMetadata {
53 timestamp: now,
54 session_id,
55 kernel_version,
56 assay_version: env!("CARGO_PKG_VERSION").to_string(),
57 },
58 tree: ProcessTreeExport::default(),
59 events: Vec::new(),
60 }
61 }
62 }
63
64 pub fn with_tree(mut self, tree: ProcessTreeExport) -> Self {
65 self.bundle.tree = tree;
66 self
67 }
68
69 pub fn with_events(mut self, events: Vec<EventRecordExport>) -> Self {
70 self.bundle.events = events;
71 self
72 }
73
74 #[cfg(unix)]
81 pub fn atomic_write(&self, output_dir: &Path) -> Result<PathBuf> {
82 let dir_path_str = output_dir.to_str().ok_or_else(|| anyhow!("Invalid path"))?;
83
84 if !output_dir.exists() {
86 fs::create_dir_all(output_dir).context("Failed to create output dir")?;
87 let mut perms = fs::metadata(output_dir)?.permissions();
88 perms.set_mode(0o700);
89 fs::set_permissions(output_dir, perms).context("Failed to secure new output dir")?;
90 }
91
92 let dir_raw_fd = open(
94 dir_path_str,
95 OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW,
96 Mode::empty()
97 ).context("Failed to open output directory securely")?;
98
99 let dir_file = unsafe { std::fs::File::from_raw_fd(dir_raw_fd) };
101
102 let dir_meta = dir_file.metadata()?;
104 let current_mode = dir_meta.permissions().mode();
105 if (current_mode & 0o777) != 0o700 {
106 fchmod(dir_file.as_raw_fd(), Mode::from_bits_truncate(0o700))
108 .context("Failed to fchmod output directory")?;
109 }
110
111 let suffix = Uuid::new_v4().simple().to_string();
113 let filename = format!("incident_{}_{}.json", self.bundle.metadata.session_id, suffix);
114 let tmp_filename = format!(".tmp_{}", filename);
115
116 let content = serde_json::to_string_pretty(&self.bundle)
117 .context("Failed to serialize incident bundle")?;
118
119 let tmp_fd = openat(
121 dir_file.as_raw_fd(),
122 tmp_filename.as_str(),
123 OFlag::O_CREAT | OFlag::O_WRONLY | OFlag::O_EXCL | OFlag::O_NOFOLLOW,
124 Mode::from_bits_truncate(0o600)
125 ).context("Failed to create temp file securely")?;
126
127 let mut tmp_file = unsafe { std::fs::File::from_raw_fd(tmp_fd) };
128
129 use std::io::Write;
131 tmp_file.write_all(content.as_bytes())?;
132 tmp_file.sync_all()?;
133
134 renameat(
136 Some(dir_file.as_raw_fd()),
137 tmp_filename.as_str(),
138 Some(dir_file.as_raw_fd()),
139 filename.as_str()
140 ).context("Failed to rename atomic file")?;
141
142 dir_file.sync_all()?;
144
145 Ok(output_dir.join(filename))
146 }
147
148 #[cfg(not(unix))]
149 pub fn atomic_write(&self, _output_dir: &Path) -> Result<PathBuf> {
150 Err(anyhow!("Incident bundles only supported on Unix"))
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use std::os::unix::fs::PermissionsExt;
158
159 #[test]
160 fn test_atomic_write_security() -> Result<()> {
161 let temp_dir = tempfile::tempdir()?;
162 let builder = IncidentBuilder::new("test-session".to_string());
163
164 let path = builder.atomic_write(temp_dir.path())?;
166
167 assert!(path.exists());
169 assert!(path.file_name().unwrap().to_str().unwrap().contains("test-session"));
170
171 let perms = fs::metadata(&path)?.permissions();
173 let mode = perms.mode() & 0o777;
174 assert_eq!(mode, 0o600, "Incident bundle permissions must be 0600");
175
176 let content = fs::read_to_string(&path)?;
178 let json: serde_json::Value = serde_json::from_str(&content)?;
179 assert_eq!(json["metadata"]["session_id"], "test-session");
180
181 Ok(())
182 }
183}