1use super::{
4 ProjectStateGuard, containing_directory, refusal, require_regular,
5 resolve_existing_parent_prefix,
6};
7use rustix::fs::{
8 AtFlags, CWD, Mode, OFlags, StatxAttributes, StatxFlags, fstatfs, ioctl_getflags, linkat,
9 openat, statx,
10};
11use std::fs::{File, Metadata, Permissions};
12use std::io::{self, Write};
13use std::os::fd::AsRawFd;
14use std::os::unix::fs::MetadataExt;
15use std::path::Path;
16use xattr::FileExt;
17
18const ACCESS_ATTRIBUTES: [&str; 6] = [
20 "system.posix_acl_access",
21 "system.nfs4_acl",
22 "security.selinux",
23 "security.SMACK64",
24 "security.SMACK64EXEC",
25 "security.SMACK64MMAP",
26];
27
28const CASEFOLD_FLAG: u32 = 0x4000_0000;
30const VERITY_FLAG: u32 = 0x0010_0000;
32
33pub(super) fn fixed_name_matches(name: &str, expected: &str) -> bool {
40 if !expected.is_ascii() {
41 return false;
42 }
43 let mut remaining = expected.as_bytes();
44 for character in name.chars() {
45 let ascii = [character.to_ascii_lowercase() as u8];
46 let chunk: &[u8] = match character {
47 '\u{00ad}'
48 | '\u{034f}'
49 | '\u{061c}'
50 | '\u{115f}'..='\u{1160}'
51 | '\u{17b4}'..='\u{17b5}'
52 | '\u{180b}'..='\u{180e}'
53 | '\u{200b}'..='\u{200f}'
54 | '\u{202a}'..='\u{202e}'
55 | '\u{2060}'..='\u{2064}'
56 | '\u{2066}'..='\u{206f}'
57 | '\u{3164}'
58 | '\u{fe00}'..='\u{fe0f}'
59 | '\u{feff}'
60 | '\u{ffa0}'
61 | '\u{1bca0}'..='\u{1bca3}'
62 | '\u{1d173}'..='\u{1d17a}'
63 | '\u{e0001}'
64 | '\u{e0020}'..='\u{e007f}'
65 | '\u{e0100}'..='\u{e01ef}' => b"",
66 '\u{00df}' | '\u{1e9e}' => b"ss",
67 '\u{017f}' => b"s",
68 '\u{037e}' => b";",
69 '\u{1fef}' => b"`",
70 '\u{212a}' => b"k",
71 '\u{fb00}' => b"ff",
72 '\u{fb01}' => b"fi",
73 '\u{fb02}' => b"fl",
74 '\u{fb03}' => b"ffi",
75 '\u{fb04}' => b"ffl",
76 '\u{fb05}' | '\u{fb06}' => b"st",
77 character if character.is_ascii() => &ascii,
78 _ => return false,
79 };
80 for byte in chunk {
81 match remaining.split_first() {
82 Some((candidate, suffix)) if candidate.to_ascii_lowercase() == *byte => {
83 remaining = suffix;
84 }
85 _ => return false,
86 }
87 }
88 }
89 remaining.is_empty()
90}
91
92pub(super) fn named(path: &Path, expected: &str) -> io::Result<bool> {
94 let name = path
95 .file_name()
96 .and_then(std::ffi::OsStr::to_str)
97 .ok_or_else(|| refusal(path, "managed filename is not UTF-8"))?;
98 let parent = resolve_existing_parent_prefix(containing_directory(path))?;
99 let mut prefix = parent.as_path();
100 let directory = loop {
101 match openat(
102 CWD,
103 prefix,
104 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
105 Mode::empty(),
106 ) {
107 Ok(directory) => break directory,
108 Err(rustix::io::Errno::NOENT) => {
109 prefix = prefix
110 .parent()
111 .ok_or_else(|| refusal(path, "managed containing directory is unavailable"))?;
112 }
113 Err(source) => return Err(source.into()),
114 }
115 };
116 let filesystem = fstatfs(&directory)?.f_type as u32;
119 let flags = match ioctl_getflags(&directory) {
120 Ok(flags) => flags.bits(),
121 Err(rustix::io::Errno::NOTTY | rustix::io::Errno::OPNOTSUPP)
122 if matches!(filesystem, 0x0102_1994 | 0x9123_683e) =>
123 {
124 0
125 }
126 Err(source) => return Err(source.into()),
127 };
128 let folded = flags & CASEFOLD_FLAG != 0;
129 match filesystem {
130 0xef53 | 0xf2f5_2010 => Ok(folded && fixed_name_matches(name, expected)),
133 0x5846_5342 => Ok(folded && name.eq_ignore_ascii_case(expected)),
135 0x0102_1994 | 0x9123_683e if !folded => Ok(false),
137 _ => Err(refusal(
138 path,
139 "managed case-alias filesystem comparison model is unknown",
140 )),
141 }
142}
143
144fn require_unsealed(file: &File, path: &Path) -> io::Result<()> {
146 let sealed = match statx(file, "", AtFlags::EMPTY_PATH, StatxFlags::empty()) {
147 Ok(state) if state.stx_attributes_mask.contains(StatxAttributes::VERITY) => {
148 Some(state.stx_attributes.contains(StatxAttributes::VERITY))
149 }
150 Ok(_)
151 | Err(rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP | rustix::io::Errno::NOTTY) => {
152 None
153 }
154 Err(source) => return Err(source.into()),
155 };
156 let sealed = match sealed {
157 Some(sealed) => sealed,
158 None => match ioctl_getflags(file) {
159 Ok(flags) => flags.bits() & VERITY_FLAG != 0,
160 Err(rustix::io::Errno::NOTTY | rustix::io::Errno::OPNOTSUPP) => false,
164 Err(source) => return Err(source.into()),
165 },
166 };
167 if sealed {
168 return Err(refusal(
169 path,
170 "fs-verity-protected output cannot be replaced",
171 ));
172 }
173 Ok(())
174}
175
176pub(super) struct ReplacementPolicy {
178 source: File,
180 metadata: Metadata,
182 attributes: Vec<(&'static str, Option<Vec<u8>>)>,
184}
185
186impl ReplacementPolicy {
187 pub(super) fn capture(path: &Path, expected: &Metadata) -> io::Result<Self> {
189 let flags = OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
190 let source = match openat(CWD, path, flags | OFlags::RDONLY, Mode::empty()) {
191 Ok(file) => file,
192 Err(rustix::io::Errno::ACCESS) => {
193 openat(CWD, path, flags | OFlags::WRONLY, Mode::empty())?
194 }
195 Err(source) => return Err(source.into()),
196 };
197 let source = File::from(source);
198 let metadata = source.metadata()?;
199 require_regular(path, &metadata)?;
200 if metadata.dev() != expected.dev() || metadata.ino() != expected.ino() {
201 return Err(refusal(
202 path,
203 "destination object changed before access capture",
204 ));
205 }
206 require_unsealed(&source, path)?;
207 let mut attributes = Vec::with_capacity(ACCESS_ATTRIBUTES.len());
208 for name in ACCESS_ATTRIBUTES {
209 match source.get_xattr(name) {
210 Ok(value) => attributes.push((name, value)),
211 Err(source) if source.kind() == io::ErrorKind::Unsupported => {}
212 Err(source) => return Err(source),
213 }
214 }
215 Ok(Self {
216 source,
217 metadata,
218 attributes,
219 })
220 }
221
222 pub(super) fn permissions(&self) -> Permissions {
224 self.metadata.permissions()
225 }
226
227 pub(super) fn matches(&self, file: &File) -> io::Result<bool> {
229 let current = file.metadata()?;
230 if current.dev() != self.metadata.dev()
231 || current.uid() != self.metadata.uid()
232 || current.gid() != self.metadata.gid()
233 || current.mode() != self.metadata.mode()
234 {
235 return Ok(false);
236 }
237 for (name, value) in &self.attributes {
238 if file.get_xattr(name)? != *value {
239 return Ok(false);
240 }
241 }
242 Ok(true)
243 }
244
245 pub(super) fn verify_destination(&self, path: &Path) -> io::Result<()> {
247 require_unsealed(&self.source, path)?;
248 let current = std::fs::symlink_metadata(path)?;
249 require_regular(path, ¤t)?;
250 if current.dev() != self.metadata.dev()
251 || current.ino() != self.metadata.ino()
252 || !self.matches(&self.source)?
253 {
254 return Err(refusal(path, "destination object or access policy changed"));
255 }
256 Ok(())
257 }
258
259 pub(super) fn apply(&self, file: &File) -> io::Result<()> {
261 let current = file.metadata()?;
262 if current.uid() != self.metadata.uid() || current.gid() != self.metadata.gid() {
263 std::os::unix::fs::fchown(file, Some(self.metadata.uid()), Some(self.metadata.gid()))?;
264 }
265 for (name, value) in &self.attributes {
266 if file.get_xattr(name)? == *value {
267 continue;
268 }
269 if let Some(value) = value {
270 file.set_xattr(name, value)?;
271 } else {
272 file.remove_xattr(name)?;
273 }
274 }
275 if file.metadata()?.mode() != self.metadata.mode() {
276 file.set_permissions(self.metadata.permissions())?;
277 }
278 if !self.matches(file)? {
279 return Err(io::Error::new(
280 io::ErrorKind::InvalidInput,
281 "anonymous stage access policy differs from destination",
282 ));
283 }
284 Ok(())
285 }
286
287 pub(super) fn anonymous_stage(
289 &self,
290 builder: &tempfile::Builder<'_, '_>,
291 guard: &ProjectStateGuard,
292 bytes: &[u8],
293 ) -> io::Result<tempfile::NamedTempFile> {
294 builder.make_in(&guard.directory, |path| {
295 guard.verify_directories()?;
296 let mut file = File::from(openat(
297 CWD,
298 &guard.directory,
299 OFlags::TMPFILE | OFlags::RDWR | OFlags::CLOEXEC,
300 Mode::RUSR | Mode::WUSR,
301 )?);
302 file.write_all(bytes)?;
305 file.sync_all()?;
306 self.apply(&file)?;
307 file.sync_all()?;
308 if !self.matches(&file)? {
309 return Err(refusal(
310 path,
311 "anonymous stage access policy changed while writing",
312 ));
313 }
314 guard.verify_directories()?;
315 match linkat(&file, "", CWD, path, AtFlags::EMPTY_PATH) {
318 Ok(()) => {}
319 Err(rustix::io::Errno::PERM | rustix::io::Errno::NOENT) => {
320 guard.verify_directories()?;
321 let descriptor = format!("/proc/self/fd/{}", file.as_raw_fd());
322 linkat(CWD, descriptor.as_str(), CWD, path, AtFlags::SYMLINK_FOLLOW)?;
323 }
324 Err(source) => return Err(source.into()),
325 }
326 Ok(file)
327 })
328 }
329}