Compare commits

...

3 Commits

Author SHA1 Message Date
Eva Wong
a6dfb7d038 Add Windows metadata enforcement guard 2026-05-05 03:35:14 -07:00
Eva Wong
801c0f5221 Add Windows metadata setup target type 2026-05-05 03:35:14 -07:00
Eva Wong
b508d9ad80 Add Windows metadata adapter target type 2026-05-05 03:35:14 -07:00
4 changed files with 217 additions and 1 deletions

View File

@@ -95,7 +95,8 @@ pub struct ExecParams {
pub arg0: Option<String>,
}
/// Resolved filesystem overrides for the Windows sandbox backends.
/// Layer: Windows adapter layer. Resolved filesystem overrides for the Windows
/// sandbox backends.
///
/// The unelevated restricted-token backend only consumes extra deny-write
/// carveouts on top of the legacy `WorkspaceWrite` allow set. The elevated
@@ -111,6 +112,25 @@ pub(crate) struct WindowsSandboxFilesystemOverrides {
pub(crate) additional_deny_write_paths: Vec<AbsolutePathBuf>,
}
/// Layer: Windows adapter layer. This is the Windows projection of
/// `WritableRoot::protected_metadata_names` from `FileSystemSandboxPolicy`.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct WindowsProtectedMetadataTarget {
pub(crate) path: AbsolutePathBuf,
pub(crate) mode: WindowsProtectedMetadataMode,
}
/// Layer: Windows adapter layer. The enforcement layer needs to know why a
/// protected metadata path is absent instead of treating every missing path as
/// an existing filesystem object.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum WindowsProtectedMetadataMode {
ExistingDeny,
MissingCreationMonitor,
}
fn windows_sandbox_uses_elevated_backend(
sandbox_level: WindowsSandboxLevel,
proxy_enforced: bool,

View File

@@ -26,6 +26,7 @@ windows_modules!(
path_normalization,
policy,
process,
protected_metadata,
token,
wfp,
wfp_setup,
@@ -173,6 +174,10 @@ pub use session::spawn_windows_sandbox_session_elevated;
#[cfg(target_os = "windows")]
pub use session::spawn_windows_sandbox_session_legacy;
#[cfg(target_os = "windows")]
pub use setup::ProtectedMetadataMode;
#[cfg(target_os = "windows")]
pub use setup::ProtectedMetadataTarget;
#[cfg(target_os = "windows")]
pub use setup::SETUP_VERSION;
#[cfg(target_os = "windows")]
pub use setup::SandboxSetupRequest;

View File

@@ -0,0 +1,174 @@
#![allow(dead_code)]
use crate::setup::ProtectedMetadataMode;
use crate::setup::ProtectedMetadataTarget;
use anyhow::Context;
use anyhow::Result;
use std::fs::Metadata;
use std::io;
use std::os::windows::fs::FileTypeExt;
use std::os::windows::fs::MetadataExt;
use std::path::Path;
use std::path::PathBuf;
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
/// Layer: Windows enforcement layer. Existing metadata objects can be protected
/// with ACLs; missing names are monitored and removed if the sandbox creates
/// them.
#[derive(Debug)]
pub(crate) struct ProtectedMetadataGuard {
deny_paths: Vec<PathBuf>,
monitored_paths: Vec<PathBuf>,
}
impl ProtectedMetadataGuard {
pub(crate) fn deny_paths(&self) -> impl Iterator<Item = &PathBuf> {
self.deny_paths.iter()
}
pub(crate) fn cleanup_created_monitored_paths(&self) -> Result<Vec<PathBuf>> {
let mut removed = Vec::new();
for path in &self.monitored_paths {
let Some(existing_path) = existing_metadata_path(path)? else {
continue;
};
remove_metadata_path(&existing_path)
.with_context(|| format!("failed to remove protected metadata {}", path.display()))?;
removed.push(existing_path);
}
Ok(removed)
}
}
pub(crate) fn prepare_protected_metadata_targets(
targets: &[ProtectedMetadataTarget],
) -> ProtectedMetadataGuard {
let mut deny_paths = Vec::new();
let mut monitored_paths = Vec::new();
for target in targets {
match target.mode {
ProtectedMetadataMode::ExistingDeny => {
deny_paths.extend(protected_metadata_existing_deny_paths(&target.path));
}
ProtectedMetadataMode::MissingCreationMonitor => {
monitored_paths.push(target.path.clone());
}
}
}
ProtectedMetadataGuard {
deny_paths,
monitored_paths,
}
}
pub fn protected_metadata_existing_deny_paths(path: &Path) -> Vec<PathBuf> {
if std::fs::symlink_metadata(path).is_ok() {
vec![path.to_path_buf()]
} else {
Vec::new()
}
}
fn existing_metadata_path(path: &Path) -> Result<Option<PathBuf>> {
match std::fs::symlink_metadata(path) {
Ok(_) => return Ok(Some(path.to_path_buf())),
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => {
return Err(err)
.with_context(|| format!("failed to inspect protected metadata {}", path.display()));
}
}
let Some(parent) = path.parent() else {
return Ok(None);
};
let Some(expected_name) = path.file_name().and_then(|name| name.to_str()) else {
return Ok(None);
};
let entries = match std::fs::read_dir(parent) {
Ok(entries) => entries,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(err)
.with_context(|| format!("failed to scan protected metadata parent {}", parent.display()));
}
};
for entry in entries {
let entry = entry.with_context(|| {
format!(
"failed to read protected metadata parent entry {}",
parent.display()
)
})?;
if entry
.file_name()
.to_str()
.is_some_and(|name| name.eq_ignore_ascii_case(expected_name))
{
return Ok(Some(entry.path()));
}
}
Ok(None)
}
fn remove_metadata_path(path: &Path) -> Result<()> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
return Err(err)
.with_context(|| format!("failed to inspect protected metadata {}", path.display()));
}
};
let file_type = metadata.file_type();
if is_directory_reparse_point(&metadata) || file_type.is_symlink_dir() {
std::fs::remove_dir(path)
.with_context(|| format!("failed to remove protected metadata {}", path.display()))?;
} else if file_type.is_symlink_file() {
std::fs::remove_file(path)
.with_context(|| format!("failed to remove protected metadata {}", path.display()))?;
} else if metadata.is_dir() {
std::fs::remove_dir_all(path)
.with_context(|| format!("failed to remove protected metadata {}", path.display()))?;
} else {
std::fs::remove_file(path)
.with_context(|| format!("failed to remove protected metadata {}", path.display()))?;
}
Ok(())
}
fn is_directory_reparse_point(metadata: &Metadata) -> bool {
metadata.is_dir() && (metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::setup::ProtectedMetadataMode;
use crate::setup::ProtectedMetadataTarget;
#[test]
fn cleanup_created_monitored_paths_removes_case_variant() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let target = temp_dir.path().join(".git");
let created = temp_dir.path().join(".GIT");
std::fs::create_dir_all(&created).expect("create metadata");
let guard = prepare_protected_metadata_targets(&[ProtectedMetadataTarget {
path: target.clone(),
mode: ProtectedMetadataMode::MissingCreationMonitor,
}]);
let removed = guard.cleanup_created_monitored_paths().expect("cleanup");
assert_eq!(removed.len(), 1);
assert!(
removed[0]
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case(".git")),
"removed path should be a .git case variant: {}",
removed[0].display()
);
assert!(!target.exists());
assert!(!created.exists());
}
}

View File

@@ -99,6 +99,23 @@ pub struct SetupRootOverrides {
pub deny_write_paths: Option<Vec<PathBuf>>,
}
/// Layer: Windows enforcement request boundary. These targets are projected by
/// the adapter layer before they reach the setup helper.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtectedMetadataTarget {
pub path: PathBuf,
pub mode: ProtectedMetadataMode,
}
/// Layer: Windows enforcement request boundary. The helper must distinguish
/// existing metadata objects from missing names that need create monitoring.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProtectedMetadataMode {
ExistingDeny,
MissingCreationMonitor,
}
pub fn run_setup_refresh(
policy: &SandboxPolicy,
policy_cwd: &Path,