Skip to main content

exarch_core/security/
permissions.rs

1//! File permission validation and sanitization.
2
3use crate::SecurityConfig;
4use crate::config::Validated;
5
6/// A Unix permission mode that has already passed through
7/// [`sanitize_permissions`].
8///
9/// This newtype closes the gap between "a raw mode read from an archive
10/// header" and "a mode safe to apply to an extracted file": both are
11/// otherwise a plain `u32`, so a caller could pass an unsanitized mode to
12/// permission-setting code by mistake, with the invariant living only in a
13/// doc comment. `SanitizedMode` can only be constructed by
14/// [`sanitize_permissions`] — its single field is private, so external
15/// callers (and other modules in this crate) cannot fabricate one that
16/// skipped the setuid/setgid/world-writable stripping.
17///
18/// # Examples
19///
20/// ```
21/// # #[cfg(feature = "testing")]
22/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
23/// use exarch_core::SecurityConfig;
24/// use exarch_core::security::sanitize_permissions;
25///
26/// let config = SecurityConfig::default().validate()?;
27///
28/// // Setuid bit is stripped
29/// let sanitized = sanitize_permissions(0o4755, &config);
30/// assert_eq!(sanitized.as_u32(), 0o755);
31/// # Ok(())
32/// # }
33/// # #[cfg(not(feature = "testing"))]
34/// # fn main() {}
35/// ```
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct SanitizedMode(u32);
38
39impl SanitizedMode {
40    /// Returns the sanitized mode as a raw Unix permission bitmask.
41    #[inline]
42    #[must_use]
43    pub fn as_u32(self) -> u32 {
44        self.0
45    }
46}
47
48/// Sanitizes file permissions by stripping dangerous bits.
49///
50/// This function removes security-sensitive permission bits that could
51/// lead to privilege escalation:
52/// - Setuid bit (04000): Allows execution with file owner's privileges
53/// - Setgid bit (02000): Allows execution with file group's privileges
54/// - World-writable bit (0002): Stripped by default unless
55///   `allow_world_writable` is set
56///
57/// The [`SanitizedMode`] return type is the only way to obtain a sanitized
58/// mode in this crate, so a caller cannot accidentally pass a raw,
59/// unsanitized mode to internal permission-setting code such as
60/// `create_file_with_mode`.
61///
62/// # Performance
63///
64/// This is a pure computation with no I/O. Typical execution time: <10 ns.
65///
66/// # Examples
67///
68/// ```
69/// # #[cfg(feature = "testing")]
70/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
71/// use exarch_core::SecurityConfig;
72/// use exarch_core::security::sanitize_permissions;
73///
74/// let config = SecurityConfig::default().validate()?;
75///
76/// // Setuid bit is stripped
77/// let sanitized = sanitize_permissions(0o4755, &config);
78/// assert_eq!(sanitized.as_u32(), 0o755);
79///
80/// // Setgid bit is stripped
81/// let sanitized = sanitize_permissions(0o2755, &config);
82/// assert_eq!(sanitized.as_u32(), 0o755);
83///
84/// // Both setuid and setgid bits stripped
85/// let sanitized = sanitize_permissions(0o6755, &config);
86/// assert_eq!(sanitized.as_u32(), 0o755);
87///
88/// // World-writable bit is stripped by default
89/// let sanitized = sanitize_permissions(0o777, &config);
90/// assert_eq!(sanitized.as_u32(), 0o775);
91/// # Ok(())
92/// # }
93/// # #[cfg(not(feature = "testing"))]
94/// # fn main() {}
95/// ```
96#[must_use]
97pub fn sanitize_permissions(mode: u32, config: &SecurityConfig<Validated>) -> SanitizedMode {
98    let mut sanitized = mode;
99
100    // Strip setuid bit (04000)
101    sanitized &= !0o4000;
102
103    // Strip setgid bit (02000)
104    sanitized &= !0o2000;
105
106    // M-CODE-1: Strip world-writable bit (0002) unless explicitly allowed
107    if !config.allowed.world_writable {
108        sanitized &= !0o002;
109    }
110
111    SanitizedMode(sanitized)
112}
113
114#[cfg(test)]
115#[allow(clippy::expect_used)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_sanitize_permissions_normal() {
121        let config = SecurityConfig::default().validate().expect("valid config");
122        assert_eq!(sanitize_permissions(0o644, &config).as_u32(), 0o644);
123    }
124
125    #[test]
126    fn test_sanitize_permissions_executable() {
127        let config = SecurityConfig::default().validate().expect("valid config");
128        assert_eq!(sanitize_permissions(0o755, &config).as_u32(), 0o755);
129    }
130
131    #[test]
132    fn test_sanitize_permissions_strip_setuid() {
133        let config = SecurityConfig::default().validate().expect("valid config");
134        assert_eq!(sanitize_permissions(0o4755, &config).as_u32(), 0o755);
135    }
136
137    #[test]
138    fn test_sanitize_permissions_strip_setgid() {
139        let config = SecurityConfig::default().validate().expect("valid config");
140        assert_eq!(sanitize_permissions(0o2755, &config).as_u32(), 0o755);
141    }
142
143    #[test]
144    fn test_sanitize_permissions_strip_both() {
145        let config = SecurityConfig::default().validate().expect("valid config");
146        assert_eq!(sanitize_permissions(0o6755, &config).as_u32(), 0o755);
147    }
148
149    #[test]
150    fn test_sanitize_permissions_strip_world_writable() {
151        let config = SecurityConfig::default().validate().expect("valid config");
152        assert_eq!(sanitize_permissions(0o777, &config).as_u32(), 0o775);
153    }
154
155    #[test]
156    fn test_sanitize_permissions_world_readable_ok() {
157        let config = SecurityConfig::default().validate().expect("valid config");
158        assert_eq!(sanitize_permissions(0o644, &config).as_u32(), 0o644);
159    }
160
161    #[test]
162    fn test_sanitize_permissions_owner_writable_ok() {
163        let config = SecurityConfig::default().validate().expect("valid config");
164        assert_eq!(sanitize_permissions(0o600, &config).as_u32(), 0o600);
165    }
166
167    #[test]
168    fn test_sanitize_permissions_group_writable_ok() {
169        let config = SecurityConfig::default().validate().expect("valid config");
170        assert_eq!(sanitize_permissions(0o664, &config).as_u32(), 0o664);
171    }
172
173    #[test]
174    fn test_sanitize_permissions_edge_case_zero() {
175        let config = SecurityConfig::default().validate().expect("valid config");
176        assert_eq!(sanitize_permissions(0o000, &config).as_u32(), 0o000);
177    }
178
179    #[test]
180    fn test_sticky_bit_preservation() {
181        let config = SecurityConfig::default().validate().expect("valid config");
182        let sanitized = sanitize_permissions(0o1755, &config).as_u32();
183        assert_eq!(sanitized & 0o1000, 0o1000, "sticky bit should be preserved");
184        assert_eq!(sanitized, 0o1755, "full mode should be preserved");
185    }
186
187    #[test]
188    fn test_sticky_bit_with_setuid_stripped() {
189        let config = SecurityConfig::default().validate().expect("valid config");
190        let sanitized = sanitize_permissions(0o7755, &config).as_u32();
191        assert_eq!(sanitized & 0o1000, 0o1000, "sticky bit should remain");
192        assert_eq!(sanitized & 0o4000, 0, "setuid should be stripped");
193        assert_eq!(sanitized & 0o2000, 0, "setgid should be stripped");
194        assert_eq!(sanitized, 0o1755, "result should be sticky + rwxr-xr-x");
195    }
196
197    // M-CODE-1: Test world-writable with config flag
198    #[test]
199    fn test_world_writable_allowed_with_config() {
200        let mut config = SecurityConfig::default();
201        config.allowed.world_writable = true;
202        let config = config.validate().expect("valid config");
203        let sanitized = sanitize_permissions(0o777, &config).as_u32();
204        assert_eq!(
205            sanitized & 0o002,
206            0o002,
207            "world-writable bit should be preserved"
208        );
209        assert_eq!(sanitized & 0o4000, 0, "setuid should be stripped");
210        assert_eq!(sanitized & 0o2000, 0, "setgid should be stripped");
211        assert_eq!(sanitized, 0o777, "result should be rwxrwxrwx");
212    }
213
214    #[test]
215    fn test_world_writable_stripped_by_default() {
216        let config = SecurityConfig::default().validate().expect("valid config");
217        assert_eq!(
218            sanitize_permissions(0o777, &config).as_u32(),
219            0o775,
220            "world-writable bit should be stripped by default"
221        );
222    }
223
224    #[test]
225    fn test_world_writable_bit_only_stripped() {
226        let config = SecurityConfig::default().validate().expect("valid config");
227        // 0o666 = rw-rw-rw-, only other-write (0o002) should be stripped ->
228        // 0o664
229        assert_eq!(
230            sanitize_permissions(0o666, &config).as_u32(),
231            0o664,
232            "only world-writable bit should be stripped, not group-write"
233        );
234    }
235}