1use std::fs::{File, OpenOptions};
8use std::io::{Read, Seek, Write};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Mutex, OnceLock};
12
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16const MAX_MODEL_ID_BYTES: usize = 4 * 1024;
17const MAX_STATE_BYTES: u64 = 64 * 1024;
18const QUARANTINE_VANISHED_REASON: &str = "quarantine vanished before deletion; the removal journal is retained, so retrying the removal resumes and completes it";
25
26#[derive(Debug, thiserror::Error)]
27pub enum ModelManagementError {
28 #[error("model-management I/O failed at {path}: {source}")]
29 Io {
30 path: PathBuf,
31 #[source]
32 source: std::io::Error,
33 },
34 #[error("invalid model-management state at {path}: {message}")]
35 InvalidState { path: PathBuf, message: String },
36 #[error("model-management identity mismatch at {path}: expected {expected}, found {actual}")]
37 IdentityMismatch {
38 path: PathBuf,
39 expected: String,
40 actual: String,
41 },
42 #[error("no CAR install receipt exists for {model_id}")]
43 MissingReceipt { model_id: String },
44 #[error("unsafe CAR-managed path for {model_id}: {path} ({reason})")]
45 UnsafeManagedPath {
46 model_id: String,
47 path: PathBuf,
48 reason: String,
49 },
50 #[error("local model {model_id} is in use")]
51 ModelInUse { model_id: String },
52 #[error("local model-management operation is already active for {model_id}")]
53 MutationInProgress { model_id: String },
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum ManagedArtifactKind {
59 Symlink,
60 Directory,
61 File,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct InstallReceipt {
67 pub model_id: String,
68 pub managed_path: PathBuf,
69 pub artifact_kind: ManagedArtifactKind,
70 pub source_model_id: String,
71 pub source_revision: Option<String>,
72 pub creation_generation: u64,
73 pub shared_cache_references: Vec<PathBuf>,
74 pub adopted: bool,
75}
76
77#[derive(Debug, Clone)]
78pub struct ModelManagementStore {
79 models_dir: PathBuf,
80 management_state_dir: PathBuf,
81 receipts_root: PathBuf,
82 tombstones_root: PathBuf,
83 installs_root: PathBuf,
84 removals_root: PathBuf,
85 leases_root: PathBuf,
86 mutation_locks_root: PathBuf,
87 #[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
91 removal_hook: Option<RemovalTestHook>,
92 #[cfg(test)]
93 identity_init_hook: Option<fn(&Path)>,
94}
95
96pub(crate) const fn directory_removal_supported() -> bool {
101 cfg!(any(target_os = "macos", target_os = "linux"))
102}
103
104#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub(crate) enum RemovalPhase {
107 AfterRename,
109 BeforeRootOpen,
112 AfterCapture,
115}
116
117#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
118#[derive(Clone)]
119pub(crate) struct RemovalTestHook(std::sync::Arc<dyn Fn(RemovalPhase, &Path) + Send + Sync>);
120
121#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
122impl std::fmt::Debug for RemovalTestHook {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.write_str("RemovalTestHook")
125 }
126}
127
128impl ModelManagementStore {
129 pub fn new(state_root: PathBuf, models_dir: PathBuf) -> Self {
130 let state_root = crate::resource_policy::normalized_state_root_key(&state_root);
131 let management_state_dir = state_root.join("model-management");
132 let models_dir = crate::resource_policy::normalized_state_root_key(&models_dir);
136 let shared_coordination_root = models_dir
137 .parent()
138 .unwrap_or(&models_dir)
139 .join(".car-model-management");
140 let store = Self {
141 models_dir,
142 receipts_root: management_state_dir.join("receipts"),
143 tombstones_root: management_state_dir.join("tombstones"),
144 installs_root: management_state_dir.join("installs"),
145 removals_root: management_state_dir.join("removals"),
146 leases_root: shared_coordination_root.join("activity"),
147 mutation_locks_root: shared_coordination_root.join("mutation"),
148 management_state_dir,
149 #[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
150 removal_hook: None,
151 #[cfg(test)]
152 identity_init_hook: None,
153 };
154 store.resume_pending_quarantines();
158 store
159 }
160
161 pub fn models_dir(&self) -> &Path {
162 &self.models_dir
163 }
164
165 #[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
166 pub(crate) fn with_removal_hook(
167 mut self,
168 hook: impl Fn(RemovalPhase, &Path) + Send + Sync + 'static,
169 ) -> Self {
170 self.removal_hook = Some(RemovalTestHook(std::sync::Arc::new(hook)));
171 self
172 }
173
174 pub fn management_state_dir(&self) -> &Path {
175 &self.management_state_dir
176 }
177
178 pub fn receipts_root(&self) -> &Path {
179 &self.receipts_root
180 }
181
182 pub fn receipt_path(&self, model_id: &str) -> PathBuf {
183 self.receipts_root.join(hashed_filename(model_id))
184 }
185
186 pub fn tombstones_root(&self) -> &Path {
187 &self.tombstones_root
188 }
189
190 pub fn tombstone_path(&self, model_id: &str) -> PathBuf {
191 self.tombstones_root.join(hashed_filename(model_id))
192 }
193
194 fn removal_journal_path(&self, model_id: &str) -> PathBuf {
195 self.removals_root.join(hashed_filename(model_id))
196 }
197
198 fn install_journal_path(&self, model_id: &str) -> PathBuf {
199 self.installs_root.join(hashed_filename(model_id))
200 }
201
202 pub fn leases_root(&self) -> &Path {
203 &self.leases_root
204 }
205
206 pub fn lease_path(&self, model_id: &str) -> PathBuf {
207 self.leases_root.join(hashed_filename(model_id))
208 }
209
210 pub fn mutation_lock_path(&self, model_id: &str) -> PathBuf {
211 self.mutation_locks_root.join(hashed_filename(model_id))
212 }
213
214 pub fn load_receipt(
215 &self,
216 model_id: &str,
217 ) -> Result<Option<InstallReceipt>, ModelManagementError> {
218 validate_model_id(model_id)?;
219 let path = self.receipt_path(model_id);
220 let bytes = match read_private_state(&path) {
221 Ok(bytes) => bytes,
222 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
223 Err(source) => return Err(ModelManagementError::Io { path, source }),
224 };
225 let receipt: InstallReceipt =
226 serde_json::from_slice(&bytes).map_err(|error| ModelManagementError::InvalidState {
227 path: path.clone(),
228 message: error.to_string(),
229 })?;
230 ensure_identity(&path, model_id, &receipt.model_id)?;
231 Ok(Some(receipt))
232 }
233
234 pub(crate) fn write_receipt(
235 &self,
236 receipt: &InstallReceipt,
237 ) -> Result<(), ModelManagementError> {
238 validate_model_id(&receipt.model_id)?;
239 if let Some(existing) = self.load_receipt(&receipt.model_id)? {
240 ensure_identity(
241 &self.receipt_path(&receipt.model_id),
242 &receipt.model_id,
243 &existing.model_id,
244 )?;
245 }
246 write_private_json(&self.receipt_path(&receipt.model_id), receipt)
247 }
248
249 pub fn can_remove(&self, model_id: &str) -> Result<bool, ModelManagementError> {
252 let Some(receipt) = self.load_receipt(model_id)? else {
253 return Ok(false);
254 };
255 self.validate_managed_artifact(&receipt)?;
256 Ok(
261 receipt.artifact_kind != ManagedArtifactKind::Directory
262 || directory_removal_supported(),
263 )
264 }
265
266 pub fn acquire_lease(&self, model_id: &str) -> Result<ModelLease, ModelManagementError> {
267 validate_model_id(model_id)?;
268 let mutation = self.open_identity_lock(&self.mutation_lock_path(model_id), model_id)?;
272 mutation
273 .lock_shared()
274 .map_err(|source| ModelManagementError::Io {
275 path: self.mutation_lock_path(model_id),
276 source,
277 })?;
278 let mut mutation = OwnedFileLock::new(mutation);
279 validate_lock_identity(&mut mutation, &self.mutation_lock_path(model_id), model_id)?;
280 let file = self.open_identity_lock(&self.lease_path(model_id), model_id)?;
281 file.lock_shared()
282 .map_err(|source| ModelManagementError::Io {
283 path: self.lease_path(model_id),
284 source,
285 })?;
286 let mut file = OwnedFileLock::new(file);
287 validate_lock_identity(&mut file, &self.lease_path(model_id), model_id)?;
288 drop(mutation);
289 Ok(ModelLease { _file: file })
290 }
291
292 pub(crate) fn begin_mutation(
293 &self,
294 model_id: &str,
295 ) -> Result<ModelMutationGuard, ModelManagementError> {
296 validate_model_id(model_id)?;
297 let mut file = self.open_identity_lock(&self.mutation_lock_path(model_id), model_id)?;
298 validate_lock_identity(&mut file, &self.mutation_lock_path(model_id), model_id)?;
299 match file.try_lock() {
300 Ok(()) => Ok(ModelMutationGuard {
301 store: self.clone(),
302 model_id: model_id.to_string(),
303 _file: OwnedFileLock::new(file),
304 }),
305 Err(std::fs::TryLockError::WouldBlock) => {
306 Err(ModelManagementError::MutationInProgress {
307 model_id: model_id.to_string(),
308 })
309 }
310 Err(std::fs::TryLockError::Error(source)) => Err(ModelManagementError::Io {
311 path: self.mutation_lock_path(model_id),
312 source,
313 }),
314 }
315 }
316
317 pub fn model_in_use(&self, model_id: &str) -> Result<bool, ModelManagementError> {
318 validate_model_id(model_id)?;
319 let mut file = self.open_identity_lock(&self.lease_path(model_id), model_id)?;
320 validate_lock_identity(&mut file, &self.lease_path(model_id), model_id)?;
321 match file.try_lock() {
322 Ok(()) => {
323 let _lock = OwnedFileLock::new(file);
324 Ok(false)
325 }
326 Err(std::fs::TryLockError::WouldBlock) => Ok(true),
327 Err(std::fs::TryLockError::Error(source)) => Err(ModelManagementError::Io {
328 path: self.lease_path(model_id),
329 source,
330 }),
331 }
332 }
333
334 pub fn car_enabled(&self, model_id: &str) -> Result<bool, ModelManagementError> {
335 Ok(self.load_tombstone(model_id)?.is_none())
336 }
337
338 fn load_tombstone(
339 &self,
340 model_id: &str,
341 ) -> Result<Option<RemovalTombstone>, ModelManagementError> {
342 validate_model_id(model_id)?;
343 let path = self.tombstone_path(model_id);
344 let bytes = match read_private_state(&path) {
345 Ok(bytes) => bytes,
346 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
347 Err(source) => return Err(ModelManagementError::Io { path, source }),
348 };
349 let tombstone: RemovalTombstone =
350 serde_json::from_slice(&bytes).map_err(|error| ModelManagementError::InvalidState {
351 path: path.clone(),
352 message: error.to_string(),
353 })?;
354 ensure_identity(&path, model_id, &tombstone.model_id)?;
355 Ok(Some(tombstone))
356 }
357
358 pub(crate) fn clear_tombstone(&self, model_id: &str) -> Result<(), ModelManagementError> {
359 validate_model_id(model_id)?;
360 let _ = self.car_enabled(model_id)?;
361 let path = self.tombstone_path(model_id);
362 match std::fs::remove_file(&path) {
363 Ok(()) => sync_directory(path.parent().expect("tombstone path has parent"))
364 .map_err(|source| ModelManagementError::Io { path, source }),
365 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
366 Err(source) => Err(ModelManagementError::Io { path, source }),
367 }
368 }
369
370 pub(crate) fn record_managed_artifact(
374 &self,
375 model_id: &str,
376 source_model_id: &str,
377 source_revision: Option<String>,
378 creation_generation: u64,
379 adopted: bool,
380 managed_path: PathBuf,
381 ) -> Result<InstallReceipt, ModelManagementError> {
382 validate_model_id(model_id)?;
383 let artifact_kind = artifact_kind(&managed_path, model_id)?;
384 let shared_cache_references = collect_shared_references(&managed_path, model_id)?;
385 let receipt = InstallReceipt {
386 model_id: model_id.to_string(),
387 managed_path,
388 artifact_kind,
389 source_model_id: source_model_id.to_string(),
390 source_revision,
391 creation_generation,
392 shared_cache_references,
393 adopted,
394 };
395 self.validate_managed_artifact(&receipt)?;
396 self.write_receipt(&receipt)?;
397 self.clear_tombstone(model_id)?;
398 Ok(receipt)
399 }
400
401 pub(crate) fn begin_install_intent(
402 &self,
403 receipt: InstallReceipt,
404 staging_path: Option<&Path>,
405 ) -> Result<(), ModelManagementError> {
406 validate_model_id(&receipt.model_id)?;
407 validate_direct_child(&self.models_dir, &receipt.managed_path, &receipt.model_id)?;
408 let staging_path = staging_path.map(Path::to_path_buf);
409 let staging_identity = staging_path
410 .as_deref()
411 .map(|path| artifact_identity(path, &receipt.model_id))
412 .transpose()?;
413 if let Some(path) = staging_path.as_deref() {
414 validate_direct_child(&self.models_dir, path, &receipt.model_id)?;
415 }
416 let intent = InstallJournal {
417 model_id: receipt.model_id.clone(),
418 receipt,
419 staging_path,
420 staging_identity,
421 };
422 let path = self.install_journal_path(&intent.model_id);
423 if let Some(existing) = self.load_install_intent(&intent.model_id)? {
424 if existing == intent {
425 return Ok(());
426 }
427 return Err(ModelManagementError::InvalidState {
428 path,
429 message: "a different CAR install intent already exists for this model".into(),
430 });
431 }
432 if std::fs::symlink_metadata(&intent.receipt.managed_path).is_ok() {
433 return Err(ModelManagementError::UnsafeManagedPath {
434 model_id: intent.model_id,
435 path: intent.receipt.managed_path,
436 reason: "pre-existing unreceipted leaf cannot become CAR-owned through an install intent"
437 .into(),
438 });
439 }
440 write_private_json(&path, &intent)
441 }
442
443 pub(crate) fn install_receipt_for_publication(
444 &self,
445 model_id: &str,
446 source_model_id: &str,
447 creation_generation: u64,
448 adopted: bool,
449 managed_path: PathBuf,
450 artifact_kind: ManagedArtifactKind,
451 artifact_source: &Path,
452 ) -> Result<InstallReceipt, ModelManagementError> {
453 validate_model_id(model_id)?;
454 let shared_cache_references = match artifact_kind {
455 ManagedArtifactKind::Symlink => {
456 vec![artifact_source
457 .canonicalize()
458 .map_err(|source| ModelManagementError::Io {
459 path: artifact_source.to_path_buf(),
460 source,
461 })?]
462 }
463 ManagedArtifactKind::Directory => collect_shared_references(artifact_source, model_id)?,
464 ManagedArtifactKind::File => Vec::new(),
465 };
466 Ok(InstallReceipt {
467 model_id: model_id.to_string(),
468 managed_path,
469 artifact_kind,
470 source_model_id: source_model_id.to_string(),
471 source_revision: None,
472 creation_generation,
473 shared_cache_references,
474 adopted,
475 })
476 }
477
478 pub(crate) fn resume_install_intent(
479 &self,
480 model_id: &str,
481 ) -> Result<Option<InstallReceipt>, ModelManagementError> {
482 let Some(intent) = self.load_install_intent(model_id)? else {
483 return Ok(None);
484 };
485 if std::fs::symlink_metadata(&intent.receipt.managed_path).is_err() {
486 if let (Some(staging), Some(identity)) = (
487 intent.staging_path.as_deref(),
488 intent.staging_identity.as_ref(),
489 ) {
490 if std::fs::symlink_metadata(staging).is_ok() {
491 ensure_artifact_identity(staging, model_id, identity)?;
492 atomic_rename_noreplace(staging, &intent.receipt.managed_path).map_err(
493 |source| ModelManagementError::Io {
494 path: intent.receipt.managed_path.clone(),
495 source,
496 },
497 )?;
498 sync_directory(&self.models_dir).map_err(|source| {
499 ModelManagementError::Io {
500 path: self.models_dir.clone(),
501 source,
502 }
503 })?;
504 } else {
505 self.clear_install_intent(model_id)?;
506 return Ok(None);
507 }
508 } else {
509 self.clear_install_intent(model_id)?;
512 return Ok(None);
513 }
514 }
515 if let Some(identity) = intent.staging_identity.as_ref() {
516 ensure_artifact_identity(&intent.receipt.managed_path, model_id, identity)?;
517 }
518 self.validate_managed_artifact(&intent.receipt)?;
519 self.write_receipt(&intent.receipt)?;
520 self.clear_install_intent(model_id)?;
521 self.clear_tombstone(model_id)?;
522 Ok(Some(intent.receipt))
523 }
524
525 fn load_install_intent(
526 &self,
527 model_id: &str,
528 ) -> Result<Option<InstallJournal>, ModelManagementError> {
529 let path = self.install_journal_path(model_id);
530 let bytes = match read_private_state(&path) {
531 Ok(bytes) => bytes,
532 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
533 Err(source) => return Err(ModelManagementError::Io { path, source }),
534 };
535 let intent: InstallJournal =
536 serde_json::from_slice(&bytes).map_err(|error| ModelManagementError::InvalidState {
537 path: path.clone(),
538 message: error.to_string(),
539 })?;
540 ensure_identity(&path, model_id, &intent.model_id)?;
541 ensure_identity(&path, model_id, &intent.receipt.model_id)?;
542 validate_direct_child(&self.models_dir, &intent.receipt.managed_path, model_id)?;
543 if let Some(staging) = intent.staging_path.as_deref() {
544 validate_direct_child(&self.models_dir, staging, model_id)?;
545 if !staging
546 .file_name()
547 .and_then(|name| name.to_str())
548 .is_some_and(|name| name.starts_with(".car-install-"))
549 {
550 return Err(ModelManagementError::UnsafeManagedPath {
551 model_id: model_id.to_string(),
552 path: staging.to_path_buf(),
553 reason: "install journal staging path lacks CAR staging identity".into(),
554 });
555 }
556 }
557 Ok(Some(intent))
558 }
559
560 fn clear_install_intent(&self, model_id: &str) -> Result<(), ModelManagementError> {
561 let path = self.install_journal_path(model_id);
562 match std::fs::remove_file(&path) {
563 Ok(()) => sync_directory(&self.installs_root)
564 .map_err(|source| ModelManagementError::Io { path, source }),
565 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
566 Err(source) => Err(ModelManagementError::Io { path, source }),
567 }
568 }
569
570 pub(crate) fn materialize_managed_projection(
574 &self,
575 managed_leaf: &str,
576 source: &Path,
577 ) -> Result<PathBuf, ModelManagementError> {
578 validate_managed_leaf(managed_leaf, &self.models_dir)?;
579 create_private_dir(&self.models_dir)?;
580 let models_root = canonical_directory(&self.models_dir)?;
581 let source_parent =
582 source
583 .parent()
584 .ok_or_else(|| ModelManagementError::UnsafeManagedPath {
585 model_id: managed_leaf.to_string(),
586 path: source.to_path_buf(),
587 reason: "source has no parent".into(),
588 })?;
589 if canonical_directory(source_parent)? == models_root
590 && source == self.models_dir.join(managed_leaf)
591 {
592 return Err(ModelManagementError::UnsafeManagedPath {
593 model_id: managed_leaf.to_string(),
594 path: source.to_path_buf(),
595 reason: "pre-existing unreceipted managed projection cannot become CAR-owned"
596 .into(),
597 });
598 }
599 let target = source
600 .canonicalize()
601 .map_err(|source_error| ModelManagementError::Io {
602 path: source.to_path_buf(),
603 source: source_error,
604 })?;
605 let managed = self.models_dir.join(managed_leaf);
606 if std::fs::symlink_metadata(&managed).is_ok() {
607 return Err(ModelManagementError::UnsafeManagedPath {
608 model_id: managed_leaf.to_string(),
609 path: managed,
610 reason: "pre-existing managed projection has no CAR creation receipt".into(),
611 });
612 }
613 static NEXT_PROJECTION: AtomicU64 = AtomicU64::new(1);
614 let tmp = self.models_dir.join(format!(
615 ".managed-projection.{}.{}",
616 std::process::id(),
617 NEXT_PROJECTION.fetch_add(1, Ordering::Relaxed)
618 ));
619 create_symlink(&target, &tmp)?;
620 let result = atomic_rename_noreplace(&tmp, &managed).map_err(|source| {
621 ModelManagementError::UnsafeManagedPath {
622 model_id: managed_leaf.to_string(),
623 path: managed.clone(),
624 reason: format!("managed projection publication raced or failed: {source}"),
625 }
626 });
627 if result.is_err() {
628 let _ = std::fs::remove_file(&tmp);
629 }
630 result?;
631 sync_directory(&self.models_dir).map_err(|source| ModelManagementError::Io {
632 path: self.models_dir.clone(),
633 source,
634 })?;
635 Ok(managed)
636 }
637
638 pub(crate) fn create_install_staging(
639 &self,
640 model_id: &str,
641 ) -> Result<PathBuf, ModelManagementError> {
642 validate_model_id(model_id)?;
643 create_private_dir(&self.models_dir)?;
644 let digest = hex::encode(Sha256::digest(model_id.as_bytes()));
645 for _ in 0..16 {
646 let candidate = self.models_dir.join(format!(
647 ".car-install-{}-{:032x}",
648 &digest[..16],
649 rand::random::<u128>()
650 ));
651 match std::fs::create_dir(&candidate) {
652 Ok(()) => {
653 harden_private_directory(&candidate)?;
654 return Ok(candidate);
655 }
656 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
657 Err(source) => {
658 return Err(ModelManagementError::Io {
659 path: candidate,
660 source,
661 });
662 }
663 }
664 }
665 Err(ModelManagementError::InvalidState {
666 path: self.models_dir.clone(),
667 message: "could not allocate a collision-resistant install staging directory".into(),
668 })
669 }
670
671 pub(crate) fn publish_install_staging(
672 &self,
673 model_id: &str,
674 staging: &Path,
675 managed_leaf: &str,
676 ) -> Result<PathBuf, ModelManagementError> {
677 validate_model_id(model_id)?;
678 validate_direct_child(&self.models_dir, staging, model_id)?;
679 if !staging
680 .file_name()
681 .and_then(|name| name.to_str())
682 .is_some_and(|name| name.starts_with(".car-install-"))
683 {
684 return Err(ModelManagementError::UnsafeManagedPath {
685 model_id: model_id.to_string(),
686 path: staging.to_path_buf(),
687 reason: "install staging path lacks CAR staging identity".into(),
688 });
689 }
690 validate_managed_leaf(managed_leaf, &self.models_dir)?;
691 let managed = self.models_dir.join(managed_leaf);
692 atomic_rename_noreplace(staging, &managed).map_err(|source| {
693 ModelManagementError::UnsafeManagedPath {
694 model_id: model_id.to_string(),
695 path: managed.clone(),
696 reason: format!("could not atomically publish CAR-owned install: {source}"),
697 }
698 })?;
699 sync_directory(&self.models_dir).map_err(|source| ModelManagementError::Io {
700 path: self.models_dir.clone(),
701 source,
702 })?;
703 Ok(managed)
704 }
705
706 pub(crate) fn discard_install_staging(&self, staging: &Path) {
707 let _ = staging;
712 }
713
714 pub(crate) fn materialize_adopted_projection(
718 &self,
719 model_id: &str,
720 source: &Path,
721 ) -> Result<PathBuf, ModelManagementError> {
722 validate_model_id(model_id)?;
723 let managed = self.adopted_projection_path(model_id)?;
724 let leaf = managed
725 .file_name()
726 .and_then(|name| name.to_str())
727 .expect("adopted projection is an UTF-8 direct child");
728 self.materialize_managed_projection(leaf, source)
729 }
730
731 pub(crate) fn adopted_projection_path(
732 &self,
733 model_id: &str,
734 ) -> Result<PathBuf, ModelManagementError> {
735 validate_model_id(model_id)?;
736 let digest = hex::encode(Sha256::digest(model_id.as_bytes()));
737 Ok(self.models_dir.join(format!(".car-adopted-{digest}")))
738 }
739
740 fn remove_with_mutation(
741 &self,
742 model_id: &str,
743 removal_generation: u64,
744 ) -> Result<RemoveFromCarResult, ModelManagementError> {
745 let _activity = self.lock_idle_activity(model_id)?;
746
747 if let Some(journal) = self.load_removal_journal(model_id)? {
748 write_private_json(
749 &self.tombstone_path(model_id),
750 &RemovalTombstone {
751 model_id: model_id.to_string(),
752 removal_generation: journal.removal_generation,
753 artifact_kind: Some(journal.receipt.artifact_kind),
754 preserved_shared_cache_references: journal
755 .receipt
756 .shared_cache_references
757 .clone(),
758 },
759 )?;
760 return self.resume_removal_journal(journal);
761 }
762
763 let Some(receipt) = self.load_receipt(model_id)? else {
764 if let Some(tombstone) = self.load_tombstone(model_id)? {
765 if let Some(artifact_kind) = tombstone.artifact_kind {
766 return Ok(RemoveFromCarResult {
767 model_id: model_id.to_string(),
768 artifact_kind,
769 preserved_shared_cache_references: tombstone
770 .preserved_shared_cache_references,
771 });
772 }
773 }
774 return Err(ModelManagementError::MissingReceipt {
775 model_id: model_id.to_string(),
776 });
777 };
778 self.validate_managed_artifact(&receipt)?;
779 if receipt.artifact_kind == ManagedArtifactKind::Directory && !directory_removal_supported()
780 {
781 return Err(ModelManagementError::UnsafeManagedPath {
782 model_id: model_id.to_string(),
783 path: receipt.managed_path,
784 reason: "recursive directory removal is unsupported without object-bound traversal"
785 .into(),
786 });
787 }
788 let artifact_identity = artifact_identity(&receipt.managed_path, model_id)?;
789 let quarantine = self.allocate_quarantine(model_id)?;
790
791 write_private_json(
792 &self.tombstone_path(model_id),
793 &RemovalTombstone {
794 model_id: model_id.to_string(),
795 removal_generation,
796 artifact_kind: Some(receipt.artifact_kind),
797 preserved_shared_cache_references: receipt.shared_cache_references.clone(),
798 },
799 )?;
800 let journal = RemovalJournal {
801 model_id: model_id.to_string(),
802 removal_generation,
803 receipt,
804 quarantine_path: quarantine,
805 artifact_identity,
806 };
807 write_private_json(&self.removal_journal_path(model_id), &journal)?;
808 self.resume_removal_journal(journal)
809 }
810
811 fn lock_idle_activity(&self, model_id: &str) -> Result<OwnedFileLock, ModelManagementError> {
812 let mut activity = self.open_identity_lock(&self.lease_path(model_id), model_id)?;
813 validate_lock_identity(&mut activity, &self.lease_path(model_id), model_id)?;
814 match activity.try_lock() {
815 Ok(()) => Ok(OwnedFileLock::new(activity)),
816 Err(std::fs::TryLockError::WouldBlock) => Err(ModelManagementError::ModelInUse {
817 model_id: model_id.to_string(),
818 }),
819 Err(std::fs::TryLockError::Error(source)) => Err(ModelManagementError::Io {
820 path: self.lease_path(model_id),
821 source,
822 }),
823 }
824 }
825
826 fn load_removal_journal(
827 &self,
828 model_id: &str,
829 ) -> Result<Option<RemovalJournal>, ModelManagementError> {
830 let path = self.removal_journal_path(model_id);
831 let bytes = match read_private_state(&path) {
832 Ok(bytes) => bytes,
833 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
834 Err(source) => return Err(ModelManagementError::Io { path, source }),
835 };
836 let journal: RemovalJournal =
837 serde_json::from_slice(&bytes).map_err(|error| ModelManagementError::InvalidState {
838 path: path.clone(),
839 message: error.to_string(),
840 })?;
841 ensure_identity(&path, model_id, &journal.model_id)?;
842 ensure_identity(&path, model_id, &journal.receipt.model_id)?;
843 validate_direct_child(&self.models_dir, &journal.receipt.managed_path, model_id)?;
844 if journal.receipt.artifact_kind == ManagedArtifactKind::Directory
845 && !directory_removal_supported()
846 {
847 return Err(ModelManagementError::UnsafeManagedPath {
848 model_id: model_id.to_string(),
849 path: journal.receipt.managed_path,
850 reason: "recursive directory removal journal is unsupported".into(),
851 });
852 }
853 validate_direct_child(&self.models_dir, &journal.quarantine_path, model_id)?;
854 if !journal
855 .quarantine_path
856 .file_name()
857 .and_then(|name| name.to_str())
858 .is_some_and(|name| name.starts_with(".car-remove-"))
859 {
860 return Err(ModelManagementError::UnsafeManagedPath {
861 model_id: model_id.to_string(),
862 path: journal.quarantine_path,
863 reason: "removal journal has an invalid quarantine path".into(),
864 });
865 }
866 Ok(Some(journal))
867 }
868
869 fn allocate_quarantine(&self, model_id: &str) -> Result<PathBuf, ModelManagementError> {
870 let digest = hex::encode(Sha256::digest(model_id.as_bytes()));
871 for _ in 0..16 {
872 let candidate = self.models_dir.join(format!(
873 ".car-remove-{}-{:032x}",
874 &digest[..16],
875 rand::random::<u128>()
876 ));
877 if std::fs::symlink_metadata(&candidate)
878 .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound)
879 {
880 return Ok(candidate);
881 }
882 }
883 Err(ModelManagementError::InvalidState {
884 path: self.models_dir.clone(),
885 message: "could not allocate a collision-resistant removal quarantine".into(),
886 })
887 }
888
889 fn resume_removal_journal(
890 &self,
891 journal: RemovalJournal,
892 ) -> Result<RemoveFromCarResult, ModelManagementError> {
893 let model_id = &journal.model_id;
894 if journal.receipt.artifact_kind == ManagedArtifactKind::Directory
895 && !directory_removal_supported()
896 {
897 return Err(ModelManagementError::UnsafeManagedPath {
898 model_id: model_id.clone(),
899 path: journal.receipt.managed_path,
900 reason: "recursive directory removal is unsupported without object-bound traversal"
901 .into(),
902 });
903 }
904 let original_exists = std::fs::symlink_metadata(&journal.receipt.managed_path).is_ok();
905 let quarantine_exists = std::fs::symlink_metadata(&journal.quarantine_path).is_ok();
906 let mut renamed_now = false;
907 if original_exists && quarantine_exists {
908 return Err(ModelManagementError::UnsafeManagedPath {
909 model_id: model_id.clone(),
910 path: journal.receipt.managed_path.clone(),
911 reason: "both managed artifact and removal quarantine exist".into(),
912 });
913 }
914 if original_exists {
915 self.validate_managed_artifact(&journal.receipt)?;
916 ensure_artifact_identity(
917 &journal.receipt.managed_path,
918 model_id,
919 &journal.artifact_identity,
920 )?;
921 validate_direct_child(&self.models_dir, &journal.receipt.managed_path, model_id)?;
924 ensure_artifact_identity(
925 &journal.receipt.managed_path,
926 model_id,
927 &journal.artifact_identity,
928 )?;
929 atomic_rename_noreplace(&journal.receipt.managed_path, &journal.quarantine_path)
930 .map_err(|source| ModelManagementError::Io {
931 path: journal.receipt.managed_path.clone(),
932 source,
933 })?;
934 sync_directory(&self.models_dir).map_err(|source| ModelManagementError::Io {
935 path: self.models_dir.clone(),
936 source,
937 })?;
938 renamed_now = true;
939 #[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
940 if let Some(hook) = &self.removal_hook {
941 (hook.0)(RemovalPhase::AfterRename, &journal.quarantine_path);
942 }
943 }
944
945 if std::fs::symlink_metadata(&journal.quarantine_path).is_err() && renamed_now {
946 return Err(ModelManagementError::UnsafeManagedPath {
951 model_id: model_id.clone(),
952 path: journal.quarantine_path,
953 reason: QUARANTINE_VANISHED_REASON.into(),
954 });
955 }
956
957 if std::fs::symlink_metadata(&journal.quarantine_path).is_ok() {
958 ensure_removal_identity(
961 &journal.quarantine_path,
962 model_id,
963 &journal.artifact_identity,
964 )?;
965 match journal.receipt.artifact_kind {
966 #[cfg(any(target_os = "macos", target_os = "linux"))]
967 ManagedArtifactKind::Directory => {
968 self.remove_quarantined_directory(
969 &journal.quarantine_path,
970 &journal.artifact_identity,
971 model_id,
972 )?;
973 }
974 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
975 ManagedArtifactKind::Directory => {
976 return Err(ModelManagementError::UnsafeManagedPath {
977 model_id: model_id.clone(),
978 path: journal.quarantine_path,
979 reason:
980 "recursive directory removal is unsupported without object-bound traversal"
981 .into(),
982 });
983 }
984 ManagedArtifactKind::Symlink | ManagedArtifactKind::File => {
985 std::fs::remove_file(&journal.quarantine_path).map_err(|source| {
986 ModelManagementError::Io {
987 path: journal.quarantine_path.clone(),
988 source,
989 }
990 })?;
991 }
992 }
993 sync_directory(&self.models_dir).map_err(|source| ModelManagementError::Io {
994 path: self.models_dir.clone(),
995 source,
996 })?;
997 }
998
999 let receipt_path = self.receipt_path(model_id);
1000 match std::fs::remove_file(&receipt_path) {
1001 Ok(()) => {
1002 sync_directory(&self.receipts_root).map_err(|source| ModelManagementError::Io {
1003 path: self.receipts_root.clone(),
1004 source,
1005 })?
1006 }
1007 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1008 Err(source) => {
1009 return Err(ModelManagementError::Io {
1010 path: receipt_path,
1011 source,
1012 });
1013 }
1014 }
1015 let journal_path = self.removal_journal_path(model_id);
1016 match std::fs::remove_file(&journal_path) {
1017 Ok(()) => {
1018 sync_directory(&self.removals_root).map_err(|source| ModelManagementError::Io {
1019 path: self.removals_root.clone(),
1020 source,
1021 })?
1022 }
1023 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1024 Err(source) => {
1025 return Err(ModelManagementError::Io {
1026 path: journal_path,
1027 source,
1028 });
1029 }
1030 }
1031 Ok(RemoveFromCarResult {
1032 model_id: model_id.clone(),
1033 artifact_kind: journal.receipt.artifact_kind,
1034 preserved_shared_cache_references: journal.receipt.shared_cache_references,
1035 })
1036 }
1037
1038 fn resume_pending_quarantines(&self) {
1039 let Ok(entries) = std::fs::read_dir(&self.removals_root) else {
1040 return;
1041 };
1042 for entry in entries.flatten() {
1043 if !is_hashed_state_filename(&entry.file_name()) {
1044 continue;
1045 }
1046 let Ok(bytes) = read_private_state(&entry.path()) else {
1047 continue;
1048 };
1049 let Ok(candidate) = serde_json::from_slice::<RemovalJournal>(&bytes) else {
1050 continue;
1051 };
1052 if entry.path() != self.removal_journal_path(&candidate.model_id) {
1053 continue;
1054 }
1055 let Ok(guard) = self.begin_mutation(&candidate.model_id) else {
1056 continue;
1057 };
1058 let Ok(Some(journal)) = self.load_removal_journal(&candidate.model_id) else {
1059 continue;
1060 };
1061 if std::fs::symlink_metadata(&journal.receipt.managed_path).is_ok() {
1062 continue;
1063 }
1064 let _ = guard.resume_existing(journal);
1065 }
1066 }
1067
1068 fn open_identity_lock(
1069 &self,
1070 path: &Path,
1071 model_id: &str,
1072 ) -> Result<File, ModelManagementError> {
1073 let parent = path.parent().expect("lock path has parent");
1074 create_private_dir(parent)?;
1075 let initialization_path = parent.join(".initialization.lock");
1080 let initialization = match open_private_new(&initialization_path) {
1081 Ok(file) => file,
1082 Err(ModelManagementError::Io { source, .. })
1083 if source.kind() == std::io::ErrorKind::AlreadyExists =>
1084 {
1085 open_existing_identity_lock(&initialization_path)?
1086 }
1087 Err(error) => return Err(error),
1088 };
1089 initialization
1090 .lock()
1091 .map_err(|source| ModelManagementError::Io {
1092 path: initialization_path,
1093 source,
1094 })?;
1095 let _initialization = OwnedFileLock::new(initialization);
1096 match open_private_new(path) {
1099 Ok(file) => {
1100 #[cfg(test)]
1101 if let Some(hook) = self.identity_init_hook {
1102 hook(path);
1103 }
1104 file.lock().map_err(|source| ModelManagementError::Io {
1105 path: path.to_path_buf(),
1106 source,
1107 })?;
1108 let mut file = OwnedFileLock::new(file);
1109 let bytes = serde_json::to_vec(&IdentityRecord {
1110 model_id: model_id.to_string(),
1111 })
1112 .map_err(|error| ModelManagementError::InvalidState {
1113 path: path.to_path_buf(),
1114 message: error.to_string(),
1115 })?;
1116 file.write_all(&bytes)
1117 .and_then(|_| file.sync_all())
1118 .map_err(|source| ModelManagementError::Io {
1119 path: path.to_path_buf(),
1120 source,
1121 })?;
1122 return file
1123 .into_unlocked_file()
1124 .map_err(|source| ModelManagementError::Io {
1125 path: path.to_path_buf(),
1126 source,
1127 });
1128 }
1129 Err(ModelManagementError::Io { source, .. })
1130 if source.kind() == std::io::ErrorKind::AlreadyExists => {}
1131 Err(error) => return Err(error),
1132 }
1133 open_existing_identity_lock(path)
1134 }
1135
1136 fn validate_managed_artifact(
1137 &self,
1138 receipt: &InstallReceipt,
1139 ) -> Result<(), ModelManagementError> {
1140 let models_root = validate_models_root(&self.models_dir, &receipt.model_id)?;
1141 if let Some(hf_root) = shared_hugging_face_root() {
1142 let hf_root = crate::resource_policy::normalized_state_root_key(&hf_root);
1143 if models_root.starts_with(&hf_root) || hf_root.starts_with(&models_root) {
1144 return Err(ModelManagementError::UnsafeManagedPath {
1145 model_id: receipt.model_id.clone(),
1146 path: receipt.managed_path.clone(),
1147 reason: "managed models root overlaps the shared Hugging Face cache".into(),
1148 });
1149 }
1150 }
1151 let parent = receipt.managed_path.parent().ok_or_else(|| {
1152 ModelManagementError::UnsafeManagedPath {
1153 model_id: receipt.model_id.clone(),
1154 path: receipt.managed_path.clone(),
1155 reason: "managed path has no parent".into(),
1156 }
1157 })?;
1158 let canonical_parent = canonical_directory(parent)?;
1159 if canonical_parent != models_root || receipt.managed_path == models_root {
1160 return Err(ModelManagementError::UnsafeManagedPath {
1161 model_id: receipt.model_id.clone(),
1162 path: receipt.managed_path.clone(),
1163 reason: "artifact is not a direct child of the managed models root".into(),
1164 });
1165 }
1166 let actual = artifact_kind(&receipt.managed_path, &receipt.model_id)?;
1167 if actual != receipt.artifact_kind {
1168 return Err(ModelManagementError::UnsafeManagedPath {
1169 model_id: receipt.model_id.clone(),
1170 path: receipt.managed_path.clone(),
1171 reason: format!(
1172 "receipt says {:?}, filesystem is {:?}",
1173 receipt.artifact_kind, actual
1174 ),
1175 });
1176 }
1177 if actual == ManagedArtifactKind::Symlink {
1178 let target =
1179 receipt
1180 .managed_path
1181 .canonicalize()
1182 .map_err(|source| ModelManagementError::Io {
1183 path: receipt.managed_path.clone(),
1184 source,
1185 })?;
1186 if !receipt
1187 .shared_cache_references
1188 .iter()
1189 .any(|path| path == &target)
1190 {
1191 return Err(ModelManagementError::UnsafeManagedPath {
1192 model_id: receipt.model_id.clone(),
1193 path: receipt.managed_path.clone(),
1194 reason: "symlink target does not match a preserved shared-cache reference"
1195 .into(),
1196 });
1197 }
1198 } else {
1199 let current_references =
1200 collect_shared_references(&receipt.managed_path, &receipt.model_id)?;
1201 if current_references != receipt.shared_cache_references {
1202 return Err(ModelManagementError::UnsafeManagedPath {
1203 model_id: receipt.model_id.clone(),
1204 path: receipt.managed_path.clone(),
1205 reason: "shared-cache references no longer match the install receipt".into(),
1206 });
1207 }
1208 }
1209 Ok(())
1210 }
1211}
1212
1213struct OwnedFileLock(Option<File>);
1217
1218impl OwnedFileLock {
1219 fn new(file: File) -> Self {
1220 Self(Some(file))
1221 }
1222
1223 fn into_unlocked_file(mut self) -> std::io::Result<File> {
1224 self.unlock()?;
1225 Ok(self.0.take().expect("owned lock has a file"))
1226 }
1227}
1228
1229impl std::ops::Deref for OwnedFileLock {
1230 type Target = File;
1231
1232 fn deref(&self) -> &File {
1233 self.0.as_ref().expect("owned lock has a file")
1234 }
1235}
1236
1237impl std::ops::DerefMut for OwnedFileLock {
1238 fn deref_mut(&mut self) -> &mut File {
1239 self.0.as_mut().expect("owned lock has a file")
1240 }
1241}
1242
1243impl Drop for OwnedFileLock {
1244 fn drop(&mut self) {
1245 if let Some(file) = &self.0 {
1246 let _ = file.unlock();
1247 }
1248 }
1249}
1250
1251pub(crate) struct ModelMutationGuard {
1252 store: ModelManagementStore,
1253 model_id: String,
1254 _file: OwnedFileLock,
1255}
1256
1257impl ModelMutationGuard {
1258 pub(crate) fn remove(
1259 self,
1260 removal_generation: u64,
1261 ) -> Result<RemoveFromCarResult, ModelManagementError> {
1262 self.store
1263 .remove_with_mutation(&self.model_id, removal_generation)
1264 }
1265
1266 fn resume_existing(
1267 self,
1268 journal: RemovalJournal,
1269 ) -> Result<RemoveFromCarResult, ModelManagementError> {
1270 if journal.model_id != self.model_id {
1271 return Err(ModelManagementError::IdentityMismatch {
1272 path: self.store.removal_journal_path(&self.model_id),
1273 expected: self.model_id,
1274 actual: journal.model_id,
1275 });
1276 }
1277 let _activity = self.store.lock_idle_activity(&self.model_id)?;
1278 write_private_json(
1279 &self.store.tombstone_path(&self.model_id),
1280 &RemovalTombstone {
1281 model_id: self.model_id.clone(),
1282 removal_generation: journal.removal_generation,
1283 artifact_kind: Some(journal.receipt.artifact_kind),
1284 preserved_shared_cache_references: journal.receipt.shared_cache_references.clone(),
1285 },
1286 )?;
1287 self.store.resume_removal_journal(journal)
1288 }
1289}
1290
1291pub struct ModelLease {
1292 _file: OwnedFileLock,
1293}
1294
1295#[derive(Debug, Clone, PartialEq, Eq)]
1296pub struct RemoveFromCarResult {
1297 pub model_id: String,
1298 pub artifact_kind: ManagedArtifactKind,
1299 pub preserved_shared_cache_references: Vec<PathBuf>,
1300}
1301
1302#[derive(Debug, Serialize, Deserialize)]
1303#[serde(deny_unknown_fields)]
1304struct IdentityRecord {
1305 model_id: String,
1306}
1307
1308#[derive(Debug, Serialize, Deserialize)]
1309#[serde(deny_unknown_fields)]
1310struct RemovalTombstone {
1311 model_id: String,
1312 removal_generation: u64,
1313 #[serde(default)]
1314 artifact_kind: Option<ManagedArtifactKind>,
1315 #[serde(default)]
1316 preserved_shared_cache_references: Vec<PathBuf>,
1317}
1318
1319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1320#[serde(deny_unknown_fields)]
1321struct ArtifactIdentity {
1322 kind: ManagedArtifactKind,
1323 #[serde(default)]
1324 device: u64,
1325 #[serde(default)]
1326 inode: u64,
1327 #[serde(default)]
1328 file_len: u64,
1329}
1330
1331#[derive(Debug, Clone, Serialize, Deserialize)]
1332#[serde(deny_unknown_fields)]
1333struct RemovalJournal {
1334 model_id: String,
1335 removal_generation: u64,
1336 receipt: InstallReceipt,
1337 quarantine_path: PathBuf,
1338 artifact_identity: ArtifactIdentity,
1339}
1340
1341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1342#[serde(deny_unknown_fields)]
1343struct InstallJournal {
1344 model_id: String,
1345 receipt: InstallReceipt,
1346 #[serde(default)]
1347 staging_path: Option<PathBuf>,
1348 #[serde(default)]
1349 staging_identity: Option<ArtifactIdentity>,
1350}
1351
1352fn hashed_filename(model_id: &str) -> String {
1353 let digest = Sha256::digest(model_id.as_bytes());
1354 format!("{}.json", hex::encode(digest))
1355}
1356
1357fn is_hashed_state_filename(name: &std::ffi::OsStr) -> bool {
1358 let Some(name) = name.to_str() else {
1359 return false;
1360 };
1361 let Some(hex) = name.strip_suffix(".json") else {
1362 return false;
1363 };
1364 hex.len() == 64
1365 && hex
1366 .bytes()
1367 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1368}
1369
1370fn validate_model_id(model_id: &str) -> Result<(), ModelManagementError> {
1371 if !model_id.is_empty() && model_id.len() <= MAX_MODEL_ID_BYTES {
1372 return Ok(());
1373 }
1374 Err(ModelManagementError::InvalidState {
1375 path: PathBuf::new(),
1376 message: "model id must contain 1..=4096 UTF-8 bytes".into(),
1377 })
1378}
1379
1380fn validate_managed_leaf(
1381 managed_leaf: &str,
1382 models_dir: &Path,
1383) -> Result<(), ModelManagementError> {
1384 if Path::new(managed_leaf).components().count() == 1
1385 && matches!(
1386 Path::new(managed_leaf).components().next(),
1387 Some(std::path::Component::Normal(_))
1388 )
1389 {
1390 return Ok(());
1391 }
1392 Err(ModelManagementError::UnsafeManagedPath {
1393 model_id: managed_leaf.to_string(),
1394 path: models_dir.join(managed_leaf),
1395 reason: "managed model name must be one ordinary path component".into(),
1396 })
1397}
1398
1399fn validate_direct_child(
1400 root: &Path,
1401 candidate: &Path,
1402 model_id: &str,
1403) -> Result<(), ModelManagementError> {
1404 let canonical_root = validate_models_root(root, model_id)?;
1405 let parent = candidate
1406 .parent()
1407 .ok_or_else(|| ModelManagementError::UnsafeManagedPath {
1408 model_id: model_id.to_string(),
1409 path: candidate.to_path_buf(),
1410 reason: "managed path has no parent".into(),
1411 })?;
1412 if canonical_directory(parent)? != canonical_root {
1413 return Err(ModelManagementError::UnsafeManagedPath {
1414 model_id: model_id.to_string(),
1415 path: candidate.to_path_buf(),
1416 reason: "managed path is not a direct child of the models root".into(),
1417 });
1418 }
1419 Ok(())
1420}
1421
1422fn validate_models_root(root: &Path, model_id: &str) -> Result<PathBuf, ModelManagementError> {
1423 let metadata = std::fs::symlink_metadata(root).map_err(|source| ModelManagementError::Io {
1424 path: root.to_path_buf(),
1425 source,
1426 })?;
1427 if metadata.file_type().is_symlink() {
1428 return Err(ModelManagementError::UnsafeManagedPath {
1429 model_id: model_id.to_string(),
1430 path: root.to_path_buf(),
1431 reason: "managed models root must not be a symlink".into(),
1432 });
1433 }
1434 #[cfg(windows)]
1435 {
1436 use std::os::windows::fs::MetadataExt;
1437 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
1438 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1439 return Err(ModelManagementError::UnsafeManagedPath {
1440 model_id: model_id.to_string(),
1441 path: root.to_path_buf(),
1442 reason: "managed models root must not be a reparse point".into(),
1443 });
1444 }
1445 }
1446 if !metadata.is_dir() {
1447 return Err(ModelManagementError::UnsafeManagedPath {
1448 model_id: model_id.to_string(),
1449 path: root.to_path_buf(),
1450 reason: "managed models root is not a directory".into(),
1451 });
1452 }
1453 canonical_directory(root)
1454}
1455
1456fn ensure_identity(path: &Path, expected: &str, actual: &str) -> Result<(), ModelManagementError> {
1457 if expected == actual {
1458 return Ok(());
1459 }
1460 Err(ModelManagementError::IdentityMismatch {
1461 path: path.to_path_buf(),
1462 expected: expected.to_string(),
1463 actual: actual.to_string(),
1464 })
1465}
1466
1467fn validate_lock_identity(
1468 file: &mut File,
1469 path: &Path,
1470 model_id: &str,
1471) -> Result<(), ModelManagementError> {
1472 file.rewind().map_err(|source| ModelManagementError::Io {
1473 path: path.to_path_buf(),
1474 source,
1475 })?;
1476 let mut bytes = Vec::new();
1477 file.take(16 * 1024)
1478 .read_to_end(&mut bytes)
1479 .map_err(|source| ModelManagementError::Io {
1480 path: path.to_path_buf(),
1481 source,
1482 })?;
1483 let record: IdentityRecord =
1484 serde_json::from_slice(&bytes).map_err(|error| ModelManagementError::InvalidState {
1485 path: path.to_path_buf(),
1486 message: error.to_string(),
1487 })?;
1488 ensure_identity(path, model_id, &record.model_id)
1489}
1490
1491fn canonical_directory(path: &Path) -> Result<PathBuf, ModelManagementError> {
1492 path.canonicalize()
1493 .map_err(|source| ModelManagementError::Io {
1494 path: path.to_path_buf(),
1495 source,
1496 })
1497}
1498
1499fn shared_hugging_face_root() -> Option<PathBuf> {
1500 std::env::var_os("HF_HOME").map(PathBuf::from).or_else(|| {
1501 std::env::var_os("HOME")
1502 .or_else(|| std::env::var_os("USERPROFILE"))
1503 .map(|home| PathBuf::from(home).join(".cache/huggingface"))
1504 })
1505}
1506
1507fn artifact_identity(
1508 path: &Path,
1509 model_id: &str,
1510) -> Result<ArtifactIdentity, ModelManagementError> {
1511 let metadata = std::fs::symlink_metadata(path).map_err(|source| ModelManagementError::Io {
1512 path: path.to_path_buf(),
1513 source,
1514 })?;
1515 reject_windows_reparse(&metadata, path, model_id)?;
1516 let kind = if metadata.file_type().is_symlink() {
1517 ManagedArtifactKind::Symlink
1518 } else if metadata.is_dir() {
1519 ManagedArtifactKind::Directory
1520 } else if metadata.is_file() {
1521 ManagedArtifactKind::File
1522 } else {
1523 return Err(ModelManagementError::UnsafeManagedPath {
1524 model_id: model_id.to_string(),
1525 path: path.to_path_buf(),
1526 reason: "unsupported artifact type".into(),
1527 });
1528 };
1529 #[cfg(unix)]
1530 {
1531 use std::os::unix::fs::MetadataExt;
1532 Ok(ArtifactIdentity {
1533 kind,
1534 device: metadata.dev(),
1535 inode: metadata.ino(),
1536 file_len: metadata.len(),
1537 })
1538 }
1539 #[cfg(not(unix))]
1540 {
1541 Ok(ArtifactIdentity {
1542 kind,
1543 device: 0,
1544 inode: 0,
1545 file_len: metadata.len(),
1546 })
1547 }
1548}
1549
1550fn ensure_artifact_identity(
1551 path: &Path,
1552 model_id: &str,
1553 expected: &ArtifactIdentity,
1554) -> Result<(), ModelManagementError> {
1555 let actual = artifact_identity(path, model_id)?;
1556 if &actual == expected {
1557 return Ok(());
1558 }
1559 Err(ModelManagementError::UnsafeManagedPath {
1560 model_id: model_id.to_string(),
1561 path: path.to_path_buf(),
1562 reason: "managed artifact identity changed during removal".into(),
1563 })
1564}
1565
1566impl ArtifactIdentity {
1567 fn matches_for_removal(&self, actual: &ArtifactIdentity) -> bool {
1572 self.kind == actual.kind
1573 && self.device == actual.device
1574 && self.inode == actual.inode
1575 && (self.kind == ManagedArtifactKind::Directory || self.file_len == actual.file_len)
1576 }
1577}
1578
1579fn ensure_removal_identity(
1580 path: &Path,
1581 model_id: &str,
1582 expected: &ArtifactIdentity,
1583) -> Result<(), ModelManagementError> {
1584 let actual = artifact_identity(path, model_id)?;
1585 if expected.matches_for_removal(&actual) {
1586 return Ok(());
1587 }
1588 Err(ModelManagementError::UnsafeManagedPath {
1589 model_id: model_id.to_string(),
1590 path: path.to_path_buf(),
1591 reason: "managed artifact identity changed during removal".into(),
1592 })
1593}
1594
1595fn artifact_kind(path: &Path, model_id: &str) -> Result<ManagedArtifactKind, ModelManagementError> {
1596 let metadata = std::fs::symlink_metadata(path).map_err(|source| ModelManagementError::Io {
1597 path: path.to_path_buf(),
1598 source,
1599 })?;
1600 reject_windows_reparse(&metadata, path, model_id)?;
1601 if metadata.file_type().is_symlink() {
1602 Ok(ManagedArtifactKind::Symlink)
1603 } else if metadata.is_dir() {
1604 Ok(ManagedArtifactKind::Directory)
1605 } else if metadata.is_file() {
1606 Ok(ManagedArtifactKind::File)
1607 } else {
1608 Err(ModelManagementError::UnsafeManagedPath {
1609 model_id: model_id.to_string(),
1610 path: path.to_path_buf(),
1611 reason: "unsupported artifact type".into(),
1612 })
1613 }
1614}
1615
1616fn collect_shared_references(
1617 path: &Path,
1618 model_id: &str,
1619) -> Result<Vec<PathBuf>, ModelManagementError> {
1620 let metadata = std::fs::symlink_metadata(path).map_err(|source| ModelManagementError::Io {
1621 path: path.to_path_buf(),
1622 source,
1623 })?;
1624 if metadata.file_type().is_symlink() {
1625 return path
1626 .canonicalize()
1627 .map(|target| vec![target])
1628 .map_err(|source| ModelManagementError::Io {
1629 path: path.to_path_buf(),
1630 source,
1631 });
1632 }
1633 if !metadata.is_dir() {
1634 return Ok(Vec::new());
1635 }
1636 let mut references = Vec::new();
1637 for entry in std::fs::read_dir(path).map_err(|source| ModelManagementError::Io {
1638 path: path.to_path_buf(),
1639 source,
1640 })? {
1641 let entry = entry.map_err(|source| ModelManagementError::Io {
1642 path: path.to_path_buf(),
1643 source,
1644 })?;
1645 let entry_path = entry.path();
1646 let entry_metadata =
1647 std::fs::symlink_metadata(&entry_path).map_err(|source| ModelManagementError::Io {
1648 path: entry_path.clone(),
1649 source,
1650 })?;
1651 if entry_metadata.file_type().is_symlink() {
1652 let target = entry_path
1653 .canonicalize()
1654 .map_err(|source| ModelManagementError::Io {
1655 path: entry_path.clone(),
1656 source,
1657 })?;
1658 if std::fs::metadata(&target)
1659 .map_err(|source| ModelManagementError::Io {
1660 path: target.clone(),
1661 source,
1662 })?
1663 .is_dir()
1664 {
1665 return Err(ModelManagementError::UnsafeManagedPath {
1666 model_id: model_id.to_string(),
1667 path: entry_path,
1668 reason: "managed directory contains a directory symlink".into(),
1669 });
1670 }
1671 references.push(target);
1672 } else if entry_metadata.is_dir() {
1673 references.extend(collect_shared_references(&entry_path, model_id)?);
1674 }
1675 }
1676 references.sort();
1677 references.dedup();
1678 Ok(references)
1679}
1680
1681#[cfg(unix)]
1682fn create_symlink(target: &Path, link: &Path) -> Result<(), ModelManagementError> {
1683 std::os::unix::fs::symlink(target, link).map_err(|source| ModelManagementError::Io {
1684 path: link.to_path_buf(),
1685 source,
1686 })
1687}
1688
1689#[cfg(windows)]
1690fn create_symlink(target: &Path, link: &Path) -> Result<(), ModelManagementError> {
1691 let result = if target.is_dir() {
1692 std::os::windows::fs::symlink_dir(target, link)
1693 } else {
1694 std::os::windows::fs::symlink_file(target, link)
1695 };
1696 result.map_err(|source| ModelManagementError::Io {
1697 path: link.to_path_buf(),
1698 source,
1699 })
1700}
1701
1702fn create_private_dir(path: &Path) -> Result<(), ModelManagementError> {
1703 std::fs::create_dir_all(path).map_err(|source| ModelManagementError::Io {
1704 path: path.to_path_buf(),
1705 source,
1706 })?;
1707 harden_private_directory(path)?;
1708 Ok(())
1709}
1710
1711fn harden_private_directory(path: &Path) -> Result<(), ModelManagementError> {
1712 let metadata = std::fs::symlink_metadata(path).map_err(|source| ModelManagementError::Io {
1713 path: path.to_path_buf(),
1714 source,
1715 })?;
1716 if metadata.file_type().is_symlink() {
1717 return Err(ModelManagementError::UnsafeManagedPath {
1718 model_id: "model-management-state".into(),
1719 path: path.to_path_buf(),
1720 reason: "private model-management directory must not be a symlink".into(),
1721 });
1722 }
1723 reject_windows_reparse(&metadata, path, "model-management-state")?;
1724 #[cfg(unix)]
1725 {
1726 use std::os::unix::fs::PermissionsExt;
1727 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(
1728 |source| ModelManagementError::Io {
1729 path: path.to_path_buf(),
1730 source,
1731 },
1732 )?;
1733 }
1734 Ok(())
1735}
1736
1737fn reject_windows_reparse(
1738 metadata: &std::fs::Metadata,
1739 path: &Path,
1740 model_id: &str,
1741) -> Result<(), ModelManagementError> {
1742 #[cfg(windows)]
1743 {
1744 use std::os::windows::fs::MetadataExt;
1745 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
1746 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1747 return Err(ModelManagementError::UnsafeManagedPath {
1748 model_id: model_id.to_string(),
1749 path: path.to_path_buf(),
1750 reason: "model-management path is a Windows reparse point".into(),
1751 });
1752 }
1753 }
1754 #[cfg(not(windows))]
1755 let _ = (metadata, path, model_id);
1756 Ok(())
1757}
1758
1759fn open_private_new(path: &Path) -> Result<File, ModelManagementError> {
1760 let mut options = OpenOptions::new();
1761 options.read(true).write(true).create_new(true);
1762 #[cfg(unix)]
1763 {
1764 use std::os::unix::fs::OpenOptionsExt;
1765 options.mode(0o600);
1766 }
1767 options
1768 .open(path)
1769 .map_err(|source| ModelManagementError::Io {
1770 path: path.to_path_buf(),
1771 source,
1772 })
1773}
1774
1775fn open_existing_identity_lock(path: &Path) -> Result<File, ModelManagementError> {
1776 let metadata = std::fs::symlink_metadata(path).map_err(|source| ModelManagementError::Io {
1777 path: path.to_path_buf(),
1778 source,
1779 })?;
1780 if metadata.file_type().is_symlink() {
1781 return Err(ModelManagementError::InvalidState {
1782 path: path.to_path_buf(),
1783 message: "model-management lock sentinel must not be a symlink".into(),
1784 });
1785 }
1786 reject_windows_reparse(&metadata, path, "model-management-lock")?;
1787 #[cfg(unix)]
1788 {
1789 use std::os::unix::fs::MetadataExt;
1790 if metadata.nlink() != 1 {
1791 return Err(ModelManagementError::InvalidState {
1792 path: path.to_path_buf(),
1793 message: "model-management lock sentinel must not be hard-linked".into(),
1794 });
1795 }
1796 }
1797 let mut options = OpenOptions::new();
1798 options.read(true).write(true);
1799 #[cfg(unix)]
1800 {
1801 use std::os::unix::fs::OpenOptionsExt;
1802 options.custom_flags(libc::O_NOFOLLOW);
1803 }
1804 #[cfg(windows)]
1805 {
1806 use std::os::windows::fs::OpenOptionsExt;
1807 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
1808 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1809 }
1810 let file = options
1811 .open(path)
1812 .map_err(|source| ModelManagementError::Io {
1813 path: path.to_path_buf(),
1814 source,
1815 })?;
1816 let opened_metadata = file.metadata().map_err(|source| ModelManagementError::Io {
1817 path: path.to_path_buf(),
1818 source,
1819 })?;
1820 if !opened_metadata.is_file() {
1821 return Err(ModelManagementError::InvalidState {
1822 path: path.to_path_buf(),
1823 message: "model-management lock sentinel is not a regular file".into(),
1824 });
1825 }
1826 reject_windows_reparse(&opened_metadata, path, "model-management-lock")?;
1827 #[cfg(unix)]
1828 {
1829 use std::os::unix::fs::MetadataExt;
1830 if opened_metadata.nlink() != 1 {
1831 return Err(ModelManagementError::InvalidState {
1832 path: path.to_path_buf(),
1833 message: "opened model-management lock sentinel is hard-linked".into(),
1834 });
1835 }
1836 }
1837 Ok(file)
1838}
1839
1840fn read_private_state(path: &Path) -> std::io::Result<Vec<u8>> {
1841 let mut options = OpenOptions::new();
1842 options.read(true);
1843 #[cfg(unix)]
1844 {
1845 use std::os::unix::fs::OpenOptionsExt;
1846 options.custom_flags(libc::O_NOFOLLOW);
1847 }
1848 let file = options.open(path)?;
1849 let metadata = file.metadata()?;
1850 if !metadata.is_file() || metadata.len() > MAX_STATE_BYTES {
1851 return Err(std::io::Error::new(
1852 std::io::ErrorKind::InvalidData,
1853 "model-management state must be a bounded regular file",
1854 ));
1855 }
1856 #[cfg(unix)]
1857 {
1858 use std::os::unix::fs::MetadataExt;
1859 if metadata.nlink() != 1 {
1860 return Err(std::io::Error::new(
1861 std::io::ErrorKind::InvalidData,
1862 "model-management state must not be hard-linked",
1863 ));
1864 }
1865 }
1866 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1867 file.take(MAX_STATE_BYTES + 1).read_to_end(&mut bytes)?;
1868 if bytes.len() as u64 > MAX_STATE_BYTES {
1869 return Err(std::io::Error::new(
1870 std::io::ErrorKind::InvalidData,
1871 "model-management state exceeds the size limit",
1872 ));
1873 }
1874 Ok(bytes)
1875}
1876
1877fn write_private_json<T: Serialize>(path: &Path, value: &T) -> Result<(), ModelManagementError> {
1878 let _guard = state_mutation_lock()
1879 .lock()
1880 .unwrap_or_else(std::sync::PoisonError::into_inner);
1881 let parent = path
1882 .parent()
1883 .expect("model-management record path has a parent");
1884 create_private_dir(parent)?;
1885 let tmp = (0..16)
1886 .map(|_| {
1887 parent.join(format!(
1888 ".{}.{}-{:032x}.tmp",
1889 std::process::id(),
1890 path.file_name()
1891 .and_then(|name| name.to_str())
1892 .unwrap_or("state"),
1893 rand::random::<u128>()
1894 ))
1895 })
1896 .find(|candidate| {
1897 std::fs::symlink_metadata(candidate)
1898 .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound)
1899 })
1900 .ok_or_else(|| ModelManagementError::InvalidState {
1901 path: parent.to_path_buf(),
1902 message: "could not allocate collision-resistant state staging file".into(),
1903 })?;
1904 let bytes =
1905 serde_json::to_vec_pretty(value).map_err(|error| ModelManagementError::InvalidState {
1906 path: path.to_path_buf(),
1907 message: error.to_string(),
1908 })?;
1909 let result = (|| {
1910 let mut file = open_private_new(&tmp)?;
1911 file.write_all(&bytes)
1912 .and_then(|_| file.sync_all())
1913 .map_err(|source| ModelManagementError::Io {
1914 path: tmp.clone(),
1915 source,
1916 })?;
1917 atomic_replace(&tmp, path).map_err(|source| ModelManagementError::Io {
1918 path: path.to_path_buf(),
1919 source,
1920 })?;
1921 car_secrets::harden_owner_only(path);
1922 sync_directory(parent).map_err(|source| ModelManagementError::Io {
1923 path: parent.to_path_buf(),
1924 source,
1925 })
1926 })();
1927 if result.is_err() {
1928 let _ = std::fs::remove_file(&tmp);
1929 }
1930 result
1931}
1932
1933fn state_mutation_lock() -> &'static Mutex<()> {
1934 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1935 LOCK.get_or_init(|| Mutex::new(()))
1936}
1937
1938#[cfg(not(windows))]
1939fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
1940 std::fs::rename(source, destination)
1941}
1942
1943#[cfg(target_os = "macos")]
1944fn atomic_rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> {
1945 use std::ffi::CString;
1946 use std::os::unix::ffi::OsStrExt;
1947
1948 let source = CString::new(source.as_os_str().as_bytes())
1949 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
1950 let destination = CString::new(destination.as_os_str().as_bytes())
1951 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
1952 let result =
1954 unsafe { libc::renamex_np(source.as_ptr(), destination.as_ptr(), libc::RENAME_EXCL) };
1955 if result == 0 {
1956 Ok(())
1957 } else {
1958 Err(std::io::Error::last_os_error())
1959 }
1960}
1961
1962#[cfg(target_os = "linux")]
1963fn atomic_rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> {
1964 use std::ffi::CString;
1965 use std::os::unix::ffi::OsStrExt;
1966
1967 let source = CString::new(source.as_os_str().as_bytes())
1968 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
1969 let destination = CString::new(destination.as_os_str().as_bytes())
1970 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
1971 let result = unsafe {
1973 libc::renameat2(
1974 libc::AT_FDCWD,
1975 source.as_ptr(),
1976 libc::AT_FDCWD,
1977 destination.as_ptr(),
1978 libc::RENAME_NOREPLACE,
1979 )
1980 };
1981 if result == 0 {
1982 Ok(())
1983 } else {
1984 Err(std::io::Error::last_os_error())
1985 }
1986}
1987
1988#[cfg(windows)]
1989fn atomic_rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> {
1990 use std::os::windows::ffi::OsStrExt;
1991
1992 const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
1993 #[link(name = "kernel32")]
1994 unsafe extern "system" {
1995 fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
1996 }
1997 let source = source
1998 .as_os_str()
1999 .encode_wide()
2000 .chain(Some(0))
2001 .collect::<Vec<_>>();
2002 let destination = destination
2003 .as_os_str()
2004 .encode_wide()
2005 .chain(Some(0))
2006 .collect::<Vec<_>>();
2007 let moved = unsafe {
2009 MoveFileExW(
2010 source.as_ptr(),
2011 destination.as_ptr(),
2012 MOVEFILE_WRITE_THROUGH,
2013 )
2014 };
2015 if moved == 0 {
2016 Err(std::io::Error::last_os_error())
2017 } else {
2018 Ok(())
2019 }
2020}
2021
2022#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
2023fn atomic_rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> {
2024 Err(std::io::Error::new(
2025 std::io::ErrorKind::Unsupported,
2026 "atomic no-replace publication is unavailable on this platform",
2027 ))
2028}
2029
2030#[cfg(windows)]
2031fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
2032 use std::os::windows::ffi::OsStrExt;
2033
2034 const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
2035 const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
2036 #[link(name = "kernel32")]
2037 unsafe extern "system" {
2038 fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
2039 }
2040 let source = source
2041 .as_os_str()
2042 .encode_wide()
2043 .chain(Some(0))
2044 .collect::<Vec<_>>();
2045 let destination = destination
2046 .as_os_str()
2047 .encode_wide()
2048 .chain(Some(0))
2049 .collect::<Vec<_>>();
2050 let replaced = unsafe {
2053 MoveFileExW(
2054 source.as_ptr(),
2055 destination.as_ptr(),
2056 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
2057 )
2058 };
2059 if replaced == 0 {
2060 Err(std::io::Error::last_os_error())
2061 } else {
2062 Ok(())
2063 }
2064}
2065
2066#[cfg(unix)]
2067fn sync_directory(path: &Path) -> std::io::Result<()> {
2068 File::open(path)?.sync_all()
2069}
2070
2071#[cfg(any(target_os = "macos", target_os = "linux"))]
2084mod object_bound {
2085 use std::ffi::{CStr, CString};
2086 use std::fs::File;
2087 use std::os::fd::{AsRawFd, FromRawFd};
2088 use std::os::unix::ffi::OsStrExt;
2089 use std::os::unix::fs::MetadataExt;
2090 use std::path::Path;
2091
2092 pub(super) const MAX_REMOVAL_DEPTH: usize = 32;
2093
2094 const DIRECTORY_FLAGS: libc::c_int =
2095 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW;
2096
2097 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2098 pub(super) struct Identity {
2099 pub(super) device: u64,
2100 pub(super) inode: u64,
2101 }
2102
2103 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2104 pub(super) enum EntryKind {
2105 Directory,
2106 RegularFile,
2107 Symlink,
2108 Other,
2109 }
2110
2111 pub(super) fn c_name(path: &Path) -> std::io::Result<CString> {
2112 CString::new(path.as_os_str().as_bytes())
2113 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))
2114 }
2115
2116 pub(super) fn open_directory(path: &Path) -> std::io::Result<File> {
2118 let name = c_name(path)?;
2119 let raw = unsafe { libc::open(name.as_ptr(), DIRECTORY_FLAGS) };
2121 if raw < 0 {
2122 return Err(std::io::Error::last_os_error());
2123 }
2124 Ok(unsafe { File::from_raw_fd(raw) })
2126 }
2127
2128 #[cfg(target_os = "macos")]
2132 pub(super) fn open_child_directory(parent: &File, name: &CStr) -> std::io::Result<File> {
2133 let raw = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), DIRECTORY_FLAGS) };
2135 if raw < 0 {
2136 return Err(std::io::Error::last_os_error());
2137 }
2138 Ok(unsafe { File::from_raw_fd(raw) })
2140 }
2141
2142 #[cfg(target_os = "linux")]
2146 pub(super) fn open_child_directory(parent: &File, name: &CStr) -> std::io::Result<File> {
2147 let mut how: libc::open_how = unsafe { std::mem::zeroed() };
2149 how.flags = DIRECTORY_FLAGS as u64;
2150 how.resolve = libc::RESOLVE_BENEATH | libc::RESOLVE_NO_SYMLINKS | libc::RESOLVE_NO_XDEV;
2151 let raw = unsafe {
2154 libc::syscall(
2155 libc::SYS_openat2,
2156 parent.as_raw_fd(),
2157 name.as_ptr(),
2158 &how as *const libc::open_how,
2159 std::mem::size_of::<libc::open_how>(),
2160 )
2161 };
2162 if raw < 0 {
2163 return Err(std::io::Error::last_os_error());
2164 }
2165 Ok(unsafe { File::from_raw_fd(raw as libc::c_int) })
2167 }
2168
2169 pub(super) fn same_filesystem(root: &Identity, child: &Identity) -> bool {
2173 root.device == child.device
2174 }
2175
2176 pub(super) fn is_not_a_directory(error: &std::io::Error) -> bool {
2177 matches!(
2178 error.raw_os_error(),
2179 Some(libc::ENOTDIR) | Some(libc::ELOOP)
2180 )
2181 }
2182
2183 pub(super) fn identity_of(file: &File) -> std::io::Result<Identity> {
2184 let metadata = file.metadata()?;
2185 Ok(Identity {
2186 device: metadata.dev(),
2187 inode: metadata.ino(),
2188 })
2189 }
2190
2191 #[allow(clippy::unnecessary_cast)]
2196 pub(super) fn stat_entry(parent: &File, name: &CStr) -> std::io::Result<(EntryKind, Identity)> {
2197 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
2199 let result = unsafe {
2202 libc::fstatat(
2203 parent.as_raw_fd(),
2204 name.as_ptr(),
2205 &mut stat,
2206 libc::AT_SYMLINK_NOFOLLOW,
2207 )
2208 };
2209 if result != 0 {
2210 return Err(std::io::Error::last_os_error());
2211 }
2212 let kind = match stat.st_mode & libc::S_IFMT {
2213 libc::S_IFDIR => EntryKind::Directory,
2214 libc::S_IFREG => EntryKind::RegularFile,
2215 libc::S_IFLNK => EntryKind::Symlink,
2216 _ => EntryKind::Other,
2217 };
2218 Ok((
2219 kind,
2220 Identity {
2221 device: stat.st_dev as u64,
2222 inode: stat.st_ino as u64,
2223 },
2224 ))
2225 }
2226
2227 pub(super) fn list_names(directory: &File) -> std::io::Result<Vec<CString>> {
2230 let dot = c".";
2231 let raw = unsafe { libc::openat(directory.as_raw_fd(), dot.as_ptr(), DIRECTORY_FLAGS) };
2233 if raw < 0 {
2234 return Err(std::io::Error::last_os_error());
2235 }
2236 let stream = unsafe { libc::fdopendir(raw) };
2238 if stream.is_null() {
2239 let error = std::io::Error::last_os_error();
2240 unsafe {
2242 libc::close(raw);
2243 }
2244 return Err(error);
2245 }
2246 let mut names = Vec::new();
2247 let enumeration = loop {
2248 set_errno(0);
2249 let entry = unsafe { libc::readdir(stream) };
2251 if entry.is_null() {
2252 let errno = errno();
2253 break if errno == 0 {
2254 Ok(())
2255 } else {
2256 Err(std::io::Error::from_raw_os_error(errno))
2257 };
2258 }
2259 let bytes = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
2261 if bytes != b"." && bytes != b".." {
2262 match CString::new(bytes) {
2263 Ok(name) => names.push(name),
2264 Err(error) => {
2265 break Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
2266 }
2267 }
2268 }
2269 };
2270 let closed = unsafe { libc::closedir(stream) };
2272 enumeration?;
2273 if closed < 0 {
2274 return Err(std::io::Error::last_os_error());
2275 }
2276 names.sort();
2277 Ok(names)
2278 }
2279
2280 pub(super) fn unlink_entry(parent: &File, name: &CStr) -> std::io::Result<()> {
2282 let result = unsafe { libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), 0) };
2284 if result == 0 {
2285 return Ok(());
2286 }
2287 let error = std::io::Error::last_os_error();
2288 if error.kind() == std::io::ErrorKind::NotFound {
2289 return Ok(());
2290 }
2291 Err(error)
2292 }
2293
2294 pub(super) fn remove_empty_directory(parent: &File, name: &CStr) -> std::io::Result<()> {
2297 let result =
2299 unsafe { libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), libc::AT_REMOVEDIR) };
2300 if result == 0 {
2301 return Ok(());
2302 }
2303 let error = std::io::Error::last_os_error();
2304 if error.kind() == std::io::ErrorKind::NotFound {
2305 return Ok(());
2306 }
2307 Err(error)
2308 }
2309
2310 pub(super) fn is_not_empty(error: &std::io::Error) -> bool {
2311 matches!(
2312 error.raw_os_error(),
2313 Some(libc::ENOTEMPTY) | Some(libc::EEXIST)
2314 )
2315 }
2316
2317 #[cfg(target_os = "macos")]
2318 fn errno() -> i32 {
2319 unsafe { *libc::__error() }
2321 }
2322
2323 #[cfg(target_os = "macos")]
2324 fn set_errno(value: i32) {
2325 unsafe {
2327 *libc::__error() = value;
2328 }
2329 }
2330
2331 #[cfg(target_os = "linux")]
2332 fn errno() -> i32 {
2333 unsafe { *libc::__errno_location() }
2335 }
2336
2337 #[cfg(target_os = "linux")]
2338 fn set_errno(value: i32) {
2339 unsafe {
2341 *libc::__errno_location() = value;
2342 }
2343 }
2344}
2345
2346#[cfg(any(target_os = "macos", target_os = "linux"))]
2347impl ModelManagementStore {
2348 fn remove_quarantined_directory(
2351 &self,
2352 quarantine: &Path,
2353 expected: &ArtifactIdentity,
2354 model_id: &str,
2355 ) -> Result<(), ModelManagementError> {
2356 use object_bound::{EntryKind, Identity, MAX_REMOVAL_DEPTH};
2357 use std::ffi::CString;
2358 use std::fs::File;
2359
2360 struct CapturedDirectory {
2361 file: File,
2362 name: CString,
2363 parent: usize,
2364 depth: usize,
2365 identity: Identity,
2366 }
2367 struct CapturedEntry {
2368 parent: usize,
2369 name: CString,
2370 identity: Identity,
2371 }
2372
2373 let unsafe_path = |reason: &str| ModelManagementError::UnsafeManagedPath {
2374 model_id: model_id.to_string(),
2375 path: quarantine.to_path_buf(),
2376 reason: reason.into(),
2377 };
2378 let io_error = |source: std::io::Error| ModelManagementError::Io {
2379 path: quarantine.to_path_buf(),
2380 source,
2381 };
2382
2383 #[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
2384 if let Some(hook) = &self.removal_hook {
2385 (hook.0)(RemovalPhase::BeforeRootOpen, quarantine);
2386 }
2387
2388 let root = match object_bound::open_directory(quarantine) {
2392 Ok(root) => root,
2393 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2394 return Err(unsafe_path(QUARANTINE_VANISHED_REASON));
2395 }
2396 Err(source) => return Err(io_error(source)),
2397 };
2398 let root_identity = object_bound::identity_of(&root).map_err(io_error)?;
2399 if root_identity.device != expected.device || root_identity.inode != expected.inode {
2400 return Err(unsafe_path(
2401 "quarantined directory identity changed before deletion",
2402 ));
2403 }
2404
2405 let mut directories = vec![CapturedDirectory {
2407 file: root,
2408 name: CString::default(),
2409 parent: 0,
2410 depth: 0,
2411 identity: root_identity,
2412 }];
2413 let mut entries: Vec<CapturedEntry> = Vec::new();
2414 let mut pending = vec![0usize];
2415 while let Some(index) = pending.pop() {
2416 let depth = directories[index].depth;
2417 if depth > MAX_REMOVAL_DEPTH {
2418 return Err(unsafe_path(
2419 "managed directory nesting exceeds the removal depth cap",
2420 ));
2421 }
2422 let names = object_bound::list_names(&directories[index].file).map_err(io_error)?;
2423 for name in names {
2424 match object_bound::open_child_directory(&directories[index].file, &name) {
2425 Ok(child) => {
2426 let identity = object_bound::identity_of(&child).map_err(io_error)?;
2427 if !object_bound::same_filesystem(&root_identity, &identity) {
2428 return Err(unsafe_path(
2429 "managed directory crosses a filesystem boundary",
2430 ));
2431 }
2432 directories.push(CapturedDirectory {
2433 file: child,
2434 name,
2435 parent: index,
2436 depth: depth + 1,
2437 identity,
2438 });
2439 pending.push(directories.len() - 1);
2440 }
2441 Err(error) if object_bound::is_not_a_directory(&error) => {
2442 let (kind, identity) =
2443 object_bound::stat_entry(&directories[index].file, &name)
2444 .map_err(io_error)?;
2445 match kind {
2446 EntryKind::RegularFile | EntryKind::Symlink => {
2447 entries.push(CapturedEntry {
2448 parent: index,
2449 name,
2450 identity,
2451 });
2452 }
2453 EntryKind::Directory | EntryKind::Other => {
2454 return Err(unsafe_path(
2455 "managed directory contains an entry CAR cannot classify",
2456 ));
2457 }
2458 }
2459 }
2460 Err(error) if error.raw_os_error() == Some(libc::EXDEV) => {
2461 return Err(unsafe_path(
2462 "managed directory crosses a filesystem boundary",
2463 ));
2464 }
2465 Err(error)
2466 if matches!(
2467 error.raw_os_error(),
2468 Some(libc::ENOSYS) | Some(libc::EINVAL) | Some(libc::E2BIG)
2469 ) =>
2470 {
2471 return Err(unsafe_path(
2472 "descriptor-bound directory open is unavailable on this kernel",
2473 ));
2474 }
2475 Err(source) => return Err(io_error(source)),
2476 }
2477 }
2478 }
2479
2480 #[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
2481 if let Some(hook) = &self.removal_hook {
2482 (hook.0)(RemovalPhase::AfterCapture, quarantine);
2483 }
2484
2485 for entry in &entries {
2488 let parent = &directories[entry.parent].file;
2489 match object_bound::stat_entry(parent, &entry.name) {
2490 Ok((_, identity)) if identity == entry.identity => {
2491 object_bound::unlink_entry(parent, &entry.name).map_err(io_error)?;
2492 }
2493 Ok(_) => {
2494 return Err(unsafe_path(
2495 "managed directory entry identity changed during deletion",
2496 ));
2497 }
2498 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2499 Err(source) => return Err(io_error(source)),
2500 }
2501 }
2502
2503 while directories.len() > 1 {
2506 let CapturedDirectory {
2507 file,
2508 name,
2509 parent,
2510 identity,
2511 ..
2512 } = directories.pop().expect("non-empty");
2513 drop(file);
2514 let parent = &directories[parent].file;
2515 match object_bound::stat_entry(parent, &name) {
2516 Ok((EntryKind::Directory, actual)) if actual == identity => {
2517 object_bound::remove_empty_directory(parent, &name).map_err(|error| {
2518 if object_bound::is_not_empty(&error) {
2519 unsafe_path("managed directory contains entries that were not captured")
2520 } else {
2521 io_error(error)
2522 }
2523 })?;
2524 }
2525 Ok(_) => {
2526 return Err(unsafe_path(
2527 "managed directory entry identity changed during deletion",
2528 ));
2529 }
2530 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2531 Err(source) => return Err(io_error(source)),
2532 }
2533 }
2534
2535 let CapturedDirectory { file: root, .. } = directories.pop().expect("root");
2538 drop(root);
2539 let models = object_bound::open_directory(&self.models_dir).map_err(|source| {
2540 ModelManagementError::Io {
2541 path: self.models_dir.clone(),
2542 source,
2543 }
2544 })?;
2545 let leaf = quarantine
2546 .file_name()
2547 .map(Path::new)
2548 .ok_or_else(|| unsafe_path("quarantine path has no file name"))?;
2549 let leaf = object_bound::c_name(leaf).map_err(io_error)?;
2550 match object_bound::stat_entry(&models, &leaf) {
2551 Ok((EntryKind::Directory, actual)) if actual == root_identity => {
2552 object_bound::remove_empty_directory(&models, &leaf).map_err(|error| {
2553 if object_bound::is_not_empty(&error) {
2554 unsafe_path("managed directory contains entries that were not captured")
2555 } else {
2556 io_error(error)
2557 }
2558 })
2559 }
2560 Ok(_) => Err(unsafe_path(
2561 "quarantined directory identity changed during deletion",
2562 )),
2563 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2564 Err(source) => Err(io_error(source)),
2565 }
2566 }
2567}
2568
2569#[cfg(not(unix))]
2574fn sync_directory(_path: &Path) -> std::io::Result<()> {
2575 Ok(())
2576}
2577
2578#[cfg(test)]
2579mod tests {
2580 use super::*;
2581
2582 #[test]
2584 fn identity_initialization_child() {
2585 use std::io::{BufRead, Write};
2586 let Some(root) = std::env::var_os("CAR_IDENTITY_TEST_ROOT") else {
2587 return;
2588 };
2589 let root = PathBuf::from(root);
2590 let mut store = ModelManagementStore::new(root.join("state"), root.join("models"));
2591 if std::env::var("CAR_IDENTITY_TEST_ROLE").as_deref() == Ok("creator") {
2592 store.identity_init_hook = Some(|path| {
2593 let phase = std::env::var("CAR_IDENTITY_TEST_PHASE").unwrap();
2594 if path.parent().unwrap().file_name().unwrap() == phase.as_str() {
2595 println!("IDENTITY_CREATED");
2596 std::io::stdout().flush().unwrap();
2597 let mut release = String::new();
2598 std::io::stdin().lock().read_line(&mut release).unwrap();
2599 assert_eq!(release.trim(), "release");
2600 }
2601 });
2602 } else {
2603 println!("READER_STARTED");
2604 std::io::stdout().flush().unwrap();
2605 }
2606 let _lease = store.acquire_lease("fixture/model").unwrap();
2607 }
2608
2609 #[test]
2610 fn identity_initialization_serializes_processes_before_json_is_written() {
2611 use std::io::{BufRead, BufReader, Write};
2612 use std::process::{Command, Stdio};
2613 fn wait_marker(reader: &mut impl BufRead, marker: &str) {
2614 let mut line = String::new();
2615 loop {
2616 line.clear();
2617 assert_ne!(
2618 reader.read_line(&mut line).unwrap(),
2619 0,
2620 "child exited before {marker}"
2621 );
2622 if line.contains(marker) {
2623 return;
2624 }
2625 }
2626 }
2627 for phase in ["mutation", "activity"] {
2628 let root = tempfile::tempdir().unwrap();
2629 let spawn = |role: &str| {
2630 Command::new(std::env::current_exe().unwrap())
2631 .args([
2632 "--exact",
2633 "model_management::tests::identity_initialization_child",
2634 "--nocapture",
2635 ])
2636 .env("CAR_IDENTITY_TEST_ROOT", root.path())
2637 .env("CAR_IDENTITY_TEST_ROLE", role)
2638 .env("CAR_IDENTITY_TEST_PHASE", phase)
2639 .stdin(Stdio::piped())
2640 .stdout(Stdio::piped())
2641 .stderr(Stdio::inherit())
2642 .spawn()
2643 .unwrap()
2644 };
2645 let mut creator = spawn("creator");
2646 let mut creator_out = BufReader::new(creator.stdout.take().unwrap());
2647 wait_marker(&mut creator_out, "IDENTITY_CREATED");
2648 let store =
2649 ModelManagementStore::new(root.path().join("state"), root.path().join("models"));
2650 let path = if phase == "mutation" {
2651 store.mutation_lock_path("fixture/model")
2652 } else {
2653 store.lease_path("fixture/model")
2654 };
2655 assert!(
2656 std::fs::read(&path).unwrap().is_empty(),
2657 "probe must hit the original partial-publication window"
2658 );
2659 let gate =
2660 open_existing_identity_lock(&path.parent().unwrap().join(".initialization.lock"))
2661 .unwrap();
2662 assert!(
2663 gate.try_lock().is_err(),
2664 "creator must hold the initialization gate before publishing an empty identity"
2665 );
2666 let mut contender = spawn("reader");
2667 let mut contender_out = BufReader::new(contender.stdout.take().unwrap());
2668 wait_marker(&mut contender_out, "READER_STARTED");
2669 assert!(contender.try_wait().unwrap().is_none());
2670 writeln!(creator.stdin.take().unwrap(), "release").unwrap();
2671 assert!(creator.wait().unwrap().success());
2672 assert!(contender.wait().unwrap().success());
2673 let before = std::fs::metadata(&path).unwrap();
2674 drop(store.acquire_lease("fixture/model").unwrap());
2675 #[cfg(unix)]
2676 {
2677 use std::os::unix::fs::MetadataExt;
2678 assert_eq!(before.ino(), std::fs::metadata(&path).unwrap().ino());
2679 }
2680 #[cfg(not(unix))]
2681 let _ = before;
2682 }
2683 }
2684
2685 #[test]
2686 fn identity_initialization_preserves_persistent_corruption_refusal() {
2687 for phase in ["mutation", "activity"] {
2688 for corrupt in [b"".as_slice(), b"not json".as_slice()] {
2689 let root = tempfile::tempdir().unwrap();
2690 let store = ModelManagementStore::new(
2691 root.path().join("state"),
2692 root.path().join("models"),
2693 );
2694 drop(store.acquire_lease("fixture/model").unwrap());
2695 let path = if phase == "mutation" {
2696 store.mutation_lock_path("fixture/model")
2697 } else {
2698 store.lease_path("fixture/model")
2699 };
2700 std::fs::write(&path, corrupt).unwrap();
2701 assert!(matches!(
2702 store.acquire_lease("fixture/model"),
2703 Err(ModelManagementError::InvalidState { .. })
2704 ));
2705 assert_eq!(std::fs::read(&path).unwrap(), corrupt);
2706 }
2707 }
2708 }
2709
2710 #[cfg(unix)]
2711 #[test]
2712 fn identity_initialization_rejects_linked_gates_and_identity_files() {
2713 for phase in ["mutation", "activity"] {
2714 for gate in [false, true] {
2715 for symlink in [false, true] {
2716 let root = tempfile::tempdir().unwrap();
2717 let store = ModelManagementStore::new(
2718 root.path().join("state"),
2719 root.path().join("models"),
2720 );
2721 let identity = if phase == "mutation" {
2722 store.mutation_lock_path("fixture/model")
2723 } else {
2724 store.lease_path("fixture/model")
2725 };
2726 create_private_dir(identity.parent().unwrap()).unwrap();
2727 let path = if gate {
2728 identity.parent().unwrap().join(".initialization.lock")
2729 } else {
2730 identity
2731 };
2732 let outside = root.path().join("outside");
2733 std::fs::write(&outside, b"untouched").unwrap();
2734 if symlink {
2735 std::os::unix::fs::symlink(&outside, &path).unwrap();
2736 } else {
2737 std::fs::hard_link(&outside, &path).unwrap();
2738 }
2739 assert!(matches!(
2740 store.acquire_lease("fixture/model"),
2741 Err(ModelManagementError::InvalidState { .. })
2742 ));
2743 assert_eq!(std::fs::read(&outside).unwrap(), b"untouched");
2744 }
2745 }
2746 }
2747 }
2748
2749 fn directory_receipt(model_id: &str, managed_path: PathBuf) -> InstallReceipt {
2750 InstallReceipt {
2751 model_id: model_id.into(),
2752 managed_path,
2753 artifact_kind: ManagedArtifactKind::Directory,
2754 source_model_id: model_id.into(),
2755 source_revision: None,
2756 creation_generation: 1,
2757 shared_cache_references: vec![],
2758 adopted: false,
2759 }
2760 }
2761
2762 #[test]
2763 fn raw_receipt_identity_is_private_and_collision_fails_closed() {
2764 let root = tempfile::tempdir().unwrap();
2765 let models = root.path().join("models");
2766 std::fs::create_dir_all(&models).unwrap();
2767 let store = ModelManagementStore::new(root.path().join("state"), models.clone());
2768 let receipt = directory_receipt("org/model", models.join("Model"));
2769 store.write_receipt(&receipt).unwrap();
2770 assert_eq!(store.load_receipt("org/model").unwrap(), Some(receipt));
2771
2772 #[cfg(unix)]
2773 {
2774 use std::os::unix::fs::PermissionsExt;
2775 assert_eq!(
2776 std::fs::metadata(store.receipt_path("org/model"))
2777 .unwrap()
2778 .permissions()
2779 .mode()
2780 & 0o077,
2781 0
2782 );
2783 }
2784
2785 let collision = directory_receipt("different/model", models.join("Other"));
2786 std::fs::write(
2787 store.receipt_path("org/model"),
2788 serde_json::to_vec(&collision).unwrap(),
2789 )
2790 .unwrap();
2791 assert!(matches!(
2792 store.load_receipt("org/model"),
2793 Err(ModelManagementError::IdentityMismatch { .. })
2794 ));
2795 }
2796
2797 #[test]
2798 fn journaled_removal_rejects_leaf_identity_swap() {
2799 let root = tempfile::tempdir().unwrap();
2800 let models = root.path().join("models");
2801 std::fs::create_dir_all(&models).unwrap();
2802 let store = ModelManagementStore::new(root.path().join("state"), models.clone());
2803 let managed = models.join("Managed");
2804 std::fs::create_dir_all(&managed).unwrap();
2805 std::fs::write(managed.join("weights"), b"original").unwrap();
2806 let receipt = directory_receipt("org/model", managed.clone());
2807 store.write_receipt(&receipt).unwrap();
2808 let journal = RemovalJournal {
2809 model_id: "org/model".into(),
2810 removal_generation: 2,
2811 artifact_identity: artifact_identity(&managed, "org/model").unwrap(),
2812 receipt,
2813 quarantine_path: store.allocate_quarantine("org/model").unwrap(),
2814 };
2815 write_private_json(&store.removal_journal_path("org/model"), &journal).unwrap();
2816 let displaced = models.join("displaced-original");
2817 std::fs::rename(&managed, &displaced).unwrap();
2818 std::fs::create_dir_all(&managed).unwrap();
2819 std::fs::write(managed.join("sentinel"), b"replacement").unwrap();
2820
2821 assert!(matches!(
2822 store.begin_mutation("org/model").unwrap().remove(2),
2823 Err(ModelManagementError::UnsafeManagedPath { .. })
2824 ));
2825 assert_eq!(
2826 std::fs::read(managed.join("sentinel")).unwrap(),
2827 b"replacement"
2828 );
2829 }
2830
2831 #[test]
2832 fn journaled_removal_rejects_models_parent_swap() {
2833 let root = tempfile::tempdir().unwrap();
2834 let models = root.path().join("models");
2835 std::fs::create_dir_all(&models).unwrap();
2836 let store = ModelManagementStore::new(root.path().join("state"), models.clone());
2837 let managed = models.join("Managed");
2838 std::fs::create_dir_all(&managed).unwrap();
2839 std::fs::write(managed.join("weights"), b"original").unwrap();
2840 let receipt = directory_receipt("org/model", managed.clone());
2841 store.write_receipt(&receipt).unwrap();
2842 let journal = RemovalJournal {
2843 model_id: "org/model".into(),
2844 removal_generation: 2,
2845 artifact_identity: artifact_identity(&managed, "org/model").unwrap(),
2846 receipt,
2847 quarantine_path: store.allocate_quarantine("org/model").unwrap(),
2848 };
2849 write_private_json(&store.removal_journal_path("org/model"), &journal).unwrap();
2850 std::fs::rename(&models, root.path().join("models-original")).unwrap();
2851 std::fs::create_dir_all(&managed).unwrap();
2852 std::fs::write(managed.join("sentinel"), b"replacement-parent").unwrap();
2853
2854 assert!(matches!(
2855 store.begin_mutation("org/model").unwrap().remove(2),
2856 Err(ModelManagementError::UnsafeManagedPath { .. })
2857 ));
2858 assert_eq!(
2859 std::fs::read(managed.join("sentinel")).unwrap(),
2860 b"replacement-parent"
2861 );
2862 }
2863
2864 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
2865 #[test]
2866 fn directory_receipts_fail_closed_without_recursive_cleanup() {
2867 let root = tempfile::tempdir().unwrap();
2868 let models = root.path().join("models");
2869 std::fs::create_dir_all(&models).unwrap();
2870 let store = ModelManagementStore::new(root.path().join("state"), models.clone());
2871 let managed = models.join("Managed");
2872 std::fs::create_dir_all(&managed).unwrap();
2873 std::fs::write(managed.join("shared-sentinel"), b"preserve").unwrap();
2874 store
2875 .write_receipt(&directory_receipt("org/model", managed.clone()))
2876 .unwrap();
2877
2878 assert!(!store.can_remove("org/model").unwrap());
2879 let error = store
2880 .begin_mutation("org/model")
2881 .unwrap()
2882 .remove(2)
2883 .unwrap_err();
2884 assert!(matches!(
2885 error,
2886 ModelManagementError::UnsafeManagedPath { .. }
2887 ));
2888 assert_eq!(
2889 std::fs::read(managed.join("shared-sentinel")).unwrap(),
2890 b"preserve"
2891 );
2892 }
2893
2894 #[test]
2895 fn failed_directory_staging_is_left_for_object_bound_cleanup() {
2896 let root = tempfile::tempdir().unwrap();
2897 let models = root.path().join("models");
2898 std::fs::create_dir_all(&models).unwrap();
2899 let store = ModelManagementStore::new(root.path().join("state"), models);
2900 let staging = store.create_install_staging("org/model").unwrap();
2901 std::fs::write(staging.join("shared-sentinel"), b"preserve").unwrap();
2902
2903 store.discard_install_staging(&staging);
2904
2905 assert_eq!(
2906 std::fs::read(staging.join("shared-sentinel")).unwrap(),
2907 b"preserve"
2908 );
2909 }
2910
2911 #[cfg(unix)]
2912 #[test]
2913 fn completed_symlink_removal_retry_is_idempotent() {
2914 use std::os::unix::fs::symlink;
2915
2916 let root = tempfile::tempdir().unwrap();
2917 let models = root.path().join("models");
2918 let shared = root.path().join("shared");
2919 std::fs::create_dir_all(&models).unwrap();
2920 std::fs::create_dir_all(&shared).unwrap();
2921 std::fs::write(shared.join("sentinel"), b"preserve").unwrap();
2922 let managed = models.join("Managed");
2923 symlink(&shared, &managed).unwrap();
2924 let store = ModelManagementStore::new(root.path().join("state"), models);
2925 let receipt = InstallReceipt {
2926 model_id: "org/model".into(),
2927 managed_path: managed,
2928 artifact_kind: ManagedArtifactKind::Symlink,
2929 source_model_id: "org/model".into(),
2930 source_revision: None,
2931 creation_generation: 1,
2932 shared_cache_references: vec![shared.canonicalize().unwrap()],
2933 adopted: false,
2934 };
2935 store.write_receipt(&receipt).unwrap();
2936
2937 let first = store
2938 .begin_mutation("org/model")
2939 .unwrap()
2940 .remove(2)
2941 .unwrap();
2942 let retry = store
2943 .begin_mutation("org/model")
2944 .unwrap()
2945 .remove(3)
2946 .unwrap();
2947 assert_eq!(retry, first);
2948 assert!(shared.join("sentinel").exists());
2949 }
2950
2951 #[test]
2952 fn pulled_directory_publish_before_receipt_resumes_from_install_intent() {
2953 let root = tempfile::tempdir().unwrap();
2954 let models = root.path().join("models");
2955 std::fs::create_dir_all(&models).unwrap();
2956 let store = ModelManagementStore::new(root.path().join("state"), models.clone());
2957 let staging = store.create_install_staging("org/model").unwrap();
2958 std::fs::write(staging.join("weights"), b"owned").unwrap();
2959 let receipt = store
2960 .install_receipt_for_publication(
2961 "org/model",
2962 "org/model",
2963 1,
2964 false,
2965 models.join("Managed"),
2966 ManagedArtifactKind::Directory,
2967 &staging,
2968 )
2969 .unwrap();
2970 store.begin_install_intent(receipt, Some(&staging)).unwrap();
2971 store
2972 .publish_install_staging("org/model", &staging, "Managed")
2973 .unwrap();
2974 assert!(store.load_receipt("org/model").unwrap().is_none());
2975
2976 let resumed = store.resume_install_intent("org/model").unwrap().unwrap();
2977 assert_eq!(resumed.managed_path, models.join("Managed"));
2978 assert!(store.load_receipt("org/model").unwrap().is_some());
2979 #[cfg(any(target_os = "macos", target_os = "linux"))]
2980 assert!(store.can_remove("org/model").unwrap());
2981 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
2982 assert!(!store.can_remove("org/model").unwrap());
2983 }
2984
2985 #[cfg(unix)]
2986 #[test]
2987 fn adopted_projection_publish_before_receipt_resumes_from_install_intent() {
2988 let root = tempfile::tempdir().unwrap();
2989 let models = root.path().join("models");
2990 let source = root.path().join("hand-installed");
2991 std::fs::create_dir_all(&models).unwrap();
2992 std::fs::create_dir_all(&source).unwrap();
2993 std::fs::write(source.join("sentinel"), b"preserve").unwrap();
2994 let store = ModelManagementStore::new(root.path().join("state"), models);
2995 let managed = store.adopted_projection_path("org/model").unwrap();
2996 let receipt = store
2997 .install_receipt_for_publication(
2998 "org/model",
2999 "org/model",
3000 1,
3001 true,
3002 managed.clone(),
3003 ManagedArtifactKind::Symlink,
3004 &source,
3005 )
3006 .unwrap();
3007 store.begin_install_intent(receipt, None).unwrap();
3008 store
3009 .materialize_adopted_projection("org/model", &source)
3010 .unwrap();
3011 assert!(store.load_receipt("org/model").unwrap().is_none());
3012
3013 let resumed = store.resume_install_intent("org/model").unwrap().unwrap();
3014 assert_eq!(resumed.managed_path, managed);
3015 assert!(store.can_remove("org/model").unwrap());
3016 assert!(source.join("sentinel").exists());
3017 }
3018
3019 #[cfg(unix)]
3020 #[test]
3021 fn install_intent_never_claims_a_preexisting_unreceipted_leaf() {
3022 use std::os::unix::fs::symlink;
3023
3024 let root = tempfile::tempdir().unwrap();
3025 let models = root.path().join("models");
3026 let source = root.path().join("shared");
3027 std::fs::create_dir_all(&models).unwrap();
3028 std::fs::create_dir_all(&source).unwrap();
3029 let managed = models.join("Managed");
3030 symlink(&source, &managed).unwrap();
3031 let store = ModelManagementStore::new(root.path().join("state"), models);
3032 let receipt = store
3033 .install_receipt_for_publication(
3034 "org/model",
3035 "org/model",
3036 1,
3037 false,
3038 managed.clone(),
3039 ManagedArtifactKind::Symlink,
3040 &source,
3041 )
3042 .unwrap();
3043
3044 let error = store.begin_install_intent(receipt, None).unwrap_err();
3045 assert!(matches!(
3046 error,
3047 ModelManagementError::UnsafeManagedPath { .. }
3048 ));
3049 assert!(managed.exists());
3050 assert!(store.load_receipt("org/model").unwrap().is_none());
3051 }
3052
3053 #[test]
3054 fn forged_install_journal_paths_fail_before_filesystem_mutation() {
3055 let root = tempfile::tempdir().unwrap();
3056 let models = root.path().join("models");
3057 std::fs::create_dir_all(&models).unwrap();
3058 let outside = root.path().join("outside");
3059 std::fs::create_dir_all(&outside).unwrap();
3060 std::fs::write(outside.join("sentinel"), b"preserve").unwrap();
3061 let store = ModelManagementStore::new(root.path().join("state"), models.clone());
3062
3063 let forged_managed = InstallJournal {
3064 model_id: "org/model".into(),
3065 receipt: directory_receipt("org/model", outside.clone()),
3066 staging_path: None,
3067 staging_identity: None,
3068 };
3069 write_private_json(&store.install_journal_path("org/model"), &forged_managed).unwrap();
3070 assert!(matches!(
3071 store.resume_install_intent("org/model"),
3072 Err(ModelManagementError::UnsafeManagedPath { .. })
3073 ));
3074 assert!(outside.join("sentinel").exists());
3075
3076 let forged_staging = InstallJournal {
3077 model_id: "org/other".into(),
3078 receipt: directory_receipt("org/other", models.join("Other")),
3079 staging_path: Some(outside.clone()),
3080 staging_identity: Some(artifact_identity(&outside, "org/other").unwrap()),
3081 };
3082 write_private_json(&store.install_journal_path("org/other"), &forged_staging).unwrap();
3083 assert!(matches!(
3084 store.resume_install_intent("org/other"),
3085 Err(ModelManagementError::UnsafeManagedPath { .. })
3086 ));
3087 assert!(outside.join("sentinel").exists());
3088 }
3089
3090 #[cfg(unix)]
3091 #[test]
3092 fn startup_ignores_noncanonical_removal_journal_without_touching_valid_model() {
3093 use std::os::unix::fs::symlink;
3094
3095 let root = tempfile::tempdir().unwrap();
3096 let state = root.path().join("state");
3097 let models = root.path().join("models");
3098 let shared = root.path().join("shared");
3099 let outside = root.path().join("outside");
3100 std::fs::create_dir_all(&models).unwrap();
3101 std::fs::create_dir_all(&shared).unwrap();
3102 std::fs::create_dir_all(&outside).unwrap();
3103 std::fs::write(shared.join("sentinel"), b"shared").unwrap();
3104 std::fs::write(outside.join("sentinel"), b"outside").unwrap();
3105 let managed = models.join("Managed");
3106 symlink(&shared, &managed).unwrap();
3107 let store = ModelManagementStore::new(state.clone(), models.clone());
3108 let legitimate = InstallReceipt {
3109 model_id: "org/model".into(),
3110 managed_path: managed.clone(),
3111 artifact_kind: ManagedArtifactKind::Symlink,
3112 source_model_id: "org/model".into(),
3113 source_revision: None,
3114 creation_generation: 1,
3115 shared_cache_references: vec![shared.canonicalize().unwrap()],
3116 adopted: false,
3117 };
3118 store.write_receipt(&legitimate).unwrap();
3119 create_private_dir(&store.removals_root).unwrap();
3120 let forged = RemovalJournal {
3121 model_id: "org/model".into(),
3122 removal_generation: 99,
3123 receipt: InstallReceipt {
3124 managed_path: outside.join("missing-leaf"),
3125 ..legitimate
3126 },
3127 quarantine_path: models.join(".car-remove-forged"),
3128 artifact_identity: ArtifactIdentity {
3129 kind: ManagedArtifactKind::Symlink,
3130 device: 1,
3131 inode: 1,
3132 file_len: 0,
3133 },
3134 };
3135 write_private_json(&store.removals_root.join("junk.json"), &forged).unwrap();
3136 drop(store);
3137
3138 let recovered = ModelManagementStore::new(state, models);
3139 assert!(std::fs::symlink_metadata(&managed).is_ok());
3140 assert!(recovered.car_enabled("org/model").unwrap());
3141 assert_eq!(std::fs::read(shared.join("sentinel")).unwrap(), b"shared");
3142 assert_eq!(std::fs::read(outside.join("sentinel")).unwrap(), b"outside");
3143 }
3144
3145 #[cfg(any(target_os = "macos", target_os = "linux"))]
3148 #[test]
3149 fn mutation_guard_releases_lock_even_with_an_inherited_descriptor() {
3150 let (_root, _models, store) = supported_platform_store();
3151 let guard = store.begin_mutation("org/model").unwrap();
3152 let inherited = guard._file.try_clone().unwrap();
3153 assert!(matches!(
3154 store.begin_mutation("org/model"),
3155 Err(ModelManagementError::MutationInProgress { .. })
3156 ));
3157 drop(guard);
3158 let replacement = store.begin_mutation("org/model").unwrap();
3159 drop(inherited);
3161 assert!(matches!(
3162 store.begin_mutation("org/model"),
3163 Err(ModelManagementError::MutationInProgress { .. })
3164 ));
3165 drop(replacement);
3166 assert!(store.begin_mutation("org/model").is_ok());
3167 }
3168
3169 #[cfg(any(target_os = "macos", target_os = "linux"))]
3170 #[test]
3171 fn lease_and_idle_guards_release_despite_inherited_descriptors() {
3172 let (_root, _models, store) = supported_platform_store();
3173 let lease = store.acquire_lease("org/model").unwrap();
3174 let inherited = lease._file.try_clone().unwrap();
3175 assert!(store.model_in_use("org/model").unwrap());
3176 drop(lease);
3177 assert!(!store.model_in_use("org/model").unwrap());
3178 let idle = store.lock_idle_activity("org/model").unwrap();
3179 let inherited_idle = idle.try_clone().unwrap();
3180 assert!(store.model_in_use("org/model").unwrap());
3181 drop(idle);
3182 assert!(!store.model_in_use("org/model").unwrap());
3183 drop((inherited, inherited_idle));
3184 }
3185
3186 #[cfg(any(target_os = "macos", target_os = "linux"))]
3187 #[test]
3188 fn temporary_owner_releases_initialization_gate_despite_inherited_descriptor() {
3189 let (_root, _models, store) = supported_platform_store();
3190 let identity = store
3191 .open_identity_lock(&store.mutation_lock_path("org/model"), "org/model")
3192 .unwrap();
3193 let path = store
3194 .mutation_lock_path("org/model")
3195 .parent()
3196 .unwrap()
3197 .join(".initialization.lock");
3198 let gate = open_existing_identity_lock(&path).unwrap();
3199 gate.lock().unwrap();
3200 let gate = OwnedFileLock::new(gate);
3201 let inherited = gate.try_clone().unwrap();
3202 let contender = open_existing_identity_lock(&path).unwrap();
3203 assert!(contender.try_lock().is_err());
3204 drop(gate);
3205 contender.try_lock().unwrap();
3206 contender.unlock().unwrap();
3207 drop((identity, inherited));
3208 }
3209
3210 #[cfg(any(target_os = "macos", target_os = "linux"))]
3211 fn supported_platform_store() -> (tempfile::TempDir, PathBuf, ModelManagementStore) {
3212 let root = tempfile::tempdir().unwrap();
3213 let models = root.path().join("models");
3214 std::fs::create_dir_all(&models).unwrap();
3215 let store = ModelManagementStore::new(root.path().join("state"), models.clone());
3216 (root, models, store)
3217 }
3218
3219 #[cfg(any(target_os = "macos", target_os = "linux"))]
3220 fn no_quarantine_left(models: &Path) -> bool {
3221 std::fs::read_dir(models).unwrap().all(|entry| {
3222 !entry
3223 .unwrap()
3224 .file_name()
3225 .to_string_lossy()
3226 .starts_with(".car-remove-")
3227 })
3228 }
3229
3230 #[cfg(any(target_os = "macos", target_os = "linux"))]
3231 const MAX_REMOVAL_DEPTH_FOR_TESTS: usize = super::object_bound::MAX_REMOVAL_DEPTH;
3232
3233 #[cfg(any(target_os = "macos", target_os = "linux"))]
3237 fn unsafe_managed_reason(error: &ModelManagementError) -> &str {
3238 match error {
3239 ModelManagementError::UnsafeManagedPath { reason, .. } => reason,
3240 other => panic!("expected UnsafeManagedPath, got {other:?}"),
3241 }
3242 }
3243
3244 #[cfg(any(target_os = "macos", target_os = "linux"))]
3245 #[test]
3246 fn directory_receipt_is_removable_and_leaves_a_tombstone() {
3247 let (_root, models, store) = supported_platform_store();
3248 let managed = models.join("Managed");
3249 std::fs::create_dir_all(&managed).unwrap();
3250 std::fs::write(managed.join("weights"), b"owned").unwrap();
3251 store
3252 .write_receipt(&directory_receipt("org/model", managed.clone()))
3253 .unwrap();
3254
3255 assert!(store.can_remove("org/model").unwrap());
3256 let result = store
3257 .begin_mutation("org/model")
3258 .unwrap()
3259 .remove(2)
3260 .unwrap();
3261
3262 assert_eq!(result.artifact_kind, ManagedArtifactKind::Directory);
3263 assert!(std::fs::symlink_metadata(&managed).is_err());
3264 assert!(store.load_receipt("org/model").unwrap().is_none());
3265 let tombstone = store.load_tombstone("org/model").unwrap().unwrap();
3266 assert_eq!(
3267 tombstone.artifact_kind,
3268 Some(ManagedArtifactKind::Directory)
3269 );
3270 assert_eq!(tombstone.removal_generation, 2);
3271 assert!(no_quarantine_left(&models));
3272 assert!(store.load_removal_journal("org/model").unwrap().is_none());
3273 }
3274
3275 #[cfg(any(target_os = "macos", target_os = "linux"))]
3276 #[test]
3277 fn directory_removal_preserves_shared_cache_symlink_targets() {
3278 use std::os::unix::fs::symlink;
3279
3280 let (root, models, store) = supported_platform_store();
3281 let shared = root.path().join("shared-cache");
3282 std::fs::create_dir_all(&shared).unwrap();
3283 std::fs::write(shared.join("blob"), b"shared-bytes").unwrap();
3284 let managed = models.join("Managed");
3285 let nested = managed.join("a").join("b").join("c");
3286 std::fs::create_dir_all(&nested).unwrap();
3287 std::fs::write(managed.join("config.json"), b"{}").unwrap();
3288 std::fs::write(nested.join("weights"), b"owned").unwrap();
3289 symlink(shared.join("blob"), managed.join("blob.safetensors")).unwrap();
3290 let blob = shared.join("blob").canonicalize().unwrap();
3291 let mut receipt = directory_receipt("org/model", managed.clone());
3292 receipt.shared_cache_references = vec![blob.clone()];
3293 store.write_receipt(&receipt).unwrap();
3294
3295 let result = store
3296 .begin_mutation("org/model")
3297 .unwrap()
3298 .remove(2)
3299 .unwrap();
3300
3301 assert!(std::fs::symlink_metadata(&managed).is_err());
3302 assert_eq!(std::fs::read(&blob).unwrap(), b"shared-bytes");
3303 assert_eq!(std::fs::read_dir(&shared).unwrap().count(), 1);
3304 assert_eq!(result.preserved_shared_cache_references, vec![blob]);
3305 assert!(no_quarantine_left(&models));
3306 }
3307
3308 #[cfg(any(target_os = "macos", target_os = "linux"))]
3309 #[test]
3310 fn directory_removal_resumes_after_a_partial_quarantine_delete() {
3311 let (_root, models, store) = supported_platform_store();
3312 let managed = models.join("Managed");
3313 std::fs::create_dir_all(managed.join("sub")).unwrap();
3314 std::fs::write(managed.join("weights"), b"owned").unwrap();
3315 std::fs::write(managed.join("sub").join("more"), b"owned").unwrap();
3316 let receipt = directory_receipt("org/model", managed.clone());
3317 store.write_receipt(&receipt).unwrap();
3318 let quarantine = store.allocate_quarantine("org/model").unwrap();
3319 let journal = RemovalJournal {
3320 model_id: "org/model".into(),
3321 removal_generation: 2,
3322 artifact_identity: artifact_identity(&managed, "org/model").unwrap(),
3323 receipt,
3324 quarantine_path: quarantine.clone(),
3325 };
3326 write_private_json(&store.removal_journal_path("org/model"), &journal).unwrap();
3327 std::fs::rename(&managed, &quarantine).unwrap();
3329 std::fs::remove_file(quarantine.join("weights")).unwrap();
3330
3331 let resumed = store
3332 .begin_mutation("org/model")
3333 .unwrap()
3334 .remove(2)
3335 .unwrap();
3336 assert_eq!(resumed.artifact_kind, ManagedArtifactKind::Directory);
3337 assert!(std::fs::symlink_metadata(&quarantine).is_err());
3338 assert!(no_quarantine_left(&models));
3339
3340 let replayed = store
3345 .begin_mutation("org/model")
3346 .unwrap()
3347 .remove(3)
3348 .unwrap();
3349 assert_eq!(replayed.artifact_kind, ManagedArtifactKind::Directory);
3350 let tombstone = store.load_tombstone("org/model").unwrap().unwrap();
3351 assert_eq!(tombstone.removal_generation, 2);
3352 assert!(replayed.preserved_shared_cache_references.is_empty());
3353 }
3354
3355 #[cfg(any(target_os = "macos", target_os = "linux"))]
3356 #[test]
3357 fn directory_removal_refuses_trees_deeper_than_the_cap() {
3358 let (_root, models, store) = supported_platform_store();
3359 let managed = models.join("Managed");
3360 let mut deep = managed.clone();
3361 for level in 0..(MAX_REMOVAL_DEPTH_FOR_TESTS + 1) {
3362 deep = deep.join(format!("level{level}"));
3363 }
3364 std::fs::create_dir_all(&deep).unwrap();
3365 std::fs::write(deep.join("sentinel"), b"deep").unwrap();
3366 store
3367 .write_receipt(&directory_receipt("org/model", managed.clone()))
3368 .unwrap();
3369
3370 let error = store
3371 .begin_mutation("org/model")
3372 .unwrap()
3373 .remove(2)
3374 .unwrap_err();
3375
3376 let reason = unsafe_managed_reason(&error);
3377 assert!(reason.contains("depth"), "unexpected reason: {reason}");
3378 let journal = store.load_removal_journal("org/model").unwrap().unwrap();
3380 let quarantined_leaf = deep
3381 .strip_prefix(&managed)
3382 .map(|rest| journal.quarantine_path.join(rest))
3383 .unwrap();
3384 assert_eq!(
3385 std::fs::read(quarantined_leaf.join("sentinel")).unwrap(),
3386 b"deep"
3387 );
3388 }
3389
3390 #[test]
3394 fn removal_identity_ignores_length_for_directories_only() {
3395 let directory = ArtifactIdentity {
3396 kind: ManagedArtifactKind::Directory,
3397 device: 7,
3398 inode: 42,
3399 file_len: 96,
3400 };
3401 let shrunk = ArtifactIdentity {
3402 file_len: 32,
3403 ..directory.clone()
3404 };
3405 assert!(directory.matches_for_removal(&shrunk));
3406 assert!(!directory.matches_for_removal(&ArtifactIdentity {
3407 inode: 43,
3408 ..directory.clone()
3409 }));
3410 assert!(!directory.matches_for_removal(&ArtifactIdentity {
3411 device: 8,
3412 ..directory.clone()
3413 }));
3414
3415 let file = ArtifactIdentity {
3416 kind: ManagedArtifactKind::File,
3417 device: 7,
3418 inode: 42,
3419 file_len: 96,
3420 };
3421 assert!(file.matches_for_removal(&file));
3422 assert!(!file.matches_for_removal(&ArtifactIdentity {
3423 file_len: 95,
3424 ..file.clone()
3425 }));
3426 assert!(!file.matches_for_removal(&ArtifactIdentity {
3427 kind: ManagedArtifactKind::Symlink,
3428 ..file.clone()
3429 }));
3430 }
3431
3432 #[cfg(any(target_os = "macos", target_os = "linux"))]
3433 #[test]
3434 fn same_filesystem_predicate_compares_devices_only() {
3435 use super::object_bound::{same_filesystem, Identity};
3436 let root = Identity {
3437 device: 5,
3438 inode: 1,
3439 };
3440 assert!(same_filesystem(
3441 &root,
3442 &Identity {
3443 device: 5,
3444 inode: 999
3445 }
3446 ));
3447 assert!(!same_filesystem(
3448 &root,
3449 &Identity {
3450 device: 6,
3451 inode: 1
3452 }
3453 ));
3454 }
3455
3456 #[cfg(any(target_os = "macos", target_os = "linux"))]
3457 #[test]
3458 fn directory_removal_refuses_entries_added_after_capture() {
3459 let (_root, models, store) = supported_platform_store();
3460 let managed = models.join("Managed");
3461 std::fs::create_dir_all(managed.join("sub")).unwrap();
3462 std::fs::write(managed.join("weights"), b"owned").unwrap();
3463 std::fs::write(managed.join("sub").join("more"), b"owned").unwrap();
3464 let store = store.with_removal_hook(|phase, quarantine| {
3465 if phase == RemovalPhase::AfterCapture {
3466 std::fs::write(quarantine.join("sub").join("late"), b"not captured").unwrap();
3467 }
3468 });
3469 store
3470 .write_receipt(&directory_receipt("org/model", managed.clone()))
3471 .unwrap();
3472
3473 let error = store
3474 .begin_mutation("org/model")
3475 .unwrap()
3476 .remove(2)
3477 .unwrap_err();
3478
3479 let reason = unsafe_managed_reason(&error);
3480 assert!(
3481 reason.contains("not captured"),
3482 "unexpected reason: {reason}"
3483 );
3484 let journal = store.load_removal_journal("org/model").unwrap().unwrap();
3485 let late = journal.quarantine_path.join("sub").join("late");
3486 assert_eq!(std::fs::read(&late).unwrap(), b"not captured");
3487
3488 std::fs::remove_file(&late).unwrap();
3490 let store = store.with_removal_hook(|_, _| {});
3491 let resumed = store
3492 .begin_mutation("org/model")
3493 .unwrap()
3494 .remove(2)
3495 .unwrap();
3496 assert_eq!(resumed.artifact_kind, ManagedArtifactKind::Directory);
3497 assert!(no_quarantine_left(&models));
3498 }
3499
3500 #[cfg(any(target_os = "macos", target_os = "linux"))]
3501 #[test]
3502 fn directory_removal_refuses_root_swap_after_rename() {
3503 let (_root, models, store) = supported_platform_store();
3504 let managed = models.join("Managed");
3505 std::fs::create_dir_all(&managed).unwrap();
3506 std::fs::write(managed.join("weights"), b"owned").unwrap();
3507 let displaced = models.join("displaced");
3508 let store = store.with_removal_hook(move |phase, quarantine| {
3509 if phase == RemovalPhase::AfterRename {
3510 std::fs::rename(quarantine, &displaced).unwrap();
3511 std::fs::create_dir_all(quarantine).unwrap();
3512 std::fs::write(quarantine.join("sentinel"), b"replacement").unwrap();
3513 }
3514 });
3515 store
3516 .write_receipt(&directory_receipt("org/model", managed.clone()))
3517 .unwrap();
3518
3519 let error = store
3520 .begin_mutation("org/model")
3521 .unwrap()
3522 .remove(2)
3523 .unwrap_err();
3524
3525 let reason = unsafe_managed_reason(&error);
3526 assert!(
3527 reason.contains("identity changed"),
3528 "unexpected reason: {reason}"
3529 );
3530 let journal = store.load_removal_journal("org/model").unwrap().unwrap();
3531 assert_eq!(
3532 std::fs::read(journal.quarantine_path.join("sentinel")).unwrap(),
3533 b"replacement"
3534 );
3535 assert_eq!(
3536 std::fs::read(models.join("displaced").join("weights")).unwrap(),
3537 b"owned"
3538 );
3539 }
3540
3541 #[cfg(any(target_os = "macos", target_os = "linux"))]
3542 #[test]
3543 fn directory_symlink_planted_after_rename_is_unlinked_not_followed() {
3544 use std::os::unix::fs::symlink;
3545
3546 let (root, models, store) = supported_platform_store();
3547 let outside = root.path().join("outside");
3548 std::fs::create_dir_all(&outside).unwrap();
3549 std::fs::write(outside.join("sentinel"), b"outside").unwrap();
3550 let managed = models.join("Managed");
3551 std::fs::create_dir_all(&managed).unwrap();
3552 std::fs::write(managed.join("weights"), b"owned").unwrap();
3553 let target = outside.clone();
3554 let store = store.with_removal_hook(move |phase, quarantine| {
3555 if phase == RemovalPhase::AfterRename {
3556 symlink(&target, quarantine.join("escape")).unwrap();
3557 }
3558 });
3559 store
3560 .write_receipt(&directory_receipt("org/model", managed.clone()))
3561 .unwrap();
3562
3563 let result = store.begin_mutation("org/model").unwrap().remove(2);
3564
3565 assert_eq!(
3566 std::fs::read(outside.join("sentinel")).map_err(|e| e.to_string()),
3567 Ok(b"outside".to_vec()),
3568 "outside sentinel was deleted; removal result: {result:?}"
3569 );
3570 assert!(result.is_ok(), "{result:?}");
3571 assert!(std::fs::symlink_metadata(&managed).is_err());
3572 assert!(no_quarantine_left(&models));
3573 }
3574
3575 #[cfg(any(target_os = "macos", target_os = "linux"))]
3576 #[test]
3577 fn directory_removal_refuses_root_swap_between_path_check_and_descriptor_open() {
3578 let (_root, models, store) = supported_platform_store();
3579 let managed = models.join("Managed");
3580 std::fs::create_dir_all(&managed).unwrap();
3581 std::fs::write(managed.join("weights"), b"owned").unwrap();
3582 let displaced = models.join("displaced");
3583 let store = store.with_removal_hook(move |phase, quarantine| {
3584 if phase == RemovalPhase::BeforeRootOpen {
3585 std::fs::rename(quarantine, &displaced).unwrap();
3586 std::fs::create_dir_all(quarantine).unwrap();
3587 std::fs::write(quarantine.join("sentinel"), b"replacement").unwrap();
3588 }
3589 });
3590 store
3591 .write_receipt(&directory_receipt("org/model", managed.clone()))
3592 .unwrap();
3593
3594 let error = store
3595 .begin_mutation("org/model")
3596 .unwrap()
3597 .remove(2)
3598 .unwrap_err();
3599
3600 let reason = unsafe_managed_reason(&error);
3601 assert_eq!(
3602 reason,
3603 "quarantined directory identity changed before deletion"
3604 );
3605 let journal = store.load_removal_journal("org/model").unwrap().unwrap();
3606 assert_eq!(
3607 std::fs::read(journal.quarantine_path.join("sentinel")).unwrap(),
3608 b"replacement"
3609 );
3610 assert_eq!(
3611 std::fs::read(models.join("displaced").join("weights")).unwrap(),
3612 b"owned"
3613 );
3614 }
3615
3616 #[cfg(any(target_os = "macos", target_os = "linux"))]
3617 #[test]
3618 fn directory_removal_refuses_when_the_quarantine_vanishes_after_our_own_rename() {
3619 let (_root, models, store) = supported_platform_store();
3620 let managed = models.join("Managed");
3621 std::fs::create_dir_all(&managed).unwrap();
3622 std::fs::write(managed.join("weights"), b"owned").unwrap();
3623 let managed_for_hook = managed.clone();
3624 let store = store.with_removal_hook(move |phase, quarantine| {
3625 if phase == RemovalPhase::AfterRename {
3626 std::fs::rename(quarantine, &managed_for_hook).unwrap();
3628 }
3629 });
3630 store
3631 .write_receipt(&directory_receipt("org/model", managed.clone()))
3632 .unwrap();
3633
3634 let error = store
3635 .begin_mutation("org/model")
3636 .unwrap()
3637 .remove(2)
3638 .unwrap_err();
3639
3640 let reason = unsafe_managed_reason(&error);
3641 assert!(reason.contains("vanished"), "unexpected reason: {reason}");
3642 assert_eq!(std::fs::read(managed.join("weights")).unwrap(), b"owned");
3643 assert!(store.load_receipt("org/model").unwrap().is_some());
3644 assert!(store.load_removal_journal("org/model").unwrap().is_some());
3645 }
3646
3647 #[cfg(any(target_os = "macos", target_os = "linux"))]
3648 #[test]
3649 fn first_attempt_vanish_refusal_names_the_retry_that_completes_the_removal() {
3650 let (_root, models, store) = supported_platform_store();
3651 let managed = models.join("Managed");
3652 std::fs::create_dir_all(&managed).unwrap();
3653 std::fs::write(managed.join("weights"), b"owned").unwrap();
3654 let managed_for_hook = managed.clone();
3655 let sabotaged = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
3656 let store = store.with_removal_hook(move |phase, quarantine| {
3657 if phase == RemovalPhase::AfterRename
3661 && !sabotaged.swap(true, std::sync::atomic::Ordering::SeqCst)
3662 {
3663 std::fs::rename(quarantine, &managed_for_hook).unwrap();
3664 }
3665 });
3666 store
3667 .write_receipt(&directory_receipt("org/model", managed.clone()))
3668 .unwrap();
3669
3670 let error = store
3671 .begin_mutation("org/model")
3672 .unwrap()
3673 .remove(2)
3674 .unwrap_err();
3675
3676 assert_eq!(
3680 unsafe_managed_reason(&error),
3681 "quarantine vanished before deletion; the removal journal is retained, so retrying the removal resumes and completes it"
3682 );
3683 assert!(store.load_removal_journal("org/model").unwrap().is_some());
3684
3685 let retried = store
3687 .begin_mutation("org/model")
3688 .unwrap()
3689 .remove(2)
3690 .unwrap();
3691
3692 assert_eq!(retried.artifact_kind, ManagedArtifactKind::Directory);
3693 assert!(std::fs::symlink_metadata(&managed).is_err());
3694 assert!(no_quarantine_left(&models));
3695 assert!(store.load_removal_journal("org/model").unwrap().is_none());
3696 assert!(store.load_receipt("org/model").unwrap().is_none());
3697 }
3698
3699 #[cfg(any(target_os = "macos", target_os = "linux"))]
3700 #[test]
3701 fn directory_removal_refuses_a_captured_file_replaced_before_unlink() {
3702 let (root, models, store) = supported_platform_store();
3703 let outside = root.path().join("outside");
3704 std::fs::create_dir_all(&outside).unwrap();
3705 let managed = models.join("Managed");
3706 std::fs::create_dir_all(&managed).unwrap();
3707 std::fs::write(managed.join("weights"), b"owned").unwrap();
3708 let parked = outside.join("parked");
3709 let store = store.with_removal_hook(move |phase, quarantine| {
3710 if phase == RemovalPhase::AfterCapture {
3711 std::fs::rename(quarantine.join("weights"), &parked).unwrap();
3712 std::fs::write(quarantine.join("weights"), b"victim").unwrap();
3713 }
3714 });
3715 store
3716 .write_receipt(&directory_receipt("org/model", managed.clone()))
3717 .unwrap();
3718
3719 let error = store
3720 .begin_mutation("org/model")
3721 .unwrap()
3722 .remove(2)
3723 .unwrap_err();
3724
3725 let reason = unsafe_managed_reason(&error);
3726 assert!(
3727 reason.contains("identity changed"),
3728 "unexpected reason: {reason}"
3729 );
3730 let journal = store.load_removal_journal("org/model").unwrap().unwrap();
3731 assert_eq!(
3732 std::fs::read(journal.quarantine_path.join("weights")).unwrap(),
3733 b"victim"
3734 );
3735 assert_eq!(std::fs::read(outside.join("parked")).unwrap(), b"owned");
3736 }
3737
3738 #[cfg(any(target_os = "macos", target_os = "linux"))]
3739 #[test]
3740 fn directory_removal_refuses_a_captured_directory_replaced_before_rmdir() {
3741 let (root, models, store) = supported_platform_store();
3742 let outside = root.path().join("outside");
3743 std::fs::create_dir_all(&outside).unwrap();
3744 let managed = models.join("Managed");
3745 std::fs::create_dir_all(managed.join("sub")).unwrap();
3746 std::fs::write(managed.join("sub").join("more"), b"owned").unwrap();
3747 let parked = outside.join("parked-sub");
3748 let parked_for_hook = parked.clone();
3749 let store = store.with_removal_hook(move |phase, quarantine| {
3750 if phase == RemovalPhase::AfterCapture {
3751 std::fs::rename(quarantine.join("sub"), &parked_for_hook).unwrap();
3752 std::fs::create_dir_all(quarantine.join("sub")).unwrap();
3753 std::fs::write(quarantine.join("sub").join("victim"), b"victim").unwrap();
3754 }
3755 });
3756 store
3757 .write_receipt(&directory_receipt("org/model", managed.clone()))
3758 .unwrap();
3759
3760 let error = store
3761 .begin_mutation("org/model")
3762 .unwrap()
3763 .remove(2)
3764 .unwrap_err();
3765
3766 let reason = unsafe_managed_reason(&error);
3767 assert!(
3768 reason.contains("identity changed"),
3769 "unexpected reason: {reason}"
3770 );
3771 let journal = store.load_removal_journal("org/model").unwrap().unwrap();
3772 assert_eq!(
3773 std::fs::read(journal.quarantine_path.join("sub").join("victim")).unwrap(),
3774 b"victim"
3775 );
3776 assert!(std::fs::symlink_metadata(&parked).is_ok());
3779 }
3780
3781 #[cfg(any(target_os = "macos", target_os = "linux"))]
3782 #[test]
3783 fn directory_removal_accepts_a_tree_at_the_depth_cap() {
3784 let (_root, models, store) = supported_platform_store();
3785 let managed = models.join("Managed");
3786 let mut deep = managed.clone();
3787 for level in 0..MAX_REMOVAL_DEPTH_FOR_TESTS {
3788 deep = deep.join(format!("level{level}"));
3789 }
3790 std::fs::create_dir_all(&deep).unwrap();
3791 std::fs::write(deep.join("sentinel"), b"deep").unwrap();
3792 store
3793 .write_receipt(&directory_receipt("org/model", managed.clone()))
3794 .unwrap();
3795
3796 let result = store
3797 .begin_mutation("org/model")
3798 .unwrap()
3799 .remove(2)
3800 .unwrap();
3801
3802 assert_eq!(result.artifact_kind, ManagedArtifactKind::Directory);
3803 assert!(std::fs::symlink_metadata(&managed).is_err());
3804 assert!(no_quarantine_left(&models));
3805 }
3806
3807 #[cfg(any(target_os = "macos", target_os = "linux"))]
3808 #[test]
3809 fn directory_removal_refuses_when_the_quarantine_vanishes_before_the_descriptor_open() {
3810 let (_root, models, store) = supported_platform_store();
3811 let managed = models.join("Managed");
3812 std::fs::create_dir_all(&managed).unwrap();
3813 std::fs::write(managed.join("weights"), b"owned").unwrap();
3814 let managed_for_hook = managed.clone();
3815 let store = store.with_removal_hook(move |phase, quarantine| {
3816 if phase == RemovalPhase::BeforeRootOpen {
3817 std::fs::rename(quarantine, &managed_for_hook).unwrap();
3820 }
3821 });
3822 store
3823 .write_receipt(&directory_receipt("org/model", managed.clone()))
3824 .unwrap();
3825
3826 let error = store
3827 .begin_mutation("org/model")
3828 .unwrap()
3829 .remove(2)
3830 .unwrap_err();
3831
3832 assert_eq!(
3837 unsafe_managed_reason(&error),
3838 "quarantine vanished before deletion; the removal journal is retained, so retrying the removal resumes and completes it"
3839 );
3840 assert_eq!(std::fs::read(managed.join("weights")).unwrap(), b"owned");
3841 assert!(store.load_receipt("org/model").unwrap().is_some());
3842 assert!(store.load_removal_journal("org/model").unwrap().is_some());
3843 }
3844
3845 #[cfg(any(target_os = "macos", target_os = "linux"))]
3852 #[test]
3853 fn retry_after_the_quarantine_was_parked_elsewhere_settles_without_deleting_it() {
3854 let (root, models, store) = supported_platform_store();
3855 let outside = root.path().join("outside");
3856 std::fs::create_dir_all(&outside).unwrap();
3857 let managed = models.join("Managed");
3858 std::fs::create_dir_all(&managed).unwrap();
3859 std::fs::write(managed.join("weights"), b"owned").unwrap();
3860 let parked = outside.join("parked");
3861 let parked_for_hook = parked.clone();
3862 let store = store.with_removal_hook(move |phase, quarantine| {
3863 if phase == RemovalPhase::AfterRename {
3864 std::fs::rename(quarantine, &parked_for_hook).unwrap();
3866 }
3867 });
3868 store
3869 .write_receipt(&directory_receipt("org/model", managed.clone()))
3870 .unwrap();
3871
3872 let error = store
3873 .begin_mutation("org/model")
3874 .unwrap()
3875 .remove(2)
3876 .unwrap_err();
3877
3878 assert_eq!(
3879 unsafe_managed_reason(&error),
3880 "quarantine vanished before deletion; the removal journal is retained, so retrying the removal resumes and completes it"
3881 );
3882 assert!(store.load_removal_journal("org/model").unwrap().is_some());
3883
3884 let retried = store
3885 .begin_mutation("org/model")
3886 .unwrap()
3887 .remove(2)
3888 .unwrap();
3889
3890 assert_eq!(retried.artifact_kind, ManagedArtifactKind::Directory);
3891 assert!(store.load_removal_journal("org/model").unwrap().is_none());
3892 assert!(store.load_receipt("org/model").unwrap().is_none());
3893 assert!(no_quarantine_left(&models));
3894 assert_eq!(std::fs::read(parked.join("weights")).unwrap(), b"owned");
3896 }
3897}