1use std::fs::{self, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13const PRODUCT: &str = "running-process";
15
16use prost::Message;
17use sha2::{Digest, Sha256};
18
19use crate::daemon_registration::host_identity;
20use crate::daemon_registration::protocol::{CacheManifest, HostIdentity};
21use crate::daemon_registration::secure_dir;
22use crate::daemon_registration::validation::{
23 validate_service_name, validate_version, PipePathError,
24};
25
26pub const ROOT_MANIFEST_FILE: &str = ".running-process-manifest.pb";
28
29pub const CACHE_MANIFEST_MEDIA_TYPE: &str = "application/vnd.running-process.cache-manifest.v1";
31
32pub const SUPPORTED_MANIFEST_SCHEMA_VERSION: u32 = 1;
34
35pub const RUNNING_PROCESS_MANIFEST_DIR_ENV: &str = "RUNNING_PROCESS_MANIFEST_DIR";
37
38#[derive(Debug, thiserror::Error)]
40pub enum ManifestError {
41 #[error("manifest I/O failed: {0}")]
43 Io(#[from] io::Error),
44 #[error("manifest protobuf decode failed: {0}")]
46 Decode(#[from] prost::DecodeError),
47 #[error("manifest protobuf encode failed: {0}")]
49 Encode(#[from] prost::EncodeError),
50 #[error("manifest self_sha256 mismatch")]
52 Corruption,
53 #[error("manifest schema too new: got {got}, supported {supported}")]
55 SchemaTooNew {
56 got: u32,
58 supported: u32,
60 },
61 #[error(transparent)]
63 InvalidName(#[from] PipePathError),
64 #[error("manifest path has no parent: {0}")]
66 MissingParent(PathBuf),
67 #[error("central manifest registry has insecure permissions: {0}")]
69 InsecureRegistry(PathBuf),
70}
71
72#[derive(Debug)]
74pub struct ManifestScanEntry {
75 pub path: PathBuf,
77 pub result: Result<CacheManifest, ManifestError>,
79}
80
81pub fn write_to_root(cache_root: &Path, manifest: &CacheManifest) -> Result<(), ManifestError> {
83 fs::create_dir_all(cache_root)?;
84 secure_dir::ensure_private_dir(cache_root)?;
85 let target = cache_root.join(ROOT_MANIFEST_FILE);
86 write_manifest_file(&target, manifest)
87}
88
89pub fn write_to_central(
91 service_name: &str,
92 version: &str,
93 manifest: &CacheManifest,
94) -> Result<PathBuf, ManifestError> {
95 let dir = central_registry_dir();
96 write_to_central_in_dir(&dir, service_name, version, manifest)
97}
98
99pub fn write_to_central_in_dir(
101 registry_dir: &Path,
102 service_name: &str,
103 version: &str,
104 manifest: &CacheManifest,
105) -> Result<PathBuf, ManifestError> {
106 ensure_central_registry_dir(registry_dir)?;
107 let target = central_manifest_path(registry_dir, service_name, version)?;
108 write_manifest_file(&target, manifest)?;
109 Ok(target)
110}
111
112pub fn read_manifest(path: &Path) -> Result<CacheManifest, ManifestError> {
114 let bytes = fs::read(path)?;
115 let manifest = CacheManifest::decode(bytes.as_slice())?;
116 verify_schema(&manifest)?;
117 verify_self_sha256(&manifest)?;
118 Ok(manifest)
119}
120
121pub fn enumerate_central(registry_dir: &Path) -> Vec<CacheManifest> {
126 let current_host = host_identity::current();
127 enumerate_central_for_host(registry_dir, ¤t_host)
128}
129
130pub fn enumerate_central_for_host(
132 registry_dir: &Path,
133 current_host: &HostIdentity,
134) -> Vec<CacheManifest> {
135 scan_central(registry_dir)
136 .into_iter()
137 .filter_map(|entry| match entry.result {
138 Ok(manifest) if manifest_matches_host(&manifest, current_host) => Some(manifest),
139 _ => None,
140 })
141 .collect()
142}
143
144pub fn scan_central(registry_dir: &Path) -> Vec<ManifestScanEntry> {
146 match secure_dir::private_dir_permissions_are_private(registry_dir) {
147 Ok(true) => {}
148 Ok(false) => {
149 return vec![ManifestScanEntry {
150 path: registry_dir.to_path_buf(),
151 result: Err(ManifestError::InsecureRegistry(registry_dir.to_path_buf())),
152 }];
153 }
154 Err(_) if !registry_dir.exists() => return Vec::new(),
155 Err(err) => {
156 return vec![ManifestScanEntry {
157 path: registry_dir.to_path_buf(),
158 result: Err(ManifestError::Io(err)),
159 }];
160 }
161 }
162
163 let read_dir = match fs::read_dir(registry_dir) {
164 Ok(read_dir) => read_dir,
165 Err(_) => return Vec::new(),
166 };
167
168 let mut out = Vec::new();
169 for entry in read_dir.flatten() {
170 let path = entry.path();
171 if path.extension().and_then(|s| s.to_str()) != Some("pb") {
172 continue;
173 }
174 let result = read_manifest(&path);
175 out.push(ManifestScanEntry { path, result });
176 }
177 out.sort_by(|a, b| a.path.cmp(&b.path));
178 out
179}
180
181pub fn central_registry_dir() -> PathBuf {
186 if let Some(path) = crate::env_vars::MANIFEST_DIR.path() {
187 return path;
188 }
189
190 crate::platform::fs::user_data_dir(PRODUCT).join("manifests")
191}
192
193pub fn ensure_central_registry_dir(path: &Path) -> Result<(), ManifestError> {
195 secure_dir::ensure_private_dir(path)?;
196 if !secure_dir::private_dir_permissions_are_private(path)? {
197 return Err(ManifestError::InsecureRegistry(path.to_path_buf()));
198 }
199 Ok(())
200}
201
202pub fn central_manifest_path(
204 registry_dir: &Path,
205 service_name: &str,
206 version: &str,
207) -> Result<PathBuf, ManifestError> {
208 validate_service_name(service_name)?;
209 validate_version(version)?;
210 Ok(registry_dir.join(format!("{service_name}-{version}.pb")))
211}
212
213pub fn manifest_with_self_sha256(manifest: &CacheManifest) -> Result<CacheManifest, ManifestError> {
215 let mut out = manifest.clone();
216 out.manifest_schema_version = SUPPORTED_MANIFEST_SCHEMA_VERSION;
217 if out.media_type.is_empty() {
218 out.media_type = CACHE_MANIFEST_MEDIA_TYPE.to_string();
219 }
220 out.self_sha256.clear();
221 let digest = sha256_for_manifest(&out)?;
222 out.self_sha256 = digest.to_vec();
223 Ok(out)
224}
225
226pub fn sha256_for_manifest(manifest: &CacheManifest) -> Result<[u8; 32], ManifestError> {
228 let mut clone = manifest.clone();
229 clone.self_sha256.clear();
230 let mut bytes = Vec::new();
231 clone.encode(&mut bytes)?;
232 let digest = Sha256::digest(&bytes);
233 let mut out = [0_u8; 32];
234 out.copy_from_slice(&digest);
235 Ok(out)
236}
237
238fn write_manifest_file(path: &Path, manifest: &CacheManifest) -> Result<(), ManifestError> {
239 let manifest = manifest_with_self_sha256(manifest)?;
240 let mut bytes = Vec::new();
241 manifest.encode(&mut bytes)?;
242 atomic_write(path, &bytes)
243}
244
245fn verify_schema(manifest: &CacheManifest) -> Result<(), ManifestError> {
246 if manifest.manifest_schema_version > SUPPORTED_MANIFEST_SCHEMA_VERSION {
247 return Err(ManifestError::SchemaTooNew {
248 got: manifest.manifest_schema_version,
249 supported: SUPPORTED_MANIFEST_SCHEMA_VERSION,
250 });
251 }
252 Ok(())
253}
254
255fn verify_self_sha256(manifest: &CacheManifest) -> Result<(), ManifestError> {
256 if manifest.self_sha256.len() != 32 {
257 return Err(ManifestError::Corruption);
258 }
259 let expected = sha256_for_manifest(manifest)?;
260 if manifest.self_sha256.as_slice() != expected {
261 return Err(ManifestError::Corruption);
262 }
263 Ok(())
264}
265
266fn manifest_matches_host(manifest: &CacheManifest, current_host: &HostIdentity) -> bool {
267 let Some(host) = manifest.host.as_ref() else {
268 return true;
269 };
270 (host.machine_id.is_empty() || host.machine_id == current_host.machine_id)
271 && (host.boot_id.is_empty() || host.boot_id == current_host.boot_id)
272}
273
274#[cfg(feature = "client")]
280pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ManifestError> {
281 atomic_write(path, bytes)
282}
283
284fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ManifestError> {
285 let parent = path
286 .parent()
287 .ok_or_else(|| ManifestError::MissingParent(path.to_path_buf()))?;
288 fs::create_dir_all(parent)?;
289 let tmp = temp_path_for(path);
290
291 let write_result = (|| -> Result<(), ManifestError> {
292 let mut file = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
293 file.write_all(bytes)?;
294 file.sync_all()?;
295 drop(file);
296 crate::platform::fs::replace_file(&tmp, path)?;
297 crate::platform::fs::sync_directory(parent)?;
298 Ok(())
299 })();
300
301 if write_result.is_err() {
302 let _ = fs::remove_file(&tmp);
303 }
304 write_result
305}
306
307fn temp_path_for(path: &Path) -> PathBuf {
308 let file_name = path
309 .file_name()
310 .and_then(|s| s.to_str())
311 .unwrap_or("manifest.pb");
312 let nanos = SystemTime::now()
313 .duration_since(UNIX_EPOCH)
314 .map(|d| d.as_nanos())
315 .unwrap_or(0);
316 path.with_file_name(format!(".{file_name}.tmp-{}-{nanos}", std::process::id()))
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::daemon_registration::protocol::Operation;
323
324 fn sample_manifest() -> CacheManifest {
325 let host = host_identity::current();
326 CacheManifest {
327 manifest_schema_version: 1,
328 media_type: CACHE_MANIFEST_MEDIA_TYPE.to_string(),
329 self_sha256: Vec::new(),
330 host: Some(host),
331 current_operation: Some(Operation {
332 kind: 0,
333 started_at_unix_ms: 1,
334 expected_done_unix_ms: 0,
335 }),
336 valid_until_unix_ms: 0,
337 service_name: "zccache".to_string(),
338 service_version: "1.2.3".to_string(),
339 broker_envelope_version: "v1".to_string(),
340 created_at_unix_ms: 1,
341 last_active_unix_ms: 2,
342 roots: Vec::new(),
343 current_daemon: None,
344 cleanup_policy: None,
345 broker_instance: "shared".to_string(),
346 depends_on: Vec::new(),
347 provides: Vec::new(),
348 observability: None,
349 bundle_id: "bundle".to_string(),
350 }
351 }
352
353 #[test]
354 fn self_hash_roundtrip() {
355 let manifest = manifest_with_self_sha256(&sample_manifest()).unwrap();
356 assert_eq!(manifest.self_sha256.len(), 32);
357 verify_self_sha256(&manifest).unwrap();
358 }
359
360 #[test]
361 fn central_path_validates_inputs() {
362 let dir = Path::new("/tmp/registry");
363 assert!(central_manifest_path(dir, "zccache", "1.2.3").is_ok());
364 assert!(central_manifest_path(dir, "Zccache", "1.2.3").is_err());
365 assert!(central_manifest_path(dir, "zccache", "../../../evil").is_err());
366 }
367
368 #[test]
369 fn central_registry_permissions_are_private_after_ensure() {
370 let tmp = tempfile::tempdir().unwrap();
371 let registry = tmp.path().join("registry");
372 ensure_central_registry_dir(®istry).unwrap();
373 assert!(secure_dir::private_dir_permissions_are_private(®istry).unwrap());
374 }
375}