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