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