agent_engine/runtime/trace/
key.rs1use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(try_from = "String", into = "String")]
18pub struct ComponentDigest(String);
19
20impl ComponentDigest {
21 pub fn as_str(&self) -> &str {
22 &self.0
23 }
24}
25
26impl TryFrom<String> for ComponentDigest {
27 type Error = String;
28 fn try_from(value: String) -> Result<Self, Self::Error> {
29 if value.len() == 64
30 && value
31 .bytes()
32 .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
33 {
34 Ok(ComponentDigest(value))
35 } else {
36 Err("component digest must be exactly 64 lowercase hex chars".to_string())
37 }
38 }
39}
40
41impl From<ComponentDigest> for String {
42 fn from(value: ComponentDigest) -> Self {
43 value.0
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum DigestDomain {
53 Wire,
54 SystemSegment,
55 MessageBlock,
56 ToolSchema,
57 ToolsPrefix,
58 SystemPrefix,
59 HistoryTail,
60}
61
62impl DigestDomain {
63 fn label(self) -> &'static [u8] {
64 match self {
65 DigestDomain::Wire => b"synaps-trace:wire\0",
66 DigestDomain::SystemSegment => b"synaps-trace:system-segment\0",
67 DigestDomain::MessageBlock => b"synaps-trace:message-block\0",
68 DigestDomain::ToolSchema => b"synaps-trace:tool-schema\0",
69 DigestDomain::ToolsPrefix => b"synaps-trace:tools-prefix\0",
70 DigestDomain::SystemPrefix => b"synaps-trace:system-prefix\0",
71 DigestDomain::HistoryTail => b"synaps-trace:history-tail\0",
72 }
73 }
74}
75
76pub struct TraceDigestKey(zeroize::Zeroizing<[u8; 32]>);
81
82impl std::fmt::Debug for TraceDigestKey {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.write_str("TraceDigestKey(..)")
85 }
86}
87
88#[derive(Debug)]
90pub enum TraceKeyError {
91 SymlinkRefused(PathBuf),
94 NotRegularFile(PathBuf),
97 Corrupt(PathBuf),
99 Io(std::io::Error),
100}
101
102impl std::fmt::Display for TraceKeyError {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 match self {
105 TraceKeyError::SymlinkRefused(p) => {
106 write!(f, "refusing symlink at trace key path {}", p.display())
107 }
108 TraceKeyError::NotRegularFile(p) => {
109 write!(f, "trace key path {} is not a regular file", p.display())
110 }
111 TraceKeyError::Corrupt(p) => {
112 write!(f, "trace digest key at {} is corrupt", p.display())
113 }
114 TraceKeyError::Io(e) => write!(f, "trace digest key io error: {e}"),
115 }
116 }
117}
118
119impl std::error::Error for TraceKeyError {}
120
121impl From<std::io::Error> for TraceKeyError {
122 fn from(e: std::io::Error) -> Self {
123 TraceKeyError::Io(e)
124 }
125}
126
127pub fn default_digest_key_path() -> PathBuf {
129 agent_core::core::config::base_dir()
130 .join("trace")
131 .join("digest.key")
132}
133
134pub fn load_or_create_digest_key() -> Result<TraceDigestKey, TraceKeyError> {
136 load_or_create_digest_key_at(&default_digest_key_path())
137}
138
139pub fn load_or_create_digest_key_at(path: &Path) -> Result<TraceDigestKey, TraceKeyError> {
156 use std::io::Read as _;
157 use std::io::Write as _;
158
159 let parent = path.parent().ok_or_else(|| {
160 TraceKeyError::Io(std::io::Error::new(
161 std::io::ErrorKind::InvalidInput,
162 "trace key path has no parent directory",
163 ))
164 })?;
165 agent_core::core::private_fs::ensure_private_dir(parent).map_err(std::io::Error::from)?;
166
167 for _ in 0..8 {
170 match std::fs::symlink_metadata(path) {
171 Ok(meta) if meta.file_type().is_symlink() => {
172 return Err(TraceKeyError::SymlinkRefused(path.to_path_buf()));
173 }
174 Ok(meta) if !meta.file_type().is_file() => {
178 return Err(TraceKeyError::NotRegularFile(path.to_path_buf()));
179 }
180 Ok(_) => {
181 let file = open_nofollow_read(path)?;
182 let meta = file.metadata()?;
186 if !meta.file_type().is_file() {
187 return Err(TraceKeyError::NotRegularFile(path.to_path_buf()));
188 }
189 #[cfg(unix)]
192 {
193 use std::os::unix::fs::PermissionsExt as _;
194 let mode = meta.permissions().mode() & 0o777;
195 if mode != agent_core::core::private_fs::FILE_MODE {
196 file.set_permissions(std::fs::Permissions::from_mode(
197 agent_core::core::private_fs::FILE_MODE,
198 ))?;
199 }
200 }
201 let mut buf = zeroize::Zeroizing::new(Vec::with_capacity(33));
205 file.take(33).read_to_end(&mut buf)?;
206 if buf.len() != 32 {
207 return Err(TraceKeyError::Corrupt(path.to_path_buf()));
208 }
209 let mut key = zeroize::Zeroizing::new([0u8; 32]);
210 key.copy_from_slice(&buf);
211 return Ok(TraceDigestKey(key));
212 }
213 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
214 Err(e) => return Err(e.into()),
215 }
216
217 let mut key = zeroize::Zeroizing::new([0u8; 32]);
219 {
220 use rand::TryRngCore as _;
221 rand::rngs::OsRng
222 .try_fill_bytes(key.as_mut())
223 .map_err(|e| TraceKeyError::Io(std::io::Error::other(e)))?;
224 }
225
226 static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
232 let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
233 let tmp = path.with_file_name(format!("digest.key.tmp.{}.{seq}", std::process::id()));
234 let publish = (|| -> Result<bool, TraceKeyError> {
235 let mut file = open_create_new_private(&tmp)?;
236 file.write_all(key.as_ref())?;
237 file.sync_all()?;
238 drop(file);
239 match std::fs::hard_link(&tmp, path) {
240 Ok(()) => Ok(true),
241 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
243 Err(e) => Err(e.into()),
244 }
245 })();
246 let _ = std::fs::remove_file(&tmp);
247 match publish {
248 Ok(true) => return Ok(TraceDigestKey(key)),
249 Ok(false) => continue,
250 Err(e) => return Err(e),
251 }
252 }
253 Err(TraceKeyError::Io(std::io::Error::new(
254 std::io::ErrorKind::TimedOut,
255 "trace digest key creation did not converge",
256 )))
257}
258
259fn open_nofollow_read(path: &Path) -> Result<std::fs::File, TraceKeyError> {
265 #[cfg(unix)]
266 {
267 use std::os::unix::fs::OpenOptionsExt as _;
268 std::fs::OpenOptions::new()
269 .read(true)
270 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
271 .open(path)
272 .map_err(|e| {
273 if e.raw_os_error() == Some(libc::ELOOP) {
274 TraceKeyError::SymlinkRefused(path.to_path_buf())
275 } else {
276 TraceKeyError::Io(e)
277 }
278 })
279 }
280 #[cfg(not(unix))]
281 {
282 Ok(std::fs::OpenOptions::new().read(true).open(path)?)
283 }
284}
285
286fn open_create_new_private(path: &Path) -> Result<std::fs::File, TraceKeyError> {
288 #[cfg(unix)]
289 {
290 use std::os::unix::fs::OpenOptionsExt as _;
291 Ok(std::fs::OpenOptions::new()
292 .write(true)
293 .create_new(true)
294 .mode(agent_core::core::private_fs::FILE_MODE)
295 .custom_flags(libc::O_NOFOLLOW)
296 .open(path)?)
297 }
298 #[cfg(not(unix))]
299 {
300 Ok(std::fs::OpenOptions::new()
301 .write(true)
302 .create_new(true)
303 .open(path)?)
304 }
305}
306
307pub fn keyed_digest(key: &TraceDigestKey, domain: DigestDomain, bytes: &[u8]) -> ComponentDigest {
310 use hmac::Mac as _;
311 let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(key.0.as_ref())
312 .expect("HMAC-SHA256 accepts any key length");
313 mac.update(domain.label());
314 mac.update(bytes);
315 let out = mac.finalize().into_bytes();
316 let mut hex = String::with_capacity(64);
317 for b in out {
318 use std::fmt::Write as _;
319 let _ = write!(hex, "{b:02x}");
320 }
321 ComponentDigest(hex)
322}