Skip to content

Commit f0b41b0

Browse files
senamakelmedullabot
andcommitted
fix(policy): confine compiled scope construction to validated registry
Co-authored-by: Medulla <medulla@tinyhumans.ai>
1 parent 2f0c51d commit f0b41b0

4 files changed

Lines changed: 119 additions & 4 deletions

File tree

‎crates/tinysecurity-policy/src/path_registry.rs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ impl PathPolicyRegistry {
4848
{
4949
return Err(Error::InvalidArguments);
5050
}
51-
if let Some((id, _)) = self.scopes.iter().find(|(_, scope)| scope.0 == policy) {
51+
if let Some((id, _)) = self
52+
.scopes
53+
.iter()
54+
.find(|(_, scope)| scope.policy() == &policy)
55+
{
5256
return Ok(id.clone());
5357
}
5458
if self.scopes.len() >= 1024 {
@@ -71,7 +75,8 @@ impl PathPolicyRegistry {
7175
if self.scopes.iter().any(|(existing, _)| *existing == id) {
7276
continue;
7377
}
74-
self.scopes.push((id.clone(), Arc::new(PathScope(policy))));
78+
self.scopes
79+
.push((id.clone(), Arc::new(PathScope::registered(policy))));
7580
return Ok(id);
7681
}
7782
Err(Error::Unavailable)

‎crates/tinysecurity-policy/src/path_scope.rs‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,29 @@ fn candidate(path: &Path) -> Option<PathBuf> {
125125
.ok()
126126
.filter(|resolved| !protected_path(resolved))
127127
}
128-
/// Compiled immutable filesystem scope.
128+
/// Compiled immutable filesystem scope issued by [`crate::PathPolicyRegistry`].
129+
/// Direct external construction is intentionally unavailable:
130+
/// ```compile_fail
131+
/// use tinysecurity_policy::{PathScope, PathPolicy};
132+
/// fn bypass_registration(policy: PathPolicy) -> PathScope { PathScope(policy) }
133+
/// ```
129134
#[derive(Clone, Debug)]
130-
pub struct PathScope(pub PathPolicy);
135+
pub struct PathScope(PathPolicy);
131136
impl std::ops::Deref for PathScope {
132137
type Target = PathPolicy;
133138
fn deref(&self) -> &Self::Target {
134139
&self.0
135140
}
136141
}
137142
impl PathScope {
143+
/// Construct only after the registry has admitted every configured root.
144+
pub(crate) fn registered(policy: PathPolicy) -> Self {
145+
Self(policy)
146+
}
147+
pub(crate) fn policy(&self) -> &PathPolicy {
148+
&self.0
149+
}
150+
138151
/// Whether the path is under a host state reservation, including aliased/missing descendants.
139152
#[must_use]
140153
pub fn internal(&self, path: &Path) -> bool {
@@ -347,3 +360,7 @@ impl PathScope {
347360
#[cfg(test)]
348361
#[path = "path_scope_tests.rs"]
349362
mod tests;
363+
364+
#[cfg(test)]
365+
#[path = "path_scope_contract_tests.rs"]
366+
mod contract_tests;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use super::*;
2+
fn scope(root: &Path) -> Result<std::sync::Arc<PathScope>, tinysecurity_bus::Error> {
3+
let mut registry = crate::PathPolicyRegistry::default();
4+
let policy = PathPolicy {
5+
enabled: false,
6+
workspace_only: false,
7+
workspace_dir: root.into(),
8+
action_dir: root.into(),
9+
home_dir: None,
10+
forbidden_paths: vec![],
11+
trusted_roots: vec![],
12+
reserved_paths: vec![],
13+
reserved_prefixes: vec![],
14+
readonly_paths: vec![],
15+
reserved_names: vec![],
16+
};
17+
let id = registry.register(policy)?;
18+
registry.get_owned(&id)
19+
}
20+
/// Native critical-system targets fail before any filesystem read or write.
21+
#[test]
22+
fn platform_native_protected_target_is_denied_before_resolution()
23+
-> Result<(), Box<dyn std::error::Error>> {
24+
let root = tempfile::tempdir()?;
25+
#[cfg(windows)]
26+
let native = PathBuf::from(
27+
std::env::var_os("SystemRoot")
28+
.ok_or_else(|| std::io::Error::other("Windows SystemRoot is absent"))?,
29+
)
30+
.join("System32");
31+
#[cfg(not(windows))]
32+
let native = PathBuf::from("/etc");
33+
assert!(native.is_absolute());
34+
assert!(protected_path(&native));
35+
let scope = scope(root.path())?;
36+
for write in [false, true] {
37+
assert_eq!(
38+
scope.validate(&native.to_string_lossy(), write),
39+
PathValidationResult::Denied {
40+
category: Rejection::Protected
41+
}
42+
);
43+
}
44+
Ok(())
45+
}
46+
/// Windows-looking namespace prefixes do not change native POSIX path meaning.
47+
#[cfg(unix)]
48+
#[test]
49+
fn posix_question_mark_directory_is_not_a_windows_root_or_action_relative_path() {
50+
use std::path::Component;
51+
let path = Path::new("//?/etc/passwd");
52+
assert!(path.is_absolute());
53+
let components: Vec<_> = path.components().collect();
54+
assert_eq!(
55+
components,
56+
vec![
57+
Component::RootDir,
58+
Component::Normal("?".as_ref()),
59+
Component::Normal("etc".as_ref()),
60+
Component::Normal("passwd".as_ref())
61+
]
62+
);
63+
// The normalized spelling is used for floor classification only. It is
64+
// never used to construct the filesystem target passed to canonicalize.
65+
assert!(!protected_path(path));
66+
let root = Path::new("/acting-root");
67+
let policy = PathPolicy {
68+
enabled: false,
69+
workspace_only: false,
70+
workspace_dir: root.into(),
71+
action_dir: root.into(),
72+
home_dir: None,
73+
forbidden_paths: vec![],
74+
trusted_roots: vec![],
75+
reserved_paths: vec![],
76+
reserved_prefixes: vec![],
77+
readonly_paths: vec![],
78+
reserved_names: vec![],
79+
};
80+
let scope = PathScope::registered(policy);
81+
assert_eq!(
82+
scope.absolute("//?/etc/passwd"),
83+
PathBuf::from("//?/etc/passwd")
84+
);
85+
assert!(protected_path(Path::new("/etc/passwd")));
86+
}

‎docs/specs/immutable-path-scopes.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ atomic: a host still needs an execution sandbox to bound filesystem races.
6262
Acceptance coverage includes missing targets, aliased reserved state, disabled
6363
policy floors, home expansion, relative forbidden roots, independent registry
6464
identities, registry exhaustion, read-only grants and existing/broken symlinks.
65+
The compiled scope constructor is private; production handles are issued only
66+
after registry validation. A compile-fail API test pins that boundary. The
67+
Windows alias normalization is a floor classifier, never a filesystem path
68+
rewrite: on POSIX, `//?/etc/passwd` names the `?` directory beneath `/`, not
69+
`/etc/passwd`, and is never rebased beneath the action directory. Validation
70+
checks the original native path and repeats the floor on its resolved target.
71+
Native critical-system fixtures supplement cross-platform Windows-spelling tests.
6572
The real native loader verifier calls every new member and verifies that a scope
6673
survives an accepted SDK reinitialization. This completes filesystem capability
6774
migration support; command policy, redaction, approvals, egress, audit and crypto

0 commit comments

Comments
 (0)