Skip to main content

whyno_core/
operation.rs

1//! Operation types for permission queries.
2//!
3//! Each operation maps to a syscall class and determines which
4//! Permission bits are checked and whether the check targets
5//! The file or its parent directory.
6
7use serde::Serialize;
8
9/// Namespace for `setxattr(2)` extended attribute operations.
10///
11/// Determines the security model for the attribute write.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[non_exhaustive]
14pub enum XattrNamespace {
15    /// `user.*` attributes — owner or `CAP_FOWNER`.
16    User,
17    /// `trusted.*` attributes — requires `CAP_SYS_ADMIN`.
18    Trusted,
19    /// `security.*` attributes — requires `CAP_SYS_ADMIN`.
20    Security,
21    /// `system.posix_acl_access` — owner or `CAP_FOWNER`.
22    SystemPosixAcl,
23}
24
25/// Operation being attempted on the target path.
26///
27/// Determines which permission bits to check and whether the check
28/// Redirects to the parent directory (for `Delete` and `Create`).
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[non_exhaustive]
31pub enum Operation {
32    /// Read file contents or list directory entries. Requires `r`.
33    Read,
34    /// Write file contents, truncate, or modify. Requires `w`.
35    Write,
36    /// Execute a binary or traverse a directory. Requires `x`.
37    Execute,
38    /// Remove a file or directory. Checks `w+x` on parent.
39    Delete,
40    /// Create a new file or directory. Checks `w+x` on parent.
41    Create,
42    /// Stat/metadata read. Requires only path traversal (`+x` on ancestors).
43    Stat,
44    /// Change file mode bits (`chmod`). Gated by ownership or `CAP_FOWNER`.
45    Chmod,
46    /// Change file owner UID (`chown`). Requires `CAP_CHOWN`.
47    ChownUid,
48    /// Change file owner GID (`chown`). Owner-in-group or `CAP_CHOWN`.
49    ChownGid,
50    /// Set extended attribute (`setxattr`). Gated by namespace.
51    SetXattr {
52        /// Xattr namespace determines the capability required.
53        namespace: XattrNamespace,
54    },
55}
56
57impl Operation {
58    /// Returns `true` if this operation uses the metadata check path.
59    ///
60    /// Metadata ops bypass DAC mode-bit checks; they evaluate ownership
61    /// and capability rules directly. New variants MUST be added here —
62    /// no wildcard arm permitted (`clippy::wildcard_enum_match_arm` is denied).
63    #[must_use]
64    pub fn is_metadata(&self) -> bool {
65        matches!(
66            self,
67            Self::Chmod | Self::ChownUid | Self::ChownGid | Self::SetXattr { .. }
68        )
69    }
70
71    /// Returns `true` if this operation checks the parent directory
72    /// Rather than the target itself.
73    ///
74    /// `Delete` and `Create` require `w+x` on parent, not target.
75    #[must_use]
76    pub fn checks_parent(&self) -> bool {
77        matches!(self, Self::Delete | Self::Create)
78    }
79
80    /// Index into path walk of the component to check.
81    ///
82    /// For most operations, last component (target). for `Delete` and
83    /// `Create`, second-to-last (parent directory). Returns `None` if
84    /// Walk is empty or too short for a parent-directed operation.
85    #[must_use]
86    pub fn target_component(&self, walk_len: usize) -> Option<usize> {
87        if walk_len == 0 {
88            return None;
89        }
90        if self.checks_parent() {
91            walk_len.checked_sub(2)
92        } else {
93            Some(walk_len - 1)
94        }
95    }
96}
97
98/// Caller-supplied intent for metadata-change operations.
99///
100/// Separate from `SystemState` (OS-gathered state). Carries values the
101/// caller wants to apply, not current file state. Missing required fields
102/// cause the check layer to return `Degraded` rather than failing at gather time.
103#[derive(Debug, Default, Clone)]
104pub struct MetadataParams {
105    /// Target mode bits for a `Chmod` operation. `None` causes the metadata
106    /// check to return `Degraded` rather than a hard `Fail`.
107    pub new_mode: Option<u32>,
108    /// Target owner UID for a `ChownUid` operation. `None` causes the metadata
109    /// check to return `Degraded` rather than a hard `Fail`.
110    pub new_uid: Option<u32>,
111    /// Target owner GID for a `ChownGid` operation. `None` causes the metadata
112    /// check to return `Degraded` rather than a hard `Fail`.
113    pub new_gid: Option<u32>,
114}
115
116#[cfg(test)]
117#[path = "operation_metadata_tests.rs"]
118mod metadata_tests;
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn checks_parent_true_for_delete() {
126        assert!(Operation::Delete.checks_parent());
127    }
128
129    #[test]
130    fn checks_parent_true_for_create() {
131        assert!(Operation::Create.checks_parent());
132    }
133
134    #[test]
135    fn checks_parent_false_for_read() {
136        assert!(!Operation::Read.checks_parent());
137    }
138
139    #[test]
140    fn checks_parent_false_for_write() {
141        assert!(!Operation::Write.checks_parent());
142    }
143
144    #[test]
145    fn checks_parent_false_for_execute() {
146        assert!(!Operation::Execute.checks_parent());
147    }
148
149    #[test]
150    fn checks_parent_false_for_stat() {
151        assert!(!Operation::Stat.checks_parent());
152    }
153
154    #[test]
155    fn target_component_last_for_read() {
156        // Walk: ["/", "/var", "/var/log"] -> index 2
157        assert_eq!(Operation::Read.target_component(3), Some(2));
158    }
159
160    #[test]
161    fn target_component_last_for_single_component() {
162        assert_eq!(Operation::Read.target_component(1), Some(0));
163    }
164
165    #[test]
166    fn target_component_second_to_last_for_delete() {
167        // Walk: ["/", "/var", "/var/log"] -> parent is index 1
168        assert_eq!(Operation::Delete.target_component(3), Some(1));
169    }
170
171    #[test]
172    fn target_component_second_to_last_for_create() {
173        assert_eq!(Operation::Create.target_component(3), Some(1));
174    }
175
176    #[test]
177    fn target_component_none_for_empty_walk() {
178        assert_eq!(Operation::Read.target_component(0), None);
179    }
180
181    #[test]
182    fn target_component_none_for_parent_op_with_single_component() {
183        // Walk: ["/"] -> no parent exists
184        assert_eq!(Operation::Delete.target_component(1), None);
185    }
186
187    #[test]
188    fn target_component_parent_op_with_two_components() {
189        // Walk: ["/", "/file"] -> parent is index 0
190        assert_eq!(Operation::Delete.target_component(2), Some(0));
191    }
192}