1use std::fs::{self, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13#[cfg(not(windows))]
14use std::fs::File;
15
16use prost::Message;
17use sha2::{Digest, Sha256};
18
19use crate::broker::host_identity;
20use crate::broker::lifecycle::names::{validate_service_name, validate_version, PipePathError};
21use crate::broker::protocol::{CacheManifest, HostIdentity};
22use crate::broker::secure_dir;
23
24pub const ROOT_MANIFEST_FILE: &str = ".running-process-manifest.pb";
26
27pub const CACHE_MANIFEST_MEDIA_TYPE: &str = "application/vnd.running-process.cache-manifest.v1";
29
30pub const SUPPORTED_MANIFEST_SCHEMA_VERSION: u32 = 1;
32
33pub const RUNNING_PROCESS_MANIFEST_DIR_ENV: &str = "RUNNING_PROCESS_MANIFEST_DIR";
35
36#[derive(Debug, thiserror::Error)]
38pub enum ManifestError {
39 #[error("manifest I/O failed: {0}")]
41 Io(#[from] io::Error),
42 #[error("manifest protobuf decode failed: {0}")]
44 Decode(#[from] prost::DecodeError),
45 #[error("manifest protobuf encode failed: {0}")]
47 Encode(#[from] prost::EncodeError),
48 #[error("manifest self_sha256 mismatch")]
50 Corruption,
51 #[error("manifest schema too new: got {got}, supported {supported}")]
53 SchemaTooNew {
54 got: u32,
56 supported: u32,
58 },
59 #[error(transparent)]
61 InvalidName(#[from] PipePathError),
62 #[error("manifest path has no parent: {0}")]
64 MissingParent(PathBuf),
65 #[error("central manifest registry has insecure permissions: {0}")]
67 InsecureRegistry(PathBuf),
68}
69
70#[derive(Debug)]
72pub struct ManifestScanEntry {
73 pub path: PathBuf,
75 pub result: Result<CacheManifest, ManifestError>,
77}
78
79pub fn write_to_root(cache_root: &Path, manifest: &CacheManifest) -> Result<(), ManifestError> {
81 fs::create_dir_all(cache_root)?;
82 secure_dir::ensure_private_dir(cache_root)?;
83 let target = cache_root.join(ROOT_MANIFEST_FILE);
84 write_manifest_file(&target, manifest)
85}
86
87pub fn write_to_central(
89 service_name: &str,
90 version: &str,
91 manifest: &CacheManifest,
92) -> Result<PathBuf, ManifestError> {
93 let dir = central_registry_dir();
94 write_to_central_in_dir(&dir, service_name, version, manifest)
95}
96
97pub fn write_to_central_in_dir(
99 registry_dir: &Path,
100 service_name: &str,
101 version: &str,
102 manifest: &CacheManifest,
103) -> Result<PathBuf, ManifestError> {
104 ensure_central_registry_dir(registry_dir)?;
105 let target = central_manifest_path(registry_dir, service_name, version)?;
106 write_manifest_file(&target, manifest)?;
107 Ok(target)
108}
109
110pub fn read_manifest(path: &Path) -> Result<CacheManifest, ManifestError> {
112 let bytes = fs::read(path)?;
113 let manifest = CacheManifest::decode(bytes.as_slice())?;
114 verify_schema(&manifest)?;
115 verify_self_sha256(&manifest)?;
116 Ok(manifest)
117}
118
119pub fn enumerate_central(registry_dir: &Path) -> Vec<CacheManifest> {
124 let current_host = host_identity::current();
125 enumerate_central_for_host(registry_dir, ¤t_host)
126}
127
128pub fn enumerate_central_for_host(
130 registry_dir: &Path,
131 current_host: &HostIdentity,
132) -> Vec<CacheManifest> {
133 scan_central(registry_dir)
134 .into_iter()
135 .filter_map(|entry| match entry.result {
136 Ok(manifest) if manifest_matches_host(&manifest, current_host) => Some(manifest),
137 _ => None,
138 })
139 .collect()
140}
141
142pub fn scan_central(registry_dir: &Path) -> Vec<ManifestScanEntry> {
144 match secure_dir::private_dir_permissions_are_private(registry_dir) {
145 Ok(true) => {}
146 Ok(false) => {
147 return vec![ManifestScanEntry {
148 path: registry_dir.to_path_buf(),
149 result: Err(ManifestError::InsecureRegistry(registry_dir.to_path_buf())),
150 }];
151 }
152 Err(_) if !registry_dir.exists() => return Vec::new(),
153 Err(err) => {
154 return vec![ManifestScanEntry {
155 path: registry_dir.to_path_buf(),
156 result: Err(ManifestError::Io(err)),
157 }];
158 }
159 }
160
161 let read_dir = match fs::read_dir(registry_dir) {
162 Ok(read_dir) => read_dir,
163 Err(_) => return Vec::new(),
164 };
165
166 let mut out = Vec::new();
167 for entry in read_dir.flatten() {
168 let path = entry.path();
169 if path.extension().and_then(|s| s.to_str()) != Some("pb") {
170 continue;
171 }
172 let result = read_manifest(&path);
173 out.push(ManifestScanEntry { path, result });
174 }
175 out.sort_by(|a, b| a.path.cmp(&b.path));
176 out
177}
178
179pub fn central_registry_dir() -> PathBuf {
184 if let Some(path) = std::env::var_os(RUNNING_PROCESS_MANIFEST_DIR_ENV) {
185 return PathBuf::from(path);
186 }
187
188 #[cfg(windows)]
189 {
190 dirs::data_dir()
191 .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
192 .join("running-process")
193 .join("manifests")
194 }
195 #[cfg(target_os = "macos")]
196 {
197 dirs::home_dir()
198 .unwrap_or_else(std::env::temp_dir)
199 .join("Library")
200 .join("Application Support")
201 .join("running-process")
202 .join("manifests")
203 }
204 #[cfg(all(unix, not(target_os = "macos")))]
205 {
206 if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") {
207 PathBuf::from(data_home)
208 .join("running-process")
209 .join("manifests")
210 } else {
211 dirs::home_dir()
212 .unwrap_or_else(std::env::temp_dir)
213 .join(".local")
214 .join("share")
215 .join("running-process")
216 .join("manifests")
217 }
218 }
219}
220
221pub fn ensure_central_registry_dir(path: &Path) -> Result<(), ManifestError> {
223 secure_dir::ensure_private_dir(path)?;
224 if !secure_dir::private_dir_permissions_are_private(path)? {
225 return Err(ManifestError::InsecureRegistry(path.to_path_buf()));
226 }
227 Ok(())
228}
229
230pub fn central_manifest_path(
232 registry_dir: &Path,
233 service_name: &str,
234 version: &str,
235) -> Result<PathBuf, ManifestError> {
236 validate_service_name(service_name)?;
237 validate_version(version)?;
238 Ok(registry_dir.join(format!("{service_name}-{version}.pb")))
239}
240
241pub fn manifest_with_self_sha256(manifest: &CacheManifest) -> Result<CacheManifest, ManifestError> {
243 let mut out = manifest.clone();
244 out.manifest_schema_version = SUPPORTED_MANIFEST_SCHEMA_VERSION;
245 if out.media_type.is_empty() {
246 out.media_type = CACHE_MANIFEST_MEDIA_TYPE.to_string();
247 }
248 out.self_sha256.clear();
249 let digest = sha256_for_manifest(&out)?;
250 out.self_sha256 = digest.to_vec();
251 Ok(out)
252}
253
254pub fn sha256_for_manifest(manifest: &CacheManifest) -> Result<[u8; 32], ManifestError> {
256 let mut clone = manifest.clone();
257 clone.self_sha256.clear();
258 let mut bytes = Vec::new();
259 clone.encode(&mut bytes)?;
260 let digest = Sha256::digest(&bytes);
261 let mut out = [0_u8; 32];
262 out.copy_from_slice(&digest);
263 Ok(out)
264}
265
266fn write_manifest_file(path: &Path, manifest: &CacheManifest) -> Result<(), ManifestError> {
267 let manifest = manifest_with_self_sha256(manifest)?;
268 let mut bytes = Vec::new();
269 manifest.encode(&mut bytes)?;
270 atomic_write(path, &bytes)
271}
272
273fn verify_schema(manifest: &CacheManifest) -> Result<(), ManifestError> {
274 if manifest.manifest_schema_version > SUPPORTED_MANIFEST_SCHEMA_VERSION {
275 return Err(ManifestError::SchemaTooNew {
276 got: manifest.manifest_schema_version,
277 supported: SUPPORTED_MANIFEST_SCHEMA_VERSION,
278 });
279 }
280 Ok(())
281}
282
283fn verify_self_sha256(manifest: &CacheManifest) -> Result<(), ManifestError> {
284 if manifest.self_sha256.len() != 32 {
285 return Err(ManifestError::Corruption);
286 }
287 let expected = sha256_for_manifest(manifest)?;
288 if manifest.self_sha256.as_slice() != expected {
289 return Err(ManifestError::Corruption);
290 }
291 Ok(())
292}
293
294fn manifest_matches_host(manifest: &CacheManifest, current_host: &HostIdentity) -> bool {
295 let Some(host) = manifest.host.as_ref() else {
296 return true;
297 };
298 (host.machine_id.is_empty() || host.machine_id == current_host.machine_id)
299 && (host.boot_id.is_empty() || host.boot_id == current_host.boot_id)
300}
301
302pub(super) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ManifestError> {
308 atomic_write(path, bytes)
309}
310
311fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ManifestError> {
312 let parent = path
313 .parent()
314 .ok_or_else(|| ManifestError::MissingParent(path.to_path_buf()))?;
315 fs::create_dir_all(parent)?;
316 let tmp = temp_path_for(path);
317
318 let write_result = (|| -> Result<(), ManifestError> {
319 let mut file = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
320 file.write_all(bytes)?;
321 file.sync_all()?;
322 drop(file);
323 replace_file(&tmp, path)?;
324 sync_parent(parent)?;
325 Ok(())
326 })();
327
328 if write_result.is_err() {
329 let _ = fs::remove_file(&tmp);
330 }
331 write_result
332}
333
334fn temp_path_for(path: &Path) -> PathBuf {
335 let file_name = path
336 .file_name()
337 .and_then(|s| s.to_str())
338 .unwrap_or("manifest.pb");
339 let nanos = SystemTime::now()
340 .duration_since(UNIX_EPOCH)
341 .map(|d| d.as_nanos())
342 .unwrap_or(0);
343 path.with_file_name(format!(".{file_name}.tmp-{}-{nanos}", std::process::id()))
344}
345
346#[cfg(not(windows))]
347fn replace_file(tmp: &Path, target: &Path) -> io::Result<()> {
348 fs::rename(tmp, target)
349}
350
351#[cfg(windows)]
352fn replace_file(tmp: &Path, target: &Path) -> io::Result<()> {
353 use std::os::windows::ffi::OsStrExt;
354 use windows_sys::Win32::Storage::FileSystem::{ReplaceFileW, REPLACEFILE_WRITE_THROUGH};
355
356 if !target.exists() {
357 return fs::rename(tmp, target);
358 }
359
360 fn wide(path: &Path) -> Vec<u16> {
361 path.as_os_str()
362 .encode_wide()
363 .chain(std::iter::once(0))
364 .collect()
365 }
366
367 let target_w = wide(target);
368 let tmp_w = wide(tmp);
369 let ok = unsafe {
370 ReplaceFileW(
371 target_w.as_ptr(),
372 tmp_w.as_ptr(),
373 std::ptr::null(),
374 REPLACEFILE_WRITE_THROUGH,
375 std::ptr::null_mut(),
376 std::ptr::null_mut(),
377 )
378 };
379 if ok == 0 {
380 Err(io::Error::last_os_error())
381 } else {
382 Ok(())
383 }
384}
385
386#[cfg(not(windows))]
387fn sync_parent(parent: &Path) -> io::Result<()> {
388 File::open(parent)?.sync_all()
389}
390
391#[cfg(windows)]
392fn sync_parent(_parent: &Path) -> io::Result<()> {
393 Ok(())
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::broker::protocol::Operation;
400
401 fn sample_manifest() -> CacheManifest {
402 let host = host_identity::current();
403 CacheManifest {
404 manifest_schema_version: 1,
405 media_type: CACHE_MANIFEST_MEDIA_TYPE.to_string(),
406 self_sha256: Vec::new(),
407 host: Some(host),
408 current_operation: Some(Operation {
409 kind: 0,
410 started_at_unix_ms: 1,
411 expected_done_unix_ms: 0,
412 }),
413 valid_until_unix_ms: 0,
414 service_name: "zccache".to_string(),
415 service_version: "1.2.3".to_string(),
416 broker_envelope_version: "v1".to_string(),
417 created_at_unix_ms: 1,
418 last_active_unix_ms: 2,
419 roots: Vec::new(),
420 current_daemon: None,
421 cleanup_policy: None,
422 broker_instance: "shared".to_string(),
423 depends_on: Vec::new(),
424 provides: Vec::new(),
425 observability: None,
426 bundle_id: "bundle".to_string(),
427 }
428 }
429
430 #[test]
431 fn self_hash_roundtrip() {
432 let manifest = manifest_with_self_sha256(&sample_manifest()).unwrap();
433 assert_eq!(manifest.self_sha256.len(), 32);
434 verify_self_sha256(&manifest).unwrap();
435 }
436
437 #[test]
438 fn central_path_validates_inputs() {
439 let dir = Path::new("/tmp/registry");
440 assert!(central_manifest_path(dir, "zccache", "1.2.3").is_ok());
441 assert!(central_manifest_path(dir, "Zccache", "1.2.3").is_err());
442 assert!(central_manifest_path(dir, "zccache", "../../../evil").is_err());
443 }
444
445 #[test]
446 fn central_registry_permissions_are_private_after_ensure() {
447 let tmp = tempfile::tempdir().unwrap();
448 let registry = tmp.path().join("registry");
449 ensure_central_registry_dir(®istry).unwrap();
450 assert!(secure_dir::private_dir_permissions_are_private(®istry).unwrap());
451 }
452}