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}