返回 CodeWhale
registry.rs
根目录 / crates / tui / src / plugins / registry.rs
1 use std::collections::BTreeMap;
2 use std::fs::{self, OpenOptions};
3 use std::io::{Read, Write};
4 use std::path::{Path, PathBuf};
5
6 use serde::{Deserialize, Serialize};
7 use sha2::{Digest, Sha256};
8
9 use super::manifest::PluginInventory;
10 use super::path_identity::metadata_is_link_or_reparse;
11 #[cfg(windows)]
12 use super::path_identity::windows_file_identity;
13 use super::types::{
14 LoadedPlugin, PluginAuthority, PluginDiagnostic, PluginDiagnosticLevel, PluginId,
15 PluginTrustStatus,
16 };
17
18 const STATE_SCHEMA_VERSION: u32 = 1;
19 const MAX_REVIEW_HISTORY: usize = 32;
20
21 #[derive(Debug, Clone, Serialize, Deserialize)]
22 #[serde(deny_unknown_fields)]
23 struct PluginStateFile {
24 schema_version: u32,
25 #[serde(default)]
26 plugins: BTreeMap<PluginId, PersistedPluginState>,
27 }
28
29 impl Default for PluginStateFile {
30 fn default() -> Self {
31 Self {
32 schema_version: STATE_SCHEMA_VERSION,
33 plugins: BTreeMap::new(),
34 }
35 }
36 }
37
38 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
39 #[serde(deny_unknown_fields)]
40 struct PersistedPluginState {
41 #[serde(default)]
42 generation: u64,
43 #[serde(default)]
44 enabled: bool,
45 #[serde(default)]
46 trust: Option<TrustReceipt>,
47 #[serde(default)]
48 review_history: Vec<TrustReceipt>,
49 }
50
51 #[derive(Debug, Clone, Serialize, Deserialize)]
52 #[serde(deny_unknown_fields)]
53 struct TrustReceipt {
54 content_hash: String,
55 capability_hash: String,
56 reviewed_capabilities: PluginInventory,
57 reviewed_at: String,
58 }
59
60 #[derive(Debug, Clone, Default)]
61 pub struct PluginRegistry {
62 plugins: BTreeMap<PluginId, LoadedPlugin>,
63 names: BTreeMap<String, PluginId>,
64 diagnostics: Vec<PluginDiagnostic>,
65 state: PluginStateFile,
66 state_path: Option<PathBuf>,
67 state_error: Option<String>,
68 workspace: PathBuf,
69 discovery_context: Option<std::sync::Arc<super::context::PluginDiscoveryContext>>,
70 }
71
72 impl PluginRegistry {
73 #[must_use]
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 /// Construct a fail-closed registry for a workspace without consulting
79 /// process environment or filesystem discovery roots.
80 #[must_use]
81 pub fn empty(workspace: &Path) -> Self {
82 Self {
83 workspace: workspace.to_path_buf(),
84 ..Self::default()
85 }
86 }
87
88 pub(crate) fn from_discovery(
89 plugins: Vec<LoadedPlugin>,
90 mut diagnostics: Vec<PluginDiagnostic>,
91 state_path: PathBuf,
92 workspace: PathBuf,
93 discovery_context: Option<std::sync::Arc<super::context::PluginDiscoveryContext>>,
94 ) -> Self {
95 let (state, state_error) = match load_state(&state_path) {
96 Ok(state) => (state, None),
97 Err(error) => {
98 diagnostics.push(PluginDiagnostic::error(
99 "state-invalid",
100 format!("Plugin state is fail-closed and will not be overwritten: {error}"),
101 Some(state_path.clone()),
102 ));
103 (PluginStateFile::default(), Some(error))
104 }
105 };
106 let mut registry = Self {
107 plugins: BTreeMap::new(),
108 names: BTreeMap::new(),
109 diagnostics,
110 state,
111 state_path: Some(state_path),
112 state_error,
113 workspace,
114 discovery_context,
115 };
116 for plugin in plugins {
117 registry.register_loaded(plugin);
118 }
119 registry.apply_state();
120 registry
121 }
122
123 fn register_loaded(&mut self, plugin: LoadedPlugin) {
124 self.names
125 .insert(plugin.name().to_string(), plugin.id.clone());
126 self.plugins.insert(plugin.id.clone(), plugin);
127 }
128
129 fn apply_state(&mut self) {
130 let state_path = self.state_path.clone();
131 for (id, plugin) in &mut self.plugins {
132 let persisted = self.state.plugins.get(id);
133 plugin.state_generation = persisted.map_or(0, |state| state.generation);
134 plugin.enabled = persisted.is_some_and(|state| state.enabled);
135 plugin.trust_status = match persisted.and_then(|state| state.trust.as_ref()) {
136 Some(receipt) if receipt.capability_hash != plugin.capability_hash => {
137 PluginTrustStatus::CapabilitiesChanged
138 }
139 Some(receipt) if receipt.content_hash != plugin.content_hash => {
140 PluginTrustStatus::ContentChanged
141 }
142 Some(_) => PluginTrustStatus::Trusted,
143 None => PluginTrustStatus::NeverReviewed,
144 };
145 if self.state_error.is_some() {
146 plugin.enabled = false;
147 plugin.trust_status = PluginTrustStatus::NeverReviewed;
148 }
149 plugin.staged_root = state_path.as_deref().and_then(|state_path| {
150 let staged_root = runtime_stage_path(state_path, id, &plugin.content_hash);
151 staged_bundle_matches(&staged_root, &plugin.content_hash, &plugin.capability_hash)
152 .then_some(staged_root)
153 });
154 if let Some(staged_root) = plugin.staged_root.clone() {
155 match super::discovery::load_staged_skill_snapshots(
156 &staged_root,
157 &plugin.content_hash,
158 &plugin.capability_hash,
159 ) {
160 Ok(snapshots) => plugin.skill_snapshots = snapshots,
161 Err(error) => {
162 plugin.staged_root = None;
163 plugin.enabled = false;
164 plugin.diagnostics.push(PluginDiagnostic::error(
165 "staged-skill-invalid",
166 format!("Plugin runtime Skill snapshot is fail-closed: {error}"),
167 Some(staged_root),
168 ));
169 }
170 }
171 }
172 }
173 }
174
175 #[must_use]
176 pub fn workspace(&self) -> &Path {
177 &self.workspace
178 }
179
180 /// Re-discover for a new workspace using the immutable pre-dotenv roots
181 /// and environment. Registries without a context are test/ad-hoc values
182 /// and remain fail-closed instead of consulting ambient process state.
183 #[must_use]
184 pub fn rediscover_for_workspace(&self, workspace: &Path) -> std::sync::Arc<Self> {
185 self.discovery_context.as_ref().map_or_else(
186 || std::sync::Arc::new(Self::empty(workspace)),
187 |context| context.registry_for_workspace(workspace),
188 )
189 }
190
191 #[must_use]
192 pub fn host_environment(&self) -> Option<std::sync::Arc<super::context::HostEnvironment>> {
193 self.discovery_context
194 .as_ref()
195 .map(|context| context.host_environment())
196 }
197
198 #[cfg(test)]
199 pub(crate) fn replace_skill_snapshots_for_test(
200 &mut self,
201 selector: &str,
202 snapshots: Vec<super::types::PluginSkillSnapshot>,
203 ) {
204 let id = self
205 .resolve_id(selector)
206 .cloned()
207 .expect("test plugin exists");
208 self.plugins
209 .get_mut(&id)
210 .expect("test plugin exists")
211 .skill_snapshots = snapshots;
212 }
213
214 #[must_use]
215 pub fn authority_for(&self, selector: &str) -> Option<PluginAuthority> {
216 self.get(selector)
217 .and_then(|plugin| plugin.authority(self.state_path.clone()?, self.workspace.clone()))
218 }
219
220 #[must_use]
221 pub fn list(&self) -> Vec<&LoadedPlugin> {
222 let mut plugins = self.plugins.values().collect::<Vec<_>>();
223 plugins.sort_by(|left, right| {
224 left.scope
225 .cmp(&right.scope)
226 .then_with(|| left.name().cmp(right.name()))
227 .then_with(|| left.id.cmp(&right.id))
228 });
229 plugins
230 }
231
232 #[must_use]
233 pub fn get(&self, selector: &str) -> Option<&LoadedPlugin> {
234 let id = self.resolve_id(selector)?;
235 self.plugins.get(id)
236 }
237
238 #[must_use]
239 pub fn active_plugins(&self) -> Vec<&LoadedPlugin> {
240 self.list()
241 .into_iter()
242 .filter(|plugin| plugin.active())
243 .collect()
244 }
245
246 /// Compatibility name retained for the MCP adapter. Unlike the old
247 /// registry this returns only trusted, active bundles.
248 #[must_use]
249 pub fn list_enabled(&self) -> Vec<&LoadedPlugin> {
250 self.active_plugins()
251 }
252
253 #[must_use]
254 pub fn enabled_plugins(&self) -> Vec<&LoadedPlugin> {
255 self.list()
256 .into_iter()
257 .filter(|plugin| plugin.enabled)
258 .collect()
259 }
260
261 #[must_use]
262 pub fn is_enabled(&self, selector: &str) -> bool {
263 self.get(selector).is_some_and(|plugin| plugin.enabled)
264 }
265
266 #[must_use]
267 pub fn is_active(&self, selector: &str) -> bool {
268 self.get(selector).is_some_and(LoadedPlugin::active)
269 }
270
271 #[must_use]
272 pub fn diagnostics(&self) -> &[PluginDiagnostic] {
273 &self.diagnostics
274 }
275
276 #[must_use]
277 pub fn validation_is_clean(&self) -> bool {
278 !self
279 .diagnostics
280 .iter()
281 .any(|diagnostic| diagnostic.level == PluginDiagnosticLevel::Error)
282 && self.plugins.values().all(|plugin| {
283 !plugin
284 .diagnostics
285 .iter()
286 .any(|diagnostic| diagnostic.level == PluginDiagnosticLevel::Error)
287 })
288 }
289
290 #[must_use]
291 pub fn state_error(&self) -> Option<&str> {
292 self.state_error.as_deref()
293 }
294
295 #[must_use]
296 pub fn state_path(&self) -> Option<&Path> {
297 self.state_path.as_deref()
298 }
299
300 /// The pre-dotenv user plugins root, when this registry was built from a
301 /// discovery context. Registries without one (tests, fail-closed ad-hoc
302 /// values) return `None`, and the mutation controller refuses to write.
303 #[must_use]
304 pub fn user_plugins_dir(&self) -> Option<&Path> {
305 self.discovery_context
306 .as_ref()
307 .map(|context| context.user_plugins_dir())
308 }
309
310 /// Remove the persisted state entry for a bundle. This is the uninstall
311 /// hook (#5182): the caller deletes the bundle bits first, then prunes
312 /// the entry through the same locked, fail-closed state transaction used
313 /// by trust/enable/disable/revoke.
314 pub fn prune_state_entry(&mut self, selector: &str) -> Result<(), String> {
315 let id = self
316 .resolve_id(selector)
317 .cloned()
318 .ok_or_else(|| format!("Plugin bundle `{selector}` was not found"))?;
319 self.commit_state_change(|state| {
320 state.plugins.remove(&id);
321 Ok(())
322 })
323 }
324
325 pub fn trust(&mut self, selector: &str) -> Result<(), String> {
326 let plugin = self
327 .get(selector)
328 .ok_or_else(|| format!("Plugin bundle `{selector}` was not found"))?;
329 let plugin = plugin.clone();
330 let id = plugin.id.clone();
331 let state_path = self
332 .state_path
333 .as_deref()
334 .ok_or_else(|| "Plugin registry has no persistence store".to_string())?;
335 stage_bundle(state_path, &plugin)?;
336 let receipt = TrustReceipt {
337 content_hash: plugin.content_hash.clone(),
338 capability_hash: plugin.capability_hash.clone(),
339 reviewed_capabilities: plugin.inventory.clone(),
340 reviewed_at: chrono::Utc::now().to_rfc3339(),
341 };
342 self.commit_state_change(|state| {
343 let entry = state.plugins.entry(id).or_default();
344 entry.generation = entry
345 .generation
346 .checked_add(1)
347 .ok_or_else(|| "Plugin authority generation is exhausted".to_string())?;
348 // Trust records review and staging only. Even if an older state
349 // kept the enablement bit across revocation or content drift,
350 // re-review must never reactivate the bundle implicitly.
351 entry.enabled = false;
352 entry.trust = Some(receipt.clone());
353 entry.review_history.push(receipt);
354 if entry.review_history.len() > MAX_REVIEW_HISTORY {
355 let remove = entry.review_history.len() - MAX_REVIEW_HISTORY;
356 entry.review_history.drain(..remove);
357 }
358 Ok(())
359 })
360 }
361
362 pub fn revoke_trust(&mut self, selector: &str) -> Result<(), String> {
363 let id = self
364 .resolve_id(selector)
365 .cloned()
366 .ok_or_else(|| format!("Plugin bundle `{selector}` was not found"))?;
367 self.commit_state_change(|state| {
368 let entry = state.plugins.entry(id).or_default();
369 entry.generation = entry
370 .generation
371 .checked_add(1)
372 .ok_or_else(|| "Plugin authority generation is exhausted".to_string())?;
373 entry.trust = None;
374 Ok(())
375 })
376 }
377
378 pub fn enable(&mut self, selector: &str) -> Result<(), String> {
379 let plugin = self
380 .get(selector)
381 .ok_or_else(|| format!("Plugin bundle `{selector}` was not found"))?;
382 if !plugin.trusted() {
383 return Err(format!(
384 "Plugin bundle `{}` requires capability review before enablement (trust: {})",
385 plugin.name(),
386 plugin.trust_status.as_str()
387 ));
388 }
389 if plugin.staged_root.is_none() {
390 return Err(format!(
391 "Plugin bundle `{}` has no verified Codewhale runtime snapshot; review and trust it again before enablement",
392 plugin.name()
393 ));
394 }
395 if !plugin.applicable {
396 return Err(format!(
397 "Plugin bundle `{}` does not apply to this host",
398 plugin.name()
399 ));
400 }
401 let unsupported = plugin.inventory.unsupported_labels();
402 if !unsupported.is_empty() {
403 return Err(format!(
404 "Plugin bundle `{}` declares v0.9.1-inactive capabilities: {}",
405 plugin.name(),
406 unsupported.join(", ")
407 ));
408 }
409 let id = plugin.id.clone();
410 self.commit_state_change(|state| {
411 let entry = state.plugins.entry(id).or_default();
412 entry.generation = entry
413 .generation
414 .checked_add(1)
415 .ok_or_else(|| "Plugin authority generation is exhausted".to_string())?;
416 entry.enabled = true;
417 Ok(())
418 })
419 }
420
421 pub fn disable(&mut self, selector: &str) -> Result<(), String> {
422 let id = self
423 .resolve_id(selector)
424 .cloned()
425 .ok_or_else(|| format!("Plugin bundle `{selector}` was not found"))?;
426 self.commit_state_change(|state| {
427 let entry = state.plugins.entry(id).or_default();
428 entry.generation = entry
429 .generation
430 .checked_add(1)
431 .ok_or_else(|| "Plugin authority generation is exhausted".to_string())?;
432 entry.enabled = false;
433 Ok(())
434 })
435 }
436
437 fn commit_state_change(
438 &mut self,
439 mutate: impl FnOnce(&mut PluginStateFile) -> Result<(), String>,
440 ) -> Result<(), String> {
441 if let Some(error) = &self.state_error {
442 return Err(format!(
443 "Plugin state is fail-closed; repair or move the malformed state file before mutating it: {error}"
444 ));
445 }
446 let Some(path) = self.state_path.as_deref() else {
447 return Err("Plugin registry has no persistence store".to_string());
448 };
449 let lock_path = state_lock_path(path);
450 if let Some(parent) = lock_path.parent() {
451 ensure_private_plugin_state_directory(parent)?;
452 }
453 let lock_file = open_state_lock(&lock_path, true)?;
454 let mut lock = fd_lock::RwLock::new(lock_file);
455 let _guard = lock
456 .write()
457 .map_err(|e| format!("failed to lock plugin state for update: {e}"))?;
458 let mut next = load_state_unlocked(path)?;
459 mutate(&mut next)?;
460 save_state(path, &next)?;
461 self.state = next;
462 self.apply_state();
463 Ok(())
464 }
465
466 fn resolve_id(&self, selector: &str) -> Option<&PluginId> {
467 self.plugins
468 .keys()
469 .find(|id| id.as_str() == selector)
470 .or_else(|| self.names.get(selector))
471 }
472
473 #[must_use]
474 pub fn len(&self) -> usize {
475 self.plugins.len()
476 }
477
478 #[must_use]
479 pub fn is_empty(&self) -> bool {
480 self.plugins.is_empty()
481 }
482 }
483
484 fn load_state(path: &Path) -> Result<PluginStateFile, String> {
485 validate_existing_plugin_state_parent(path)?;
486 let lock_path = state_lock_path(path);
487 let lock_exists = path_entry_exists(&lock_path)?;
488 if lock_exists {
489 let lock_file = open_state_lock(&lock_path, false)?;
490 let lock = fd_lock::RwLock::new(lock_file);
491 let _guard = lock
492 .read()
493 .map_err(|e| format!("failed to read-lock plugin state: {e}"))?;
494 return load_state_unlocked(path);
495 }
496 load_state_unlocked(path)
497 }
498
499 fn load_state_unlocked(path: &Path) -> Result<PluginStateFile, String> {
500 let Some(mut file) = open_existing_regular_file(path, false)? else {
501 return Ok(PluginStateFile::default());
502 };
503 let mut raw = String::new();
504 file.read_to_string(&mut raw)
505 .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
506 let state: PluginStateFile = serde_json::from_str(&raw)
507 .map_err(|e| format!("failed to parse {}: {e}", path.display()))?;
508 if state.schema_version != STATE_SCHEMA_VERSION {
509 return Err(format!(
510 "unsupported plugin state schema {}; expected {STATE_SCHEMA_VERSION}",
511 state.schema_version
512 ));
513 }
514 Ok(state)
515 }
516
517 fn save_state(path: &Path, state: &PluginStateFile) -> Result<(), String> {
518 save_state_with_hardener(path, state, harden_plugin_state_file)
519 }
520
521 fn save_state_with_hardener(
522 path: &Path,
523 state: &PluginStateFile,
524 harden_temporary: impl FnOnce(&Path) -> Result<(), String>,
525 ) -> Result<(), String> {
526 let parent = path
527 .parent()
528 .filter(|parent| !parent.as_os_str().is_empty())
529 .ok_or_else(|| "Plugin state path must have a private parent directory".to_string())?;
530 ensure_private_plugin_state_directory(parent)?;
531
532 let mut body = serde_json::to_string_pretty(state)
533 .map_err(|error| format!("failed to serialize {}: {error}", path.display()))?;
534 body.push('\n');
535 let mut temporary = tempfile::NamedTempFile::new_in(parent)
536 .map_err(|error| format!("failed to create private plugin state temp file: {error}"))?;
537 temporary
538 .write_all(body.as_bytes())
539 .map_err(|error| format!("failed to write private plugin state temp file: {error}"))?;
540 temporary
541 .flush()
542 .and_then(|()| temporary.as_file().sync_all())
543 .map_err(|error| format!("failed to flush private plugin state temp file: {error}"))?;
544
545 // Restrict the exact temporary object before its atomic rename publishes
546 // it under the stable state path. Post-publish hardening leaves a Windows
547 // race in which another local principal can open the inherited DACL.
548 #[cfg(windows)]
549 {
550 // `NamedTempFile` keeps a writer handle open. The ACL hardener
551 // intentionally opens its target with FILE_SHARE_READ only, so close
552 // that writer before safely reopening the name for ACL mutation. Its
553 // parent was hardened above, which prevents another principal from
554 // replacing the temporary entry between those operations.
555 let temporary = temporary.into_temp_path();
556 harden_temporary(temporary.as_ref())?;
557 persist_plugin_state(temporary, path)
558 }
559 #[cfg(not(windows))]
560 {
561 harden_temporary(temporary.path())?;
562 persist_plugin_state(temporary, path)
563 }
564 }
565
566 #[cfg(unix)]
567 fn persist_plugin_state(temporary: tempfile::NamedTempFile, path: &Path) -> Result<(), String> {
568 persist_plugin_state_with_directory_sync(temporary, path, fs::File::sync_all)
569 }
570
571 #[cfg(unix)]
572 fn persist_plugin_state_with_directory_sync(
573 temporary: tempfile::NamedTempFile,
574 path: &Path,
575 sync_directory: impl FnOnce(&fs::File) -> std::io::Result<()>,
576 ) -> Result<(), String> {
577 use std::os::unix::fs::OpenOptionsExt as _;
578
579 temporary
580 .persist(path)
581 .map_err(|error| error.error)
582 .map_err(|error| format!("failed to atomically persist {}: {error}", path.display()))?;
583 let parent = path
584 .parent()
585 .filter(|parent| !parent.as_os_str().is_empty())
586 .ok_or_else(|| "Plugin state path must have a private parent directory".to_string())?;
587 let directory = OpenOptions::new()
588 .read(true)
589 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
590 .open(parent)
591 .map_err(|error| {
592 format!("failed to open plugin state directory for durability sync: {error}")
593 })?;
594 sync_directory(&directory).map_err(|error| {
595 format!(
596 "plugin state was published but its directory durability could not be confirmed: {error}"
597 )
598 })
599 }
600
601 #[cfg(windows)]
602 fn persist_plugin_state(mut temporary: tempfile::TempPath, path: &Path) -> Result<(), String> {
603 use std::os::windows::ffi::OsStrExt as _;
604 use windows::Win32::Storage::FileSystem::{
605 FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_TEMPORARY, MOVEFILE_REPLACE_EXISTING,
606 MOVEFILE_WRITE_THROUGH, MoveFileExW, SetFileAttributesW,
607 };
608 use windows::core::PCWSTR;
609
610 fn wide_path(path: &Path) -> Vec<u16> {
611 path.as_os_str().encode_wide().chain(Some(0)).collect()
612 }
613
614 let temporary_path = temporary.to_path_buf();
615 let temporary_wide = wide_path(&temporary_path);
616 let destination_wide = wide_path(path);
617 // NamedTempFile marks the source as temporary. Clear only that temporary
618 // caching hint before publication, matching tempfile's own persistence
619 // contract while retaining the owner-only DACL applied above.
620 unsafe {
621 SetFileAttributesW(
622 PCWSTR::from_raw(temporary_wide.as_ptr()),
623 FILE_ATTRIBUTE_NORMAL,
624 )
625 }
626 .map_err(|error| {
627 format!("failed to prepare private plugin state temp file for publication: {error}")
628 })?;
629
630 if let Err(error) = unsafe {
631 MoveFileExW(
632 PCWSTR::from_raw(temporary_wide.as_ptr()),
633 PCWSTR::from_raw(destination_wide.as_ptr()),
634 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
635 )
636 } {
637 // Restore tempfile's cleanup hint on the still-private source. The
638 // stable state path remains untouched when MoveFileExW fails.
639 let _ = unsafe {
640 SetFileAttributesW(
641 PCWSTR::from_raw(temporary_wide.as_ptr()),
642 FILE_ATTRIBUTE_TEMPORARY,
643 )
644 };
645 return Err(format!(
646 "failed to atomically and durably persist {}: {error}",
647 path.display()
648 ));
649 }
650
651 // The old temporary pathname no longer exists. Disarm TempPath cleanup.
652 temporary.disable_cleanup(true);
653 Ok(())
654 }
655
656 #[cfg(all(not(unix), not(windows)))]
657 fn persist_plugin_state(temporary: tempfile::NamedTempFile, path: &Path) -> Result<(), String> {
658 temporary
659 .persist(path)
660 .map_err(|error| error.error)
661 .map(|_| ())
662 .map_err(|error| format!("failed to atomically persist {}: {error}", path.display()))
663 }
664
665 fn state_lock_path(path: &Path) -> PathBuf {
666 let mut name = path
667 .file_name()
668 .map(|name| name.to_os_string())
669 .unwrap_or_else(|| "state.json".into());
670 name.push(".lock");
671 path.with_file_name(name)
672 }
673
674 #[cfg(not(windows))]
675 fn open_state_lock(path: &Path, create: bool) -> Result<fs::File, String> {
676 let mut options = OpenOptions::new();
677 options
678 .read(true)
679 .write(true)
680 .create(create)
681 .truncate(false);
682 #[cfg(unix)]
683 {
684 use std::os::unix::fs::OpenOptionsExt;
685 options
686 .mode(0o600)
687 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
688 }
689 let file = options
690 .open(path)
691 .map_err(|e| format!("failed to open plugin state lock: {e}"))?;
692 validate_opened_regular_file(path, &file)?;
693 // Discovery/doctor opens existing locks with `create=false` and must be
694 // byte-for-byte and descriptor-for-descriptor non-mutating. ACL/mode
695 // hardening belongs only to trust/enable/disable/revoke updates.
696 if create {
697 harden_plugin_state_file(path)?;
698 }
699 Ok(file)
700 }
701
702 #[cfg(windows)]
703 fn open_state_lock(path: &Path, create: bool) -> Result<fs::File, String> {
704 use std::os::windows::fs::OpenOptionsExt as _;
705
706 const LOCK_ACCESS_WITH_OWNER: u32 = 0x001e_019f;
707 const LOCK_ACCESS_WITHOUT_OWNER: u32 = 0x0016_019f;
708
709 let (file, owner_mode) = if create {
710 match open_windows_state_lock(path, true, LOCK_ACCESS_WITH_OWNER) {
711 Ok(file) => (file, WindowsAclOwnerMode::NormalizeCurrentUser),
712 Err(error) if is_windows_access_denied(&error) => {
713 // The first attempt can only be retried when Windows denied
714 // WRITE_OWNER. Do not recreate the entry here: a disappeared
715 // lock is a concurrent mutation that must fail closed instead
716 // of turning into a fresh object with an unchecked owner.
717 let file = open_windows_state_lock(path, false, LOCK_ACCESS_WITHOUT_OWNER)
718 .map_err(|error| format!("failed to open plugin state lock: {error}"))?;
719 (file, WindowsAclOwnerMode::VerifyCurrentUser)
720 }
721 Err(error) => {
722 return Err(format!("failed to open plugin state lock: {error}"));
723 }
724 }
725 } else {
726 let mut options = OpenOptions::new();
727 options
728 .read(true)
729 .write(true)
730 .truncate(false)
731 // Open the reparse point itself. `validate_opened_regular_file`
732 // then rejects it instead of following it to an unrelated target.
733 .custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
734 let file = options
735 .open(path)
736 .map_err(|error| format!("failed to open plugin state lock: {error}"))?;
737 (file, WindowsAclOwnerMode::VerifyCurrentUser)
738 };
739
740 validate_opened_regular_file(path, &file)?;
741 // Discovery/doctor opens existing locks with `create=false` and must be
742 // byte-for-byte and descriptor-for-descriptor non-mutating. ACL/mode
743 // hardening belongs only to trust/enable/disable/revoke updates.
744 if create {
745 harden_opened_plugin_state_file(path, &file, owner_mode)?;
746 }
747 Ok(file)
748 }
749
750 #[cfg(windows)]
751 fn open_windows_state_lock(
752 path: &Path,
753 create: bool,
754 access_mode: u32,
755 ) -> std::io::Result<fs::File> {
756 use std::os::windows::fs::OpenOptionsExt as _;
757
758 let mut options = OpenOptions::new();
759 options
760 .read(true)
761 .write(true)
762 .create(create)
763 .truncate(false)
764 // Open the reparse point itself. `validate_opened_regular_file` then
765 // rejects it instead of following it to an unrelated ACL target.
766 .custom_flags(0x0020_0000) // FILE_FLAG_OPEN_REPARSE_POINT
767 .access_mode(access_mode)
768 .open(path)
769 }
770
771 fn path_entry_exists(path: &Path) -> Result<bool, String> {
772 match fs::symlink_metadata(path) {
773 Ok(_) => Ok(true),
774 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
775 Err(error) => Err(format!("failed to inspect {}: {error}", path.display())),
776 }
777 }
778
779 /// Open an existing state file without following its final link/reparse point.
780 /// `None` is returned only for a genuinely absent entry; an existing unsafe
781 /// object always fails closed.
782 fn open_existing_regular_file(path: &Path, write: bool) -> Result<Option<fs::File>, String> {
783 if !path_entry_exists(path)? {
784 return Ok(None);
785 }
786 let mut options = OpenOptions::new();
787 options.read(true).write(write);
788 #[cfg(unix)]
789 {
790 use std::os::unix::fs::OpenOptionsExt as _;
791 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
792 }
793 #[cfg(windows)]
794 {
795 use std::os::windows::fs::OpenOptionsExt as _;
796 options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
797 }
798 let file = options
799 .open(path)
800 .map_err(|e| format!("failed to open {} safely: {e}", path.display()))?;
801 validate_opened_regular_file(path, &file)?;
802 Ok(Some(file))
803 }
804
805 #[cfg(unix)]
806 fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), String> {
807 use std::os::unix::fs::MetadataExt as _;
808
809 let metadata = file
810 .metadata()
811 .map_err(|e| format!("failed to inspect opened {}: {e}", path.display()))?;
812 if !metadata.is_file() || metadata.nlink() != 1 {
813 return Err(format!(
814 "{} must be one regular, non-hard-linked file",
815 path.display()
816 ));
817 }
818 Ok(())
819 }
820
821 #[cfg(windows)]
822 fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), String> {
823 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
824 let metadata = file
825 .metadata()
826 .map_err(|e| format!("failed to inspect opened {}: {e}", path.display()))?;
827 let identity = windows_file_identity(file)
828 .map_err(|e| format!("failed to identify opened {}: {e}", path.display()))?;
829 if !metadata.is_file()
830 || identity.attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
831 || identity.links != 1
832 {
833 return Err(format!(
834 "{} must be one regular, non-reparse, non-hard-linked file",
835 path.display()
836 ));
837 }
838 Ok(())
839 }
840
841 #[cfg(all(not(unix), not(windows)))]
842 fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), String> {
843 let metadata = file
844 .metadata()
845 .map_err(|e| format!("failed to inspect opened {}: {e}", path.display()))?;
846 if !metadata.is_file() {
847 return Err(format!("{} must be a regular file", path.display()));
848 }
849 Ok(())
850 }
851
852 fn validate_existing_plugin_state_parent(path: &Path) -> Result<(), String> {
853 let Some(parent) = path
854 .parent()
855 .filter(|parent| !parent.as_os_str().is_empty())
856 else {
857 return Ok(());
858 };
859 match fs::symlink_metadata(parent) {
860 Ok(_) => validate_plugin_state_directory_for_read(parent),
861 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
862 Err(error) => Err(format!(
863 "failed to inspect plugin state directory {}: {error}",
864 parent.display()
865 )),
866 }
867 }
868
869 #[cfg(unix)]
870 fn validate_plugin_state_directory_for_read(path: &Path) -> Result<(), String> {
871 use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _};
872
873 let directory = OpenOptions::new()
874 .read(true)
875 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
876 .open(path)
877 .map_err(|error| {
878 format!("failed to open plugin state directory without following links: {error}")
879 })?;
880 let metadata = directory
881 .metadata()
882 .map_err(|error| format!("failed to inspect opened plugin state directory: {error}"))?;
883 // SAFETY: geteuid has no pointer or lifetime preconditions.
884 let effective_uid = unsafe { libc::geteuid() };
885 validate_unix_plugin_state_directory_fields(
886 metadata.is_dir(),
887 metadata.uid(),
888 metadata.permissions().mode(),
889 effective_uid,
890 )
891 }
892
893 #[cfg(unix)]
894 fn validate_unix_plugin_state_directory_fields(
895 is_directory: bool,
896 owner_uid: u32,
897 mode: u32,
898 effective_uid: u32,
899 ) -> Result<(), String> {
900 if !is_directory || owner_uid != effective_uid || mode & 0o077 != 0 {
901 return Err(
902 "Plugin state directory must be current-user-owned and inaccessible to group or other users"
903 .to_string(),
904 );
905 }
906 Ok(())
907 }
908
909 #[cfg(not(unix))]
910 fn validate_plugin_state_directory_for_read(_path: &Path) -> Result<(), String> {
911 Ok(())
912 }
913
914 #[cfg(unix)]
915 fn ensure_private_plugin_state_directory(path: &Path) -> Result<(), String> {
916 use std::os::unix::fs::DirBuilderExt as _;
917
918 if !path_entry_exists(path)? {
919 let mut builder = fs::DirBuilder::new();
920 builder.recursive(true).mode(0o700);
921 builder
922 .create(path)
923 .map_err(|error| format!("failed to create plugin state directory: {error}"))?;
924 }
925 validate_plugin_state_directory_for_read(path)
926 }
927
928 #[cfg(windows)]
929 fn ensure_private_plugin_state_directory(path: &Path) -> Result<(), String> {
930 fs::create_dir_all(path)
931 .map_err(|error| format!("failed to create plugin state directory: {error}"))?;
932 set_windows_owner_only_acl(path)
933 }
934
935 #[cfg(all(not(unix), not(windows)))]
936 fn ensure_private_plugin_state_directory(path: &Path) -> Result<(), String> {
937 fs::create_dir_all(path)
938 .map_err(|error| format!("failed to create plugin state directory: {error}"))
939 }
940
941 #[cfg(windows)]
942 fn harden_plugin_state_file(path: &Path) -> Result<(), String> {
943 set_windows_owner_only_acl(path)
944 }
945
946 #[cfg(windows)]
947 fn harden_opened_plugin_state_file(
948 path: &Path,
949 file: &fs::File,
950 owner_mode: WindowsAclOwnerMode,
951 ) -> Result<(), String> {
952 validate_opened_regular_file(path, file)?;
953 ensure_windows_registry_path_still_opened(path, file)?;
954 apply_windows_owner_only_acl(file, 0x001f_01ff, owner_mode)
955 }
956
957 #[cfg(unix)]
958 fn harden_plugin_state_file(path: &Path) -> Result<(), String> {
959 use std::os::unix::fs::PermissionsExt as _;
960 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| {
961 format!(
962 "failed to restrict plugin state file permissions for {}: {error}",
963 path.display()
964 )
965 })
966 }
967
968 #[cfg(all(not(unix), not(windows)))]
969 fn harden_plugin_state_file(_path: &Path) -> Result<(), String> {
970 Ok(())
971 }
972
973 fn runtime_stage_path(state_path: &Path, id: &PluginId, content_hash: &str) -> PathBuf {
974 let mut hasher = Sha256::new();
975 hasher.update(b"codewhale-plugin-stage-v2\0");
976 hasher.update(id.as_str().as_bytes());
977 let key = hasher
978 .finalize()
979 .iter()
980 .map(|byte| format!("{byte:02x}"))
981 .collect::<String>();
982 let state_parent = state_path.parent().unwrap_or_else(|| Path::new("."));
983 let state_parent = state_parent
984 .canonicalize()
985 .unwrap_or_else(|_| state_parent.to_path_buf());
986 state_parent
987 .join(".runtime")
988 .join("v2")
989 .join(key)
990 .join(content_hash)
991 }
992
993 fn staged_bundle_matches(root: &Path, content_hash: &str, capability_hash: &str) -> bool {
994 super::manifest::PluginManifest::validate_from_path(&root.join("plugin.toml")).is_ok_and(
995 |validated| {
996 validated.content_hash == content_hash
997 && validated.capability_hash == capability_hash
998 && root
999 .canonicalize()
1000 .is_ok_and(|root| validated.canonical_root == root)
1001 },
1002 )
1003 }
1004
1005 fn stage_bundle(state_path: &Path, plugin: &LoadedPlugin) -> Result<PathBuf, String> {
1006 // Resolve the state directory before deriving the content-addressed path.
1007 // On macOS an existing ancestor such as `/var` canonicalizes to
1008 // `/private/var`; when the final `state/` directory does not exist yet,
1009 // deriving the destination first would preserve the non-canonical prefix
1010 // and the subsequent containment proof would correctly reject it as an
1011 // escape. Trust is already the mutating boundary, so creating this private
1012 // parent here is both safe and necessary for a stable path identity.
1013 let state_parent = state_path
1014 .parent()
1015 .ok_or_else(|| "plugin state path has no parent directory".to_string())?;
1016 ensure_private_plugin_state_directory(state_parent)?;
1017 let destination = runtime_stage_path(state_path, &plugin.id, &plugin.content_hash);
1018 if destination.exists() {
1019 if !staged_bundle_matches(&destination, &plugin.content_hash, &plugin.capability_hash) {
1020 return Err(
1021 "Existing Codewhale plugin runtime snapshot failed content validation; remove the exact .runtime entry and review again"
1022 .to_string(),
1023 );
1024 }
1025 // Trust is a mutating boundary, so it may upgrade an older verified
1026 // snapshot to the finalized non-writable permission contract.
1027 harden_staged_tree(&destination)?;
1028 return Ok(destination.canonicalize().unwrap_or(destination));
1029 }
1030
1031 let parent = destination
1032 .parent()
1033 .ok_or_else(|| "plugin runtime snapshot has no parent".to_string())?;
1034 ensure_private_runtime_parent(state_path, parent)?;
1035 let temporary = parent.join(format!(".staging-{}", uuid::Uuid::new_v4().simple()));
1036 fs::create_dir(&temporary)
1037 .map_err(|e| format!("failed to create temporary plugin runtime snapshot: {e}"))?;
1038 set_owner_only_directory(&temporary)?;
1039
1040 let staged = (|| {
1041 copy_bundle_tree(&plugin.canonical_root, &temporary)?;
1042 if !staged_bundle_matches(&temporary, &plugin.content_hash, &plugin.capability_hash) {
1043 return Err(
1044 "Plugin bundle changed while Codewhale was staging it; no runtime authority was granted"
1045 .to_string(),
1046 );
1047 }
1048 // Finalize descendants before activation, but keep the temporary root
1049 // owner-writable through the atomic rename. macOS rejects renaming a
1050 // directory whose own mode is already 0500 even when both parents are
1051 // writable. The destination root is hardened immediately after the
1052 // rename, before its path is returned or persisted as authority.
1053 harden_staged_tree_contents(&temporary)?;
1054 if let Err(error) = fs::rename(&temporary, &destination) {
1055 // Another process may have won the same content-addressed race.
1056 // Reuse only after exact validation and hardening at this explicit
1057 // mutation boundary; every other rename failure remains fatal.
1058 if staged_bundle_matches(&destination, &plugin.content_hash, &plugin.capability_hash) {
1059 harden_staged_tree(&destination)?;
1060 return destination.canonicalize().map_err(|e| {
1061 format!("failed to finalize raced plugin runtime snapshot path: {e}")
1062 });
1063 }
1064 return Err(format!(
1065 "failed to activate content-addressed plugin runtime snapshot: {error}"
1066 ));
1067 }
1068 set_staged_read_only_directory(&destination)?;
1069 destination
1070 .canonicalize()
1071 .map_err(|e| format!("failed to finalize plugin runtime snapshot path: {e}"))
1072 })();
1073 if staged.is_err() && temporary.exists() {
1074 let _ = fs::remove_dir_all(&temporary);
1075 }
1076 staged
1077 }
1078
1079 fn ensure_private_runtime_parent(state_path: &Path, parent: &Path) -> Result<(), String> {
1080 let configured_base = state_path
1081 .parent()
1082 .ok_or_else(|| "plugin state path has no parent directory".to_string())?;
1083 ensure_private_plugin_state_directory(configured_base)?;
1084 let base_metadata = fs::symlink_metadata(configured_base)
1085 .map_err(|e| format!("failed to inspect plugin state directory: {e}"))?;
1086 if metadata_is_link_or_reparse(&base_metadata) || !base_metadata.is_dir() {
1087 return Err(
1088 "plugin state directory must not be a symbolic link or reparse point".to_string(),
1089 );
1090 }
1091 // `runtime_stage_path` canonicalizes the same parent. Match that identity
1092 // here as well (notably `/var` -> `/private/var` on macOS) before proving
1093 // that every runtime component stays beneath the state directory.
1094 let base = configured_base
1095 .canonicalize()
1096 .map_err(|e| format!("failed to canonicalize plugin state directory: {e}"))?;
1097 let relative = parent
1098 .strip_prefix(&base)
1099 .or_else(|_| parent.strip_prefix(configured_base))
1100 .map_err(|_| "plugin runtime snapshot escaped the state directory".to_string())?;
1101 let mut cursor = base;
1102 for component in relative.components() {
1103 use std::path::Component;
1104 let Component::Normal(component) = component else {
1105 return Err("plugin runtime snapshot contains an invalid path component".to_string());
1106 };
1107 cursor.push(component);
1108 match fs::symlink_metadata(&cursor) {
1109 Ok(metadata) if metadata_is_link_or_reparse(&metadata) => {
1110 return Err(
1111 "plugin runtime snapshot directory may not traverse symbolic links or reparse points"
1112 .to_string(),
1113 );
1114 }
1115 Ok(metadata) if !metadata.is_dir() => {
1116 return Err("plugin runtime snapshot parent is not a directory".to_string());
1117 }
1118 Ok(_) => {}
1119 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1120 match fs::create_dir(&cursor) {
1121 Ok(()) => {}
1122 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1123 let metadata = fs::symlink_metadata(&cursor).map_err(|e| {
1124 format!(
1125 "failed to inspect concurrently created plugin runtime snapshot directory: {e}"
1126 )
1127 })?;
1128 if metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
1129 return Err(
1130 "concurrently created plugin runtime snapshot parent is not a safe directory"
1131 .to_string(),
1132 );
1133 }
1134 }
1135 Err(error) => {
1136 return Err(format!(
1137 "failed to create plugin runtime snapshot directory: {error}"
1138 ));
1139 }
1140 }
1141 }
1142 Err(error) => {
1143 return Err(format!(
1144 "failed to inspect plugin runtime snapshot directory: {error}"
1145 ));
1146 }
1147 }
1148 set_owner_only_directory(&cursor)?;
1149 }
1150 Ok(())
1151 }
1152
1153 #[derive(Default)]
1154 struct StageBudget {
1155 files: usize,
1156 bytes: u64,
1157 }
1158
1159 fn copy_bundle_tree(source: &Path, destination: &Path) -> Result<(), String> {
1160 let mut budget = StageBudget::default();
1161 copy_bundle_tree_bounded(source, destination, &mut budget)
1162 }
1163
1164 #[cfg(not(unix))]
1165 fn copy_bundle_tree_bounded(
1166 source: &Path,
1167 destination: &Path,
1168 budget: &mut StageBudget,
1169 ) -> Result<(), String> {
1170 use std::io::Read as _;
1171 let metadata = fs::symlink_metadata(source)
1172 .map_err(|e| format!("failed to inspect plugin content during staging: {e}"))?;
1173 if metadata_is_link_or_reparse(&metadata) {
1174 return Err("Plugin content changed into a symbolic link during staging".to_string());
1175 }
1176 if !metadata.is_dir() {
1177 return Err("Plugin runtime source is not a directory".to_string());
1178 }
1179 #[cfg(windows)]
1180 let source_guard = open_windows_bundle_directory(source)?;
1181 #[cfg(windows)]
1182 ensure_windows_registry_path_still_opened(source, &source_guard)?;
1183 let mut entries = fs::read_dir(source)
1184 .map_err(|e| format!("failed to read plugin content during staging: {e}"))?
1185 .collect::<Result<Vec<_>, _>>()
1186 .map_err(|e| format!("failed to enumerate plugin content during staging: {e}"))?;
1187 entries.sort_by_key(fs::DirEntry::file_name);
1188 for entry in entries {
1189 let source_path = entry.path();
1190 let destination_path = destination.join(entry.file_name());
1191 let metadata = fs::symlink_metadata(&source_path)
1192 .map_err(|e| format!("failed to inspect plugin entry during staging: {e}"))?;
1193 if metadata_is_link_or_reparse(&metadata) {
1194 return Err("Plugin content may not contain symbolic links".to_string());
1195 }
1196 if metadata.is_dir() {
1197 fs::create_dir(&destination_path)
1198 .map_err(|e| format!("failed to create staged plugin directory: {e}"))?;
1199 set_owner_only_directory(&destination_path)?;
1200 copy_bundle_tree_bounded(&source_path, &destination_path, budget)?;
1201 } else if metadata.is_file() {
1202 budget.files = budget.files.saturating_add(1);
1203 if budget.files > 4_096 {
1204 return Err("Plugin content exceeded the staging file limit".to_string());
1205 }
1206 let mut source_file = super::manifest::open_bundle_file(&source_path)
1207 .map_err(|e| format!("failed to open plugin file without following links: {e}"))?;
1208 #[cfg(windows)]
1209 ensure_windows_registry_path_still_opened(&source_path, &source_file)?;
1210 let mut destination_file = OpenOptions::new()
1211 .create_new(true)
1212 .write(true)
1213 .open(&destination_path)
1214 .map_err(|e| format!("failed to create staged plugin file: {e}"))?;
1215 let mut buffer = [0_u8; 64 * 1024];
1216 loop {
1217 let read = source_file
1218 .read(&mut buffer)
1219 .map_err(|e| format!("failed to read plugin file during staging: {e}"))?;
1220 if read == 0 {
1221 break;
1222 }
1223 budget.bytes = budget.bytes.saturating_add(read as u64);
1224 if budget.bytes > 64 * 1024 * 1024 {
1225 return Err("Plugin content exceeded the staging byte limit".to_string());
1226 }
1227 destination_file
1228 .write_all(&buffer[..read])
1229 .map_err(|e| format!("failed to write staged plugin file: {e}"))?;
1230 }
1231 destination_file
1232 .sync_all()
1233 .map_err(|e| format!("failed to sync staged plugin file: {e}"))?;
1234 #[cfg(windows)]
1235 // The containing staging directory is already owner-only. Close
1236 // the writer before reopening this path with the ACL hardener's
1237 // deliberately restrictive share mode.
1238 drop(destination_file);
1239 preserve_owner_only_file_mode(&destination_path, &metadata)?;
1240 #[cfg(windows)]
1241 ensure_windows_registry_path_still_opened(&source_path, &source_file)?;
1242 } else {
1243 return Err(
1244 "Plugin content must contain only regular files and directories".to_string(),
1245 );
1246 }
1247 }
1248 #[cfg(windows)]
1249 ensure_windows_registry_path_still_opened(source, &source_guard)?;
1250 Ok(())
1251 }
1252
1253 #[cfg(windows)]
1254 fn open_windows_bundle_directory(path: &Path) -> Result<fs::File, String> {
1255 use std::os::windows::fs::OpenOptionsExt as _;
1256
1257 let file = OpenOptions::new()
1258 .read(true)
1259 .share_mode(0x0000_0001)
1260 .custom_flags(0x0220_0000) // BACKUP_SEMANTICS | OPEN_REPARSE_POINT
1261 .open(path)
1262 .map_err(|e| format!("failed to open plugin directory safely: {e}"))?;
1263 let metadata = file
1264 .metadata()
1265 .map_err(|e| format!("failed to inspect opened plugin directory: {e}"))?;
1266 let identity = windows_file_identity(&file)
1267 .map_err(|e| format!("failed to identify opened plugin directory: {e}"))?;
1268 if !metadata.is_dir() || identity.attributes & 0x0000_0400 != 0 {
1269 return Err("Plugin directory changed into a reparse point during staging".to_string());
1270 }
1271 Ok(file)
1272 }
1273
1274 #[cfg(windows)]
1275 fn ensure_windows_registry_path_still_opened(path: &Path, opened: &fs::File) -> Result<(), String> {
1276 let after = fs::symlink_metadata(path)
1277 .map_err(|e| format!("failed to re-inspect staged source path: {e}"))?;
1278 if metadata_is_link_or_reparse(&after) {
1279 return Err("Plugin path changed into a reparse point during staging".to_string());
1280 }
1281 let expect_directory = if after.is_dir() {
1282 true
1283 } else if after.is_file() {
1284 false
1285 } else {
1286 return Err("Plugin path changed into an unsupported object during staging".to_string());
1287 };
1288 let current = super::manifest::open_bundle_identity_probe(path, expect_directory)
1289 .map_err(|e| format!("failed to reopen staged source path safely: {e}"))?;
1290 let opened = windows_file_identity(opened)
1291 .map_err(|e| format!("failed to identify retained plugin handle: {e}"))?;
1292 let current = windows_file_identity(&current)
1293 .map_err(|e| format!("failed to identify current plugin path: {e}"))?;
1294 if opened.volume != current.volume || opened.index != current.index {
1295 return Err("Plugin path identity changed while staging".to_string());
1296 }
1297 if opened.links != 1 && after.is_file() {
1298 return Err("Plugin content may not contain hard-linked files".to_string());
1299 }
1300 Ok(())
1301 }
1302
1303 #[cfg(unix)]
1304 fn copy_bundle_tree_bounded(
1305 source: &Path,
1306 destination: &Path,
1307 budget: &mut StageBudget,
1308 ) -> Result<(), String> {
1309 use std::ffi::CString;
1310 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
1311 use std::os::unix::ffi::OsStrExt;
1312
1313 let source = CString::new(source.as_os_str().as_bytes())
1314 .map_err(|_| "plugin runtime source path contains an invalid byte".to_string())?;
1315 // SAFETY: `source` is a NUL-terminated path and successful descriptors
1316 // are immediately owned by `OwnedFd`.
1317 let fd = unsafe {
1318 libc::open(
1319 source.as_ptr(),
1320 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
1321 )
1322 };
1323 if fd < 0 {
1324 return Err(format!(
1325 "failed to open plugin root without following links: {}",
1326 std::io::Error::last_os_error()
1327 ));
1328 }
1329 // SAFETY: `fd` is a unique successful result from `open` above.
1330 let fd = unsafe { OwnedFd::from_raw_fd(fd) };
1331 copy_bundle_directory_fd(fd.as_raw_fd(), destination, budget)
1332 }
1333
1334 #[cfg(unix)]
1335 fn copy_bundle_directory_fd(
1336 source_fd: std::os::fd::RawFd,
1337 destination: &Path,
1338 budget: &mut StageBudget,
1339 ) -> Result<(), String> {
1340 use std::ffi::{CStr, CString, OsString};
1341 use std::io::Read as _;
1342 use std::mem::MaybeUninit;
1343 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
1344 use std::os::unix::ffi::OsStringExt;
1345
1346 // `fdopendir` owns its descriptor, so duplicate the directory fd retained
1347 // by this stack frame for subsequent `openat` calls.
1348 // SAFETY: `source_fd` is an open directory descriptor.
1349 let iter_fd = unsafe { libc::dup(source_fd) };
1350 if iter_fd < 0 {
1351 return Err(format!(
1352 "failed to duplicate plugin directory descriptor: {}",
1353 std::io::Error::last_os_error()
1354 ));
1355 }
1356 // SAFETY: `iter_fd` is a fresh descriptor and ownership transfers to DIR.
1357 let directory = unsafe { libc::fdopendir(iter_fd) };
1358 if directory.is_null() {
1359 // SAFETY: fdopendir failed, so ownership did not transfer.
1360 unsafe { libc::close(iter_fd) };
1361 return Err(format!(
1362 "failed to enumerate plugin directory safely: {}",
1363 std::io::Error::last_os_error()
1364 ));
1365 }
1366 let mut names = Vec::new();
1367 loop {
1368 // SAFETY: `directory` remains valid until closed below.
1369 let entry = unsafe { libc::readdir(directory) };
1370 if entry.is_null() {
1371 break;
1372 }
1373 // SAFETY: POSIX dirent d_name is NUL-terminated for returned entries.
1374 let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
1375 if name == b"." || name == b".." {
1376 continue;
1377 }
1378 names.push(OsString::from_vec(name.to_vec()));
1379 }
1380 // SAFETY: closes DIR and its duplicated descriptor exactly once.
1381 unsafe { libc::closedir(directory) };
1382 names.sort();
1383
1384 for name in names {
1385 let name_c = CString::new(name.clone().into_vec())
1386 .map_err(|_| "plugin entry name contains an invalid byte".to_string())?;
1387 let mut stat = MaybeUninit::<libc::stat>::zeroed();
1388 // SAFETY: source_fd and name are valid; stat points to writable memory.
1389 if unsafe {
1390 libc::fstatat(
1391 source_fd,
1392 name_c.as_ptr(),
1393 stat.as_mut_ptr(),
1394 libc::AT_SYMLINK_NOFOLLOW,
1395 )
1396 } != 0
1397 {
1398 return Err(format!(
1399 "failed to inspect plugin entry safely: {}",
1400 std::io::Error::last_os_error()
1401 ));
1402 }
1403 // SAFETY: fstatat initialized stat after returning success.
1404 let stat = unsafe { stat.assume_init() };
1405 let kind = stat.st_mode & libc::S_IFMT;
1406 let destination_path = destination.join(&name);
1407 if kind == libc::S_IFDIR {
1408 // SAFETY: openat is anchored to the already-open parent and
1409 // O_NOFOLLOW prevents a concurrent directory-to-symlink swap.
1410 let child_fd = unsafe {
1411 libc::openat(
1412 source_fd,
1413 name_c.as_ptr(),
1414 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
1415 )
1416 };
1417 if child_fd < 0 {
1418 return Err(format!(
1419 "failed to open plugin directory safely: {}",
1420 std::io::Error::last_os_error()
1421 ));
1422 }
1423 // SAFETY: unique descriptor returned by openat.
1424 let child_fd = unsafe { OwnedFd::from_raw_fd(child_fd) };
1425 fs::create_dir(&destination_path)
1426 .map_err(|e| format!("failed to create staged plugin directory: {e}"))?;
1427 set_owner_only_directory(&destination_path)?;
1428 copy_bundle_directory_fd(child_fd.as_raw_fd(), &destination_path, budget)?;
1429 } else if kind == libc::S_IFREG {
1430 if stat.st_nlink != 1 {
1431 return Err("Plugin content may not contain hard-linked files".to_string());
1432 }
1433 budget.files = budget.files.saturating_add(1);
1434 if budget.files > 4_096 {
1435 return Err("Plugin content exceeded the staging file limit".to_string());
1436 }
1437 // SAFETY: openat is anchored and O_NOFOLLOW prevents a file swap
1438 // to a symbolic link between metadata inspection and open.
1439 let file_fd = unsafe {
1440 libc::openat(
1441 source_fd,
1442 name_c.as_ptr(),
1443 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
1444 )
1445 };
1446 if file_fd < 0 {
1447 return Err(format!(
1448 "failed to open plugin file safely: {}",
1449 std::io::Error::last_os_error()
1450 ));
1451 }
1452 // SAFETY: unique descriptor returned by openat.
1453 let mut source_file = unsafe { fs::File::from_raw_fd(file_fd) };
1454 let opened = source_file
1455 .metadata()
1456 .map_err(|e| format!("failed to inspect opened plugin file: {e}"))?;
1457 if !opened.is_file() {
1458 return Err("Plugin entry changed type during staging".to_string());
1459 }
1460 let mut destination_file = OpenOptions::new()
1461 .create_new(true)
1462 .write(true)
1463 .open(&destination_path)
1464 .map_err(|e| format!("failed to create staged plugin file: {e}"))?;
1465 let mut buffer = [0_u8; 64 * 1024];
1466 loop {
1467 let read = source_file
1468 .read(&mut buffer)
1469 .map_err(|e| format!("failed to read plugin file during staging: {e}"))?;
1470 if read == 0 {
1471 break;
1472 }
1473 budget.bytes = budget.bytes.saturating_add(read as u64);
1474 if budget.bytes > 64 * 1024 * 1024 {
1475 return Err("Plugin content exceeded the staging byte limit".to_string());
1476 }
1477 destination_file
1478 .write_all(&buffer[..read])
1479 .map_err(|e| format!("failed to write staged plugin file: {e}"))?;
1480 }
1481 destination_file
1482 .sync_all()
1483 .map_err(|e| format!("failed to sync staged plugin file: {e}"))?;
1484 preserve_owner_only_file_mode(&destination_path, &opened)?;
1485 } else if kind == libc::S_IFLNK {
1486 return Err("Plugin content may not contain symbolic links".to_string());
1487 } else {
1488 return Err(
1489 "Plugin content must contain only regular files and directories".to_string(),
1490 );
1491 }
1492 }
1493 Ok(())
1494 }
1495
1496 fn harden_staged_tree(path: &Path) -> Result<(), String> {
1497 let metadata = fs::symlink_metadata(path)
1498 .map_err(|e| format!("failed to harden staged plugin content: {e}"))?;
1499 if metadata_is_link_or_reparse(&metadata) {
1500 return Err(
1501 "Staged plugin content changed into a symbolic link or reparse point before hardening"
1502 .to_string(),
1503 );
1504 }
1505 if metadata.is_dir() {
1506 let entries = fs::read_dir(path)
1507 .map_err(|e| format!("failed to read staged plugin content: {e}"))?
1508 .collect::<Result<Vec<_>, _>>()
1509 .map_err(|e| format!("failed to enumerate staged plugin content: {e}"))?;
1510 for entry in entries {
1511 harden_staged_tree(&entry.path())?;
1512 }
1513 set_staged_read_only_directory(path)?;
1514 } else if metadata.is_file() {
1515 set_staged_read_only_file(path, &metadata)?;
1516 } else {
1517 return Err("Staged plugin content changed type before activation".to_string());
1518 }
1519 Ok(())
1520 }
1521
1522 fn harden_staged_tree_contents(path: &Path) -> Result<(), String> {
1523 let metadata = fs::symlink_metadata(path)
1524 .map_err(|e| format!("failed to harden staged plugin root: {e}"))?;
1525 if metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
1526 return Err("Staged plugin root changed type before activation".to_string());
1527 }
1528 let entries = fs::read_dir(path)
1529 .map_err(|e| format!("failed to read staged plugin root: {e}"))?
1530 .collect::<Result<Vec<_>, _>>()
1531 .map_err(|e| format!("failed to enumerate staged plugin root: {e}"))?;
1532 for entry in entries {
1533 harden_staged_tree(&entry.path())?;
1534 }
1535 Ok(())
1536 }
1537
1538 #[cfg(unix)]
1539 fn set_staged_read_only_directory(path: &Path) -> Result<(), String> {
1540 use std::os::unix::fs::PermissionsExt as _;
1541 fs::set_permissions(path, fs::Permissions::from_mode(0o500))
1542 .map_err(|e| format!("failed to make staged plugin directory non-writable: {e}"))
1543 }
1544
1545 #[cfg(windows)]
1546 fn set_staged_read_only_directory(path: &Path) -> Result<(), String> {
1547 // GENERIC_READ | GENERIC_EXECUTE. The owner can inspect/traverse the
1548 // finalized stage but ordinary child processes cannot rewrite it through
1549 // inherited full-control directory ACEs.
1550 set_windows_owner_only_acl_with_mask(path, 0xa000_0000)
1551 }
1552
1553 #[cfg(all(not(unix), not(windows)))]
1554 fn set_staged_read_only_directory(_path: &Path) -> Result<(), String> {
1555 Err("Plugin runtime staging cannot make directories non-writable on this platform".to_string())
1556 }
1557
1558 #[cfg(unix)]
1559 fn set_staged_read_only_file(path: &Path, source: &fs::Metadata) -> Result<(), String> {
1560 preserve_owner_only_file_mode(path, source)
1561 }
1562
1563 #[cfg(windows)]
1564 fn set_staged_read_only_file(path: &Path, source: &fs::Metadata) -> Result<(), String> {
1565 preserve_owner_only_file_mode(path, source)
1566 }
1567
1568 #[cfg(all(not(unix), not(windows)))]
1569 fn set_staged_read_only_file(path: &Path, source: &fs::Metadata) -> Result<(), String> {
1570 preserve_owner_only_file_mode(path, source)
1571 }
1572
1573 #[cfg(unix)]
1574 fn set_owner_only_directory(path: &Path) -> Result<(), String> {
1575 use std::os::unix::fs::PermissionsExt;
1576 fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|e| {
1577 format!(
1578 "failed to restrict plugin runtime directory permissions for {}: {e}",
1579 path.display()
1580 )
1581 })
1582 }
1583
1584 #[cfg(windows)]
1585 fn set_owner_only_directory(path: &Path) -> Result<(), String> {
1586 set_windows_owner_only_acl(path)
1587 }
1588
1589 #[cfg(windows)]
1590 fn set_windows_owner_only_acl(path: &Path) -> Result<(), String> {
1591 set_windows_owner_only_acl_with_mask(path, 0x001f_01ff)
1592 }
1593
1594 #[cfg(windows)]
1595 #[derive(Clone, Copy)]
1596 enum WindowsAclOwnerMode {
1597 // The handle has WRITE_OWNER, so restoring the current-user ownership and
1598 // DACL together keeps the authority boundary atomic.
1599 NormalizeCurrentUser,
1600 // A previously hardened current-user-owned object may deliberately deny
1601 // WRITE_OWNER. Re-hardening may replace its DACL only after proving the
1602 // existing owner is still the current user.
1603 VerifyCurrentUser,
1604 }
1605
1606 #[cfg(windows)]
1607 enum WindowsAclTargetOpenError {
1608 Io(std::io::Error),
1609 Validation(String),
1610 }
1611
1612 #[cfg(windows)]
1613 impl WindowsAclTargetOpenError {
1614 fn should_retry_without_write_owner(&self) -> bool {
1615 matches!(self, Self::Io(error) if is_windows_access_denied(error))
1616 }
1617
1618 fn into_message(self) -> String {
1619 match self {
1620 Self::Io(error) => format!("failed to open Windows plugin ACL target safely: {error}"),
1621 Self::Validation(message) => message,
1622 }
1623 }
1624 }
1625
1626 #[cfg(windows)]
1627 fn is_windows_access_denied(error: &std::io::Error) -> bool {
1628 // Only retry the expected access denial from a missing WRITE_OWNER grant.
1629 // A sharing violation, missing path, reparse validation failure, or any
1630 // other open failure must remain fail-closed without opening a new handle.
1631 error.raw_os_error() == Some(5) // ERROR_ACCESS_DENIED
1632 }
1633
1634 #[cfg(windows)]
1635 fn open_windows_acl_target(
1636 path: &Path,
1637 access_mode: u32,
1638 ) -> Result<fs::File, WindowsAclTargetOpenError> {
1639 use std::os::windows::fs::OpenOptionsExt as _;
1640
1641 let target = OpenOptions::new()
1642 .access_mode(access_mode)
1643 .share_mode(0x0000_0001) // FILE_SHARE_READ
1644 .custom_flags(0x0220_0000) // BACKUP_SEMANTICS | OPEN_REPARSE_POINT
1645 .open(path)
1646 .map_err(WindowsAclTargetOpenError::Io)?;
1647 let opened = target.metadata().map_err(|error| {
1648 WindowsAclTargetOpenError::Validation(format!(
1649 "failed to inspect opened Windows plugin ACL target: {error}"
1650 ))
1651 })?;
1652 let opened_identity = windows_file_identity(&target).map_err(|error| {
1653 WindowsAclTargetOpenError::Validation(format!(
1654 "failed to identify opened Windows plugin ACL target: {error}"
1655 ))
1656 })?;
1657 if opened_identity.attributes & 0x0000_0400 != 0 || !(opened.is_file() || opened.is_dir()) {
1658 return Err(WindowsAclTargetOpenError::Validation(
1659 "Windows plugin ACL target changed into a reparse point or unsupported object"
1660 .to_string(),
1661 ));
1662 }
1663 ensure_windows_registry_path_still_opened(path, &target)
1664 .map_err(WindowsAclTargetOpenError::Validation)?;
1665 Ok(target)
1666 }
1667
1668 #[cfg(windows)]
1669 fn set_windows_owner_only_acl_with_mask(path: &Path, access_mask: u32) -> Result<(), String> {
1670 const ACL_ACCESS_WITH_OWNER: u32 = 0x0002_0000 | 0x0004_0000 | 0x0008_0000;
1671 const ACL_ACCESS_WITHOUT_OWNER: u32 = 0x0002_0000 | 0x0004_0000;
1672
1673 // Bind ACL mutation to the exact object opened without following a
1674 // reparse point. A pathname-only SetNamedSecurityInfoW call could inspect
1675 // a safe entry and then follow a junction substituted before the update.
1676 let before = fs::symlink_metadata(path)
1677 .map_err(|error| format!("failed to inspect Windows plugin ACL target: {error}"))?;
1678 if metadata_is_link_or_reparse(&before) || !(before.is_file() || before.is_dir()) {
1679 return Err(
1680 "Windows plugin ACL target must be a regular non-reparse file or directory".to_string(),
1681 );
1682 }
1683 let (target, owner_mode) = match open_windows_acl_target(path, ACL_ACCESS_WITH_OWNER) {
1684 Ok(target) => (target, WindowsAclOwnerMode::NormalizeCurrentUser),
1685 Err(error) if error.should_retry_without_write_owner() => {
1686 let target = open_windows_acl_target(path, ACL_ACCESS_WITHOUT_OWNER)
1687 .map_err(WindowsAclTargetOpenError::into_message)?;
1688 (target, WindowsAclOwnerMode::VerifyCurrentUser)
1689 }
1690 Err(error) => return Err(error.into_message()),
1691 };
1692
1693 apply_windows_owner_only_acl(&target, access_mask, owner_mode)
1694 }
1695
1696 #[cfg(windows)]
1697 fn apply_windows_owner_only_acl(
1698 target: &fs::File,
1699 access_mask: u32,
1700 owner_mode: WindowsAclOwnerMode,
1701 ) -> Result<(), String> {
1702 use std::mem::{MaybeUninit, size_of};
1703 use std::os::windows::io::AsRawHandle as _;
1704 use windows::Win32::Foundation::{CloseHandle, HANDLE, WIN32_ERROR};
1705 use windows::Win32::Security::Authorization::{SE_FILE_OBJECT, SetSecurityInfo};
1706 use windows::Win32::Security::{
1707 ACCESS_ALLOWED_ACE, ACL, ACL_REVISION, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION,
1708 GetLengthSid, GetTokenInformation, InitializeAcl, OBJECT_INHERIT_ACE,
1709 OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, TOKEN_QUERY, TOKEN_USER,
1710 TokenUser,
1711 };
1712 use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
1713
1714 let mut token = HANDLE::default();
1715 // SAFETY: output handle points to valid storage and the pseudo process
1716 // handle is valid for the current process.
1717 unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }
1718 .map_err(|error| format!("failed to open the current Windows security token: {error}"))?;
1719 let result = (|| {
1720 let mut required = 0_u32;
1721 // The first call intentionally obtains the required byte count.
1722 let _ = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut required) };
1723 if required < size_of::<TOKEN_USER>() as u32 {
1724 return Err("Windows token did not expose a current-user SID".to_string());
1725 }
1726 let words = (required as usize).div_ceil(size_of::<usize>());
1727 let mut token_buffer = vec![MaybeUninit::<usize>::zeroed(); words];
1728 // SAFETY: aligned buffer is at least `required` bytes and remains alive
1729 // for every SID/ACL operation below.
1730 unsafe {
1731 GetTokenInformation(
1732 token,
1733 TokenUser,
1734 Some(token_buffer.as_mut_ptr().cast()),
1735 required,
1736 &mut required,
1737 )
1738 }
1739 .map_err(|error| format!("failed to read the current Windows user SID: {error}"))?;
1740 // SAFETY: successful TokenUser query initialized a TOKEN_USER at the
1741 // beginning of the aligned buffer.
1742 let token_user = unsafe { &*token_buffer.as_ptr().cast::<TOKEN_USER>() };
1743 let sid = token_user.User.Sid;
1744 // SAFETY: SID comes from the successful token query above.
1745 let sid_len = unsafe { GetLengthSid(sid) } as usize;
1746 if sid_len == 0 {
1747 return Err("Windows current-user SID is invalid".to_string());
1748 }
1749 let acl_bytes =
1750 size_of::<ACL>() + size_of::<ACCESS_ALLOWED_ACE>() - size_of::<u32>() + sid_len;
1751 let acl_words = acl_bytes.div_ceil(size_of::<usize>());
1752 let mut acl_buffer = vec![MaybeUninit::<usize>::zeroed(); acl_words];
1753 let acl = acl_buffer.as_mut_ptr().cast::<ACL>();
1754 // SAFETY: aligned ACL buffer is large enough for one full-access ACE
1755 // containing the current user SID.
1756 unsafe { InitializeAcl(acl, acl_bytes as u32, ACL_REVISION) }
1757 .map_err(|error| format!("failed to initialize a private Windows ACL: {error}"))?;
1758 unsafe {
1759 windows::Win32::Security::AddAccessAllowedAceEx(
1760 acl,
1761 ACL_REVISION,
1762 CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE,
1763 access_mask,
1764 sid,
1765 )
1766 }
1767 .map_err(|error| format!("failed to grant the current Windows user access: {error}"))?;
1768
1769 let (security_information, owner) = match owner_mode {
1770 WindowsAclOwnerMode::NormalizeCurrentUser => (
1771 OWNER_SECURITY_INFORMATION
1772 | DACL_SECURITY_INFORMATION
1773 | PROTECTED_DACL_SECURITY_INFORMATION,
1774 Some(sid),
1775 ),
1776 WindowsAclOwnerMode::VerifyCurrentUser => {
1777 // The caller could not obtain WRITE_OWNER. Mutate only an
1778 // exact handle whose current owner is already the token user.
1779 ensure_windows_plugin_target_owner(target, sid)?;
1780 (
1781 DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
1782 None,
1783 )
1784 }
1785 };
1786
1787 // SAFETY: `target` retains the exact validated non-reparse object and
1788 // the ACL/SID buffers remain alive through the call. The normalization
1789 // path writes owner and DACL together; the fallback path has already
1790 // verified the owner through this retained handle.
1791 let status = unsafe {
1792 SetSecurityInfo(
1793 HANDLE(target.as_raw_handle()),
1794 SE_FILE_OBJECT,
1795 security_information,
1796 owner,
1797 None,
1798 Some(acl),
1799 None,
1800 )
1801 };
1802 if status != WIN32_ERROR(0) {
1803 return Err(format!(
1804 "failed to restrict Windows plugin runtime ACL: error {}",
1805 status.0
1806 ));
1807 }
1808 if let WindowsAclOwnerMode::VerifyCurrentUser = owner_mode {
1809 // A handle that predated our restrictive share barrier may still
1810 // mutate the descriptor. Do not hand out authority if it changed
1811 // ownership around the DACL-only fallback.
1812 ensure_windows_plugin_target_owner(target, sid)?;
1813 }
1814 Ok(())
1815 })();
1816 // SAFETY: token is the unique real handle returned by OpenProcessToken.
1817 let _ = unsafe { CloseHandle(token) };
1818 result
1819 }
1820
1821 /// Require a current-user-owned target before changing its DACL. The caller
1822 /// has already opened the exact non-reparse object with a restrictive sharing
1823 /// barrier, so this does not reintroduce a path-following race.
1824 #[cfg(windows)]
1825 fn ensure_windows_plugin_target_owner(
1826 target: &fs::File,
1827 expected_owner: windows::Win32::Security::PSID,
1828 ) -> Result<(), String> {
1829 use std::os::windows::io::AsRawHandle as _;
1830 use windows::Win32::Foundation::{HANDLE, HLOCAL, LocalFree, WIN32_ERROR};
1831 use windows::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
1832 use windows::Win32::Security::{
1833 EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
1834 };
1835
1836 let mut owner = PSID::default();
1837 let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
1838 // SAFETY: `target` remains open for the complete call, all requested
1839 // output locations are valid, and Windows allocates `descriptor` for the
1840 // caller to free with LocalFree below.
1841 let status = unsafe {
1842 GetSecurityInfo(
1843 HANDLE(target.as_raw_handle()),
1844 SE_FILE_OBJECT,
1845 OWNER_SECURITY_INFORMATION,
1846 Some(&mut owner),
1847 None,
1848 None,
1849 None,
1850 Some(&mut descriptor),
1851 )
1852 };
1853 if status != WIN32_ERROR(0) {
1854 if !descriptor.0.is_null() {
1855 // SAFETY: a non-null descriptor came from GetSecurityInfo and is
1856 // documented to be released by LocalFree exactly once.
1857 let _ = unsafe { LocalFree(Some(HLOCAL(descriptor.0))) };
1858 }
1859 return Err(format!(
1860 "failed to inspect Windows plugin ACL target owner: error {}",
1861 status.0
1862 ));
1863 }
1864 let owner_matches = !owner.0.is_null() && unsafe { EqualSid(owner, expected_owner) }.is_ok();
1865 if !descriptor.0.is_null() {
1866 // SAFETY: the successful GetSecurityInfo allocation is released only
1867 // after the owner SID comparison above completes.
1868 let _ = unsafe { LocalFree(Some(HLOCAL(descriptor.0))) };
1869 }
1870 if !owner_matches {
1871 return Err(
1872 "Windows plugin ACL target owner is not the current user; refusing to harden a foreign-owned object"
1873 .to_string(),
1874 );
1875 }
1876 Ok(())
1877 }
1878
1879 #[cfg(all(not(unix), not(windows)))]
1880 fn set_owner_only_directory(_path: &Path) -> Result<(), String> {
1881 Err("Plugin runtime staging is unavailable on this platform because owner-only filesystem permissions cannot be enforced".to_string())
1882 }
1883
1884 #[cfg(unix)]
1885 fn preserve_owner_only_file_mode(path: &Path, source: &fs::Metadata) -> Result<(), String> {
1886 use std::os::unix::fs::PermissionsExt;
1887 let executable = source.permissions().mode() & 0o111 != 0;
1888 let mode = if executable { 0o500 } else { 0o400 };
1889 fs::set_permissions(path, fs::Permissions::from_mode(mode))
1890 .map_err(|e| format!("failed to restrict staged plugin file permissions: {e}"))
1891 }
1892
1893 #[cfg(windows)]
1894 fn preserve_owner_only_file_mode(path: &Path, _source: &fs::Metadata) -> Result<(), String> {
1895 // The protected handle-relative DACL is the Windows non-writable
1896 // authority. Avoid `set_permissions(path)`, which can follow a reparse
1897 // point substituted after metadata inspection.
1898 set_windows_owner_only_acl_with_mask(path, 0xa000_0000)
1899 }
1900
1901 #[cfg(all(not(unix), not(windows)))]
1902 fn preserve_owner_only_file_mode(path: &Path, _source: &fs::Metadata) -> Result<(), String> {
1903 let mut permissions = fs::metadata(path)
1904 .map_err(|e| format!("failed to inspect staged plugin file permissions: {e}"))?
1905 .permissions();
1906 permissions.set_readonly(true);
1907 fs::set_permissions(path, permissions)
1908 .map_err(|e| format!("failed to restrict staged plugin file permissions: {e}"))
1909 }
1910
1911 /// Recheck a persisted plugin receipt, the mutable reviewed source, and the
1912 /// Codewhale-owned immutable runtime copy. This function performs no writes.
1913 pub fn verify_plugin_authority(authority: &PluginAuthority) -> Result<(), String> {
1914 verify_plugin_state_authority(authority)?;
1915 for (label, manifest_path) in [
1916 ("reviewed source", &authority.source_manifest),
1917 ("Codewhale runtime snapshot", &authority.staged_manifest),
1918 ] {
1919 let current =
1920 super::manifest::PluginManifest::validate_from_path(manifest_path).map_err(|_| {
1921 format!(
1922 "Plugin bundle `{}` {label} could not be revalidated",
1923 authority.plugin_name
1924 )
1925 })?;
1926 if current.content_hash != authority.content_hash
1927 || current.capability_hash != authority.capability_hash
1928 {
1929 return Err(format!(
1930 "Plugin bundle `{}` {label} changed after review",
1931 authority.plugin_name
1932 ));
1933 }
1934 }
1935 Ok(())
1936 }
1937
1938 /// Cheap cross-process revocation probe used while an established MCP request
1939 /// is in flight. Full source/stage hashing is intentionally done before each
1940 /// dispatch; the watcher only needs to notice the locked state transition.
1941 pub fn verify_plugin_state_authority(authority: &PluginAuthority) -> Result<(), String> {
1942 let state_parent = authority
1943 .state_path
1944 .parent()
1945 .filter(|parent| !parent.as_os_str().is_empty())
1946 .ok_or_else(|| "Plugin authority state has no private parent directory".to_string())?;
1947 validate_plugin_state_directory_for_read(state_parent).map_err(|_| {
1948 "Plugin authority state directory is not private; the bundle is disabled fail-closed"
1949 .to_string()
1950 })?;
1951 let lock_path = state_lock_path(&authority.state_path);
1952 let lock_file = open_state_lock(&lock_path, false).map_err(|_| {
1953 "Plugin authority state lock is missing; review and enable the bundle again".to_string()
1954 })?;
1955 let lock = fd_lock::RwLock::new(lock_file);
1956 let _guard = lock
1957 .read()
1958 .map_err(|_| "Plugin authority state could not be read safely".to_string())?;
1959 let state = load_state_unlocked(&authority.state_path).map_err(|_| {
1960 "Plugin authority state is invalid; the bundle is disabled fail-closed".to_string()
1961 })?;
1962 let active = state
1963 .plugins
1964 .get(&authority.plugin_id)
1965 .is_some_and(|entry| {
1966 entry.generation == authority.state_generation
1967 && entry.enabled
1968 && entry.trust.as_ref().is_some_and(|receipt| {
1969 receipt.content_hash == authority.content_hash
1970 && receipt.capability_hash == authority.capability_hash
1971 })
1972 });
1973 if !active {
1974 return Err(format!(
1975 "Plugin bundle `{}` is disabled, revoked, or no longer matches its review receipt",
1976 authority.plugin_name
1977 ));
1978 }
1979 Ok(())
1980 }
1981
1982 #[cfg(test)]
1983 mod state_publication_tests {
1984 use super::{PluginStateFile, harden_plugin_state_file, save_state_with_hardener};
1985
1986 fn prepare_private_directory(_path: &std::path::Path) {
1987 #[cfg(unix)]
1988 {
1989 use std::os::unix::fs::PermissionsExt as _;
1990 std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o700)).unwrap();
1991 }
1992 }
1993
1994 #[test]
1995 fn successful_state_publication_replaces_the_stable_file_without_temp_debris() {
1996 let directory = tempfile::tempdir().unwrap();
1997 prepare_private_directory(directory.path());
1998 let state_path = directory.path().join("state-鲸.json");
1999 std::fs::write(&state_path, b"old-authoritative-state").unwrap();
2000
2001 save_state_with_hardener(
2002 &state_path,
2003 &PluginStateFile::default(),
2004 harden_plugin_state_file,
2005 )
2006 .unwrap();
2007
2008 let published = std::fs::read_to_string(&state_path).unwrap();
2009 assert!(published.contains("\"schema_version\": 1"));
2010 let entries = std::fs::read_dir(directory.path())
2011 .unwrap()
2012 .map(|entry| entry.unwrap().file_name())
2013 .collect::<Vec<_>>();
2014 assert_eq!(entries, [std::ffi::OsString::from("state-鲸.json")]);
2015 }
2016
2017 #[test]
2018 fn failed_temp_hardening_never_publishes_new_plugin_state() {
2019 let directory = tempfile::tempdir().unwrap();
2020 prepare_private_directory(directory.path());
2021 let state_path = directory.path().join("state.json");
2022 std::fs::write(&state_path, b"old-authoritative-state").unwrap();
2023
2024 let error =
2025 save_state_with_hardener(&state_path, &PluginStateFile::default(), |temporary_path| {
2026 assert!(temporary_path.is_file());
2027 assert!(
2028 std::fs::read_to_string(temporary_path)
2029 .unwrap()
2030 .contains("\"schema_version\": 1")
2031 );
2032 assert_eq!(
2033 std::fs::read(&state_path).unwrap(),
2034 b"old-authoritative-state",
2035 "the stable path must still hold the old state while hardening runs"
2036 );
2037 Err("injected pre-publication ACL failure".to_string())
2038 })
2039 .unwrap_err();
2040
2041 assert!(error.contains("injected pre-publication ACL failure"));
2042 assert_eq!(
2043 std::fs::read(&state_path).unwrap(),
2044 b"old-authoritative-state"
2045 );
2046 let entries = std::fs::read_dir(directory.path())
2047 .unwrap()
2048 .map(|entry| entry.unwrap().file_name())
2049 .collect::<Vec<_>>();
2050 assert_eq!(entries, [std::ffi::OsString::from("state.json")]);
2051 }
2052
2053 #[cfg(unix)]
2054 #[test]
2055 fn directory_sync_failure_reports_that_the_new_state_was_published() {
2056 use super::persist_plugin_state_with_directory_sync;
2057 use std::io::Write as _;
2058
2059 let directory = tempfile::tempdir().unwrap();
2060 prepare_private_directory(directory.path());
2061 let state_path = directory.path().join("state.json");
2062 std::fs::write(&state_path, b"old-authoritative-state").unwrap();
2063 let mut temporary = tempfile::NamedTempFile::new_in(directory.path()).unwrap();
2064 temporary.write_all(b"new-authoritative-state").unwrap();
2065 temporary.flush().unwrap();
2066 temporary.as_file().sync_all().unwrap();
2067
2068 let error = persist_plugin_state_with_directory_sync(temporary, &state_path, |_| {
2069 Err(std::io::Error::other(
2070 "injected post-publication directory sync failure",
2071 ))
2072 })
2073 .unwrap_err();
2074
2075 assert!(error.contains("published but its directory durability could not be confirmed"));
2076 assert_eq!(
2077 std::fs::read(&state_path).unwrap(),
2078 b"new-authoritative-state"
2079 );
2080 let entries = std::fs::read_dir(directory.path())
2081 .unwrap()
2082 .map(|entry| entry.unwrap().file_name())
2083 .collect::<Vec<_>>();
2084 assert_eq!(entries, [std::ffi::OsString::from("state.json")]);
2085 }
2086 }
2087
2088 #[cfg(all(test, unix))]
2089 mod unix_state_directory_tests {
2090 use super::validate_unix_plugin_state_directory_fields;
2091
2092 #[test]
2093 fn state_directory_validation_rejects_an_owner_mismatch() {
2094 let error = validate_unix_plugin_state_directory_fields(true, 41, 0o700, 42).unwrap_err();
2095 assert!(error.contains("current-user-owned"));
2096 }
2097 }
2098
2099 #[cfg(all(test, windows))]
2100 mod windows_acl_tests {
2101 use super::{
2102 PluginStateFile, WindowsAclOwnerMode, apply_windows_owner_only_acl,
2103 ensure_private_runtime_parent, ensure_windows_plugin_target_owner,
2104 harden_plugin_state_file, harden_staged_tree_contents, open_state_lock,
2105 save_state_with_hardener, set_windows_owner_only_acl, state_lock_path,
2106 };
2107 use std::ffi::c_void;
2108 use std::mem::{MaybeUninit, size_of};
2109 use std::os::windows::ffi::OsStrExt;
2110 use windows::Win32::Security::{
2111 ACCESS_ALLOWED_ACE, ACL, ACL_SIZE_INFORMATION, AclSizeInformation, CONTAINER_INHERIT_ACE,
2112 DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetAclInformation, GetFileSecurityW,
2113 GetSecurityDescriptorControl, GetSecurityDescriptorDacl, GetSecurityDescriptorOwner,
2114 OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID,
2115 SE_DACL_PROTECTED,
2116 };
2117 use windows::core::{BOOL, PCWSTR};
2118
2119 fn create_junction(link: &std::path::Path, target: &std::path::Path) {
2120 let output = std::process::Command::new("cmd")
2121 .args(["/C", "mklink", "/J"])
2122 .arg(link)
2123 .arg(target)
2124 .output()
2125 .expect("invoke Windows junction creation");
2126 assert!(
2127 output.status.success(),
2128 "failed to create junction: stdout={} stderr={}",
2129 String::from_utf8_lossy(&output.stdout),
2130 String::from_utf8_lossy(&output.stderr)
2131 );
2132 }
2133
2134 #[test]
2135 fn acl_hardening_rejects_junction_targets() {
2136 let directory = tempfile::tempdir().unwrap();
2137 let target = directory.path().join("target");
2138 let junction = directory.path().join("junction");
2139 std::fs::create_dir(&target).unwrap();
2140 create_junction(&junction, &target);
2141
2142 let error = set_windows_owner_only_acl(&junction).unwrap_err();
2143 assert!(error.contains("non-reparse"), "unexpected error: {error}");
2144 }
2145
2146 #[test]
2147 fn runtime_parent_creation_rejects_junction_components() {
2148 let directory = tempfile::tempdir().unwrap();
2149 let state_root = directory.path().join("state");
2150 let outside = directory.path().join("outside");
2151 std::fs::create_dir(&state_root).unwrap();
2152 std::fs::create_dir(&outside).unwrap();
2153 create_junction(&state_root.join(".runtime"), &outside);
2154 let state_path = state_root.join("state.json");
2155 let expected_parent = state_root.join(".runtime/v2/plugin");
2156
2157 let error = ensure_private_runtime_parent(&state_path, &expected_parent).unwrap_err();
2158 assert!(
2159 error.contains("reparse points"),
2160 "unexpected error: {error}"
2161 );
2162 }
2163
2164 #[test]
2165 fn staged_tree_hardening_rejects_junction_entries() {
2166 let directory = tempfile::tempdir().unwrap();
2167 let stage = directory.path().join("stage");
2168 let outside = directory.path().join("outside");
2169 std::fs::create_dir(&stage).unwrap();
2170 std::fs::create_dir(&outside).unwrap();
2171 create_junction(&stage.join("linked"), &outside);
2172
2173 let error = harden_staged_tree_contents(&stage).unwrap_err();
2174 assert!(error.contains("reparse point"), "unexpected error: {error}");
2175 }
2176
2177 #[test]
2178 fn state_lock_hardening_keeps_its_writer_handle() {
2179 let directory = tempfile::tempdir().unwrap();
2180 let state_directory = directory.path().join("state");
2181 std::fs::create_dir(&state_directory).unwrap();
2182 set_windows_owner_only_acl(&state_directory).unwrap();
2183 let lock_path = state_lock_path(&state_directory.join("state.json"));
2184
2185 let lock = open_state_lock(&lock_path, true)
2186 .expect("state lock ACL hardening must not conflict with its writer handle");
2187 assert!(lock.metadata().unwrap().is_file());
2188 }
2189
2190 #[test]
2191 fn owner_only_acl_rehardens_without_write_owner_access() {
2192 use std::os::windows::fs::OpenOptionsExt as _;
2193
2194 let directory = tempfile::tempdir().unwrap();
2195 let target = directory.path().join("state");
2196 std::fs::create_dir(&target).unwrap();
2197 set_windows_owner_only_acl(&target).unwrap();
2198
2199 // Simulate an already-private Codewhale object whose owner is still
2200 // the current user, but whose DACL intentionally does not grant
2201 // WRITE_OWNER. Rehardening must restore the full owner-only ACL
2202 // rather than assuming it may take ownership again.
2203 let reduced = std::fs::OpenOptions::new()
2204 .access_mode(0x001f_01ff) // FILE_ALL_ACCESS for this setup only
2205 .share_mode(0x0000_0001)
2206 .custom_flags(0x0220_0000) // BACKUP_SEMANTICS | OPEN_REPARSE_POINT
2207 .open(&target)
2208 .unwrap();
2209 apply_windows_owner_only_acl(
2210 &reduced,
2211 0x0017_01ff,
2212 WindowsAclOwnerMode::VerifyCurrentUser,
2213 )
2214 .expect("current owner may restrict its DACL without changing ownership");
2215 drop(reduced);
2216
2217 let denied = std::fs::OpenOptions::new()
2218 .access_mode(0x0002_0000 | 0x0004_0000 | 0x0008_0000)
2219 .share_mode(0x0000_0001)
2220 .custom_flags(0x0220_0000) // BACKUP_SEMANTICS | OPEN_REPARSE_POINT
2221 .open(&target)
2222 .unwrap_err();
2223 assert_eq!(
2224 denied.raw_os_error(),
2225 Some(5),
2226 "the full-owner path must be unavailable before exercising the fallback"
2227 );
2228
2229 set_windows_owner_only_acl(&target)
2230 .expect("rehardening must verify the current owner without requesting WRITE_OWNER");
2231 let restored = std::fs::OpenOptions::new()
2232 .access_mode(0x001f_01ff)
2233 .share_mode(0x0000_0001)
2234 .custom_flags(0x0220_0000)
2235 .open(&target);
2236 assert!(
2237 restored.is_ok(),
2238 "rehardening must restore the current user's full owner-only ACL"
2239 );
2240 }
2241
2242 #[test]
2243 fn state_lock_rehardens_without_write_owner_access() {
2244 use std::os::windows::fs::OpenOptionsExt as _;
2245
2246 let directory = tempfile::tempdir().unwrap();
2247 let state_directory = directory.path().join("state");
2248 std::fs::create_dir(&state_directory).unwrap();
2249 set_windows_owner_only_acl(&state_directory).unwrap();
2250 let lock_path = state_lock_path(&state_directory.join("state.json"));
2251 drop(open_state_lock(&lock_path, true).unwrap());
2252
2253 let reduced = std::fs::OpenOptions::new()
2254 .access_mode(0x001f_01ff) // FILE_ALL_ACCESS for this setup only
2255 .share_mode(0x0000_0001)
2256 .custom_flags(0x0220_0000) // BACKUP_SEMANTICS | OPEN_REPARSE_POINT
2257 .open(&lock_path)
2258 .unwrap();
2259 apply_windows_owner_only_acl(
2260 &reduced,
2261 0x0016_019f, // FILE_GENERIC_READ | FILE_GENERIC_WRITE | WRITE_DAC
2262 WindowsAclOwnerMode::VerifyCurrentUser,
2263 )
2264 .expect("current owner may remove WRITE_OWNER from an existing state lock");
2265 drop(reduced);
2266
2267 let denied = std::fs::OpenOptions::new()
2268 .access_mode(0x001e_019f) // FILE_GENERIC_READ | FILE_GENERIC_WRITE | WRITE_DAC | WRITE_OWNER
2269 .share_mode(0x0000_0001)
2270 .custom_flags(0x0020_0000) // FILE_FLAG_OPEN_REPARSE_POINT
2271 .open(&lock_path)
2272 .unwrap_err();
2273 assert_eq!(
2274 denied.raw_os_error(),
2275 Some(5),
2276 "the state-lock fallback must run only after WRITE_OWNER is denied"
2277 );
2278
2279 let lock = open_state_lock(&lock_path, true)
2280 .expect("state-lock hardening must fall back to DACL-only rehardening");
2281 assert!(lock.metadata().unwrap().is_file());
2282 }
2283
2284 #[test]
2285 fn owner_only_acl_rejects_an_owner_identity_mismatch() {
2286 use std::os::windows::fs::OpenOptionsExt as _;
2287 use windows::Win32::Security::{CreateWellKnownSid, WinWorldSid};
2288
2289 let directory = tempfile::tempdir().unwrap();
2290 let path = directory.path().join("state");
2291 std::fs::create_dir(&path).unwrap();
2292 set_windows_owner_only_acl(&path).unwrap();
2293 let target = std::fs::OpenOptions::new()
2294 .access_mode(0x0002_0000) // READ_CONTROL
2295 .share_mode(0x0000_0001)
2296 .custom_flags(0x0220_0000) // BACKUP_SEMANTICS | OPEN_REPARSE_POINT
2297 .open(&path)
2298 .unwrap();
2299
2300 // World is a valid SID that cannot match this user's object owner.
2301 // Supply it directly to the exact-handle verifier without changing
2302 // the fixture's real owner or relying on privileged owner mutation.
2303 let mut required = 0_u32;
2304 let _ = unsafe { CreateWellKnownSid(WinWorldSid, None, None, &mut required) };
2305 assert!(required > 0, "Windows did not report the world SID size");
2306 let words = (required as usize).div_ceil(size_of::<usize>());
2307 let mut sid_buffer = vec![MaybeUninit::<usize>::zeroed(); words];
2308 let world = PSID(sid_buffer.as_mut_ptr().cast());
2309 unsafe { CreateWellKnownSid(WinWorldSid, None, Some(world), &mut required) }.unwrap();
2310
2311 let error = ensure_windows_plugin_target_owner(&target, world).unwrap_err();
2312 assert!(
2313 error.contains("not the current user"),
2314 "unexpected error: {error}"
2315 );
2316 }
2317
2318 #[test]
2319 fn blocked_state_replacement_preserves_the_stable_authority_file() {
2320 use std::os::windows::fs::OpenOptionsExt as _;
2321
2322 let directory = tempfile::tempdir().unwrap();
2323 let state_path = directory.path().join("state.json");
2324 std::fs::write(&state_path, b"old-authoritative-state").unwrap();
2325 let retained = std::fs::OpenOptions::new()
2326 .read(true)
2327 .share_mode(0x0000_0001)
2328 .open(&state_path)
2329 .unwrap();
2330
2331 let error = save_state_with_hardener(
2332 &state_path,
2333 &PluginStateFile::default(),
2334 harden_plugin_state_file,
2335 )
2336 .unwrap_err();
2337
2338 assert!(
2339 error.contains("durably persist"),
2340 "unexpected error: {error}"
2341 );
2342 assert_eq!(
2343 std::fs::read(&state_path).unwrap(),
2344 b"old-authoritative-state"
2345 );
2346 drop(retained);
2347 }
2348
2349 #[test]
2350 fn owner_only_runtime_acl_is_protected_and_has_one_full_access_ace() {
2351 let directory = tempfile::tempdir().unwrap();
2352 let runtime = directory.path().join("runtime");
2353 std::fs::create_dir(&runtime).unwrap();
2354 set_windows_owner_only_acl(&runtime).unwrap();
2355
2356 let mut wide = runtime.as_os_str().encode_wide().collect::<Vec<_>>();
2357 wide.push(0);
2358 let mut required = 0_u32;
2359 // SAFETY: this size-probe intentionally supplies no destination buffer.
2360 let _ = unsafe {
2361 GetFileSecurityW(
2362 PCWSTR(wide.as_ptr()),
2363 (DACL_SECURITY_INFORMATION | OWNER_SECURITY_INFORMATION).0,
2364 None,
2365 0,
2366 &mut required,
2367 )
2368 };
2369 assert!(
2370 required > 0,
2371 "Windows did not report a security descriptor size"
2372 );
2373 let words = (required as usize).div_ceil(size_of::<usize>());
2374 let mut descriptor = vec![MaybeUninit::<usize>::zeroed(); words];
2375 let descriptor = PSECURITY_DESCRIPTOR(descriptor.as_mut_ptr().cast::<c_void>());
2376 // SAFETY: the aligned destination is at least `required` bytes and the
2377 // UTF-16 path remains NUL terminated for the call.
2378 assert!(
2379 unsafe {
2380 GetFileSecurityW(
2381 PCWSTR(wide.as_ptr()),
2382 (DACL_SECURITY_INFORMATION | OWNER_SECURITY_INFORMATION).0,
2383 Some(descriptor),
2384 required,
2385 &mut required,
2386 )
2387 }
2388 .as_bool()
2389 );
2390
2391 let mut present = BOOL::default();
2392 let mut defaulted = BOOL::default();
2393 let mut acl = std::ptr::null_mut::<ACL>();
2394 // SAFETY: `descriptor` contains the successful GetFileSecurityW result.
2395 unsafe { GetSecurityDescriptorDacl(descriptor, &mut present, &mut acl, &mut defaulted) }
2396 .unwrap();
2397 assert!(present.as_bool());
2398 assert!(!acl.is_null());
2399
2400 let mut info = ACL_SIZE_INFORMATION::default();
2401 // SAFETY: `acl` is owned by the live descriptor buffer above.
2402 unsafe {
2403 GetAclInformation(
2404 acl,
2405 (&mut info as *mut ACL_SIZE_INFORMATION).cast(),
2406 size_of::<ACL_SIZE_INFORMATION>() as u32,
2407 AclSizeInformation,
2408 )
2409 }
2410 .unwrap();
2411 assert_eq!(info.AceCount, 1, "runtime DACL must name only the owner");
2412
2413 let mut ace = std::ptr::null_mut::<c_void>();
2414 // SAFETY: the ACL contains exactly one ACE.
2415 unsafe { GetAce(acl, 0, &mut ace) }.unwrap();
2416 let ace = unsafe { &*ace.cast::<ACCESS_ALLOWED_ACE>() };
2417 assert_eq!(ace.Header.AceType, 0, "owner entry must be an allow ACE");
2418 assert_eq!(ace.Mask, 0x001f_01ff, "owner entry must grant full access");
2419 let ace_sid = PSID(std::ptr::addr_of!(ace.SidStart).cast_mut().cast());
2420 let mut owner = PSID::default();
2421 let mut owner_defaulted = BOOL::default();
2422 // SAFETY: `descriptor` contains the live security descriptor and both
2423 // output pointers reference initialized storage.
2424 unsafe { GetSecurityDescriptorOwner(descriptor, &mut owner, &mut owner_defaulted) }
2425 .unwrap();
2426 assert!(
2427 !owner.0.is_null(),
2428 "runtime object must have an explicit owner"
2429 );
2430 assert!(
2431 !owner_defaulted.as_bool(),
2432 "runtime object owner must be explicitly assigned"
2433 );
2434 // SAFETY: both SIDs are owned by the live descriptor/ACL buffers.
2435 unsafe { EqualSid(owner, ace_sid) }
2436 .expect("runtime object owner must equal its sole current-user ACE");
2437 let inheritance = (CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE).0 as u8;
2438 assert_eq!(ace.Header.AceFlags & inheritance, inheritance);
2439
2440 let mut control = 0_u16;
2441 let mut revision = 0_u32;
2442 // SAFETY: the descriptor buffer remains alive for this inspection.
2443 unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) }.unwrap();
2444 assert_ne!(
2445 control & SE_DACL_PROTECTED.0,
2446 0,
2447 "runtime DACL must not inherit broader parent permissions"
2448 );
2449 }
2450 }
2451
2451 lines RUST