git_policy_cli/subvolume/subvolume_sync_attrs.rs
1//! What:
2//! Copying the attributes of one directory entry onto its copy: owner, permission bits, access and modification
3//! times to the nanosecond, and extended attributes (user attributes and POSIX ACLs included); and recreating a
4//! named pipe.
5//! Why:
6//! `cp --archive` and `rsync --archive --xattrs` did this in the migration script. The index stat rewrite trusts
7//! that sizes and modification times survive the copy, and Git compares the mode and owner of each entry, so the
8//! copy must carry them. Failures that an unprivileged user must expect are tolerated and counted, never silent: an
9//! owner other than the caller's cannot be set (`EPERM`), and a security label (`security.selinux`) is set by the
10//! policy for a new file, not by the caller. A failure that no unprivileged user should meet (a permission change or
11//! a time change refused for lack of space, say) is an error, because the copy would be recorded as faithful when it
12//! is not. The attributes are applied through the descriptor of the copy, so a path swapped for a link while the
13//! pass runs cannot redirect them; only a symbolic link, which cannot be opened, is addressed by its path.
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! await chown(copy, st.uid, st.gid); await chmod(copy, st.mode & 0o7777); await utimes(copy, st.atime, st.mtime);
18//! ```
19
20use crate::fs_primitive_error::c_path;
21use std::collections::BTreeMap;
22use std::ffi::{CStr, CString};
23use std::fs::{File, Metadata};
24use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
25use std::os::unix::io::AsRawFd;
26use std::path::Path;
27
28/// What:
29/// How many attribute operations could not be applied, by kind.
30/// Why:
31/// The report says so, so a person knows what the copy does not carry.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// type AttributeLosses = { owner: number; extendedAttributes: number; mode: number; times: number };
36/// ```
37#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
38pub struct AttributeLosses {
39 /// Entries whose owner or group could not be set.
40 pub owner: u64,
41 /// Extended attributes that could not be copied.
42 pub extended_attributes: u64,
43 /// Entries whose permission bits the operating system refused to change for lack of ownership.
44 pub mode: u64,
45 /// Entries whose times the operating system refused to change for lack of ownership.
46 pub times: u64,
47}
48
49/// What:
50/// An attribute operation that failed in a way the copy cannot tolerate.
51/// Why:
52/// The caller names the path and turns it into the migration's error.
53///
54/// In TS you'd write (pseudocode):
55/// ```ts
56/// type AttributeFailure = { action: string; error: Error };
57/// ```
58#[derive(Debug)]
59pub struct AttributeFailure {
60 /// What was being set, in words for `cannot <action> <path>`.
61 pub action: &'static str,
62 /// The operating system's refusal.
63 pub error: std::io::Error,
64}
65
66/// What:
67/// Where an attribute operation lands: an open file or directory, or a symbolic link addressed by its path.
68/// Why:
69/// The `f` calls work on a descriptor and cannot be redirected; a symbolic link cannot be opened for them, so the
70/// `l` calls take its path and never follow it.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// type Handle = { kind: 'descriptor'; file: FileHandle } | { kind: 'link'; path: string };
75/// ```
76#[derive(Clone, Copy)]
77enum Handle<'a> {
78 /// An open file, directory or named pipe.
79 Descriptor(&'a File),
80 /// A symbolic link.
81 Link(&'a CStr),
82}
83
84/// Whether an attribute is one the copy carries and the equality check compares: user attributes and POSIX ACLs.
85/// The security label and the other namespaces belong to the policy and to privileged users.
86fn is_carried(name: &[u8]) -> bool {
87 return name.starts_with(b"user.") || name == b"system.posix_acl_access" || name == b"system.posix_acl_default";
88}
89
90/// The names of the extended attributes of a handle, or nothing when it has none or the filesystem refuses.
91fn attribute_names(handle: Handle<'_>) -> Vec<Vec<u8>> {
92 // SAFETY: a null buffer with size 0 asks for the length of the list.
93 let length: isize = unsafe {
94 match handle {
95 Handle::Descriptor(file) => libc::flistxattr(file.as_raw_fd(), std::ptr::null_mut(), 0),
96 Handle::Link(path) => libc::llistxattr(path.as_ptr(), std::ptr::null_mut(), 0),
97 }
98 };
99 if length <= 0 {
100 return Vec::new();
101 }
102 let mut names: Vec<u8> = vec![0; length as usize];
103 // SAFETY: `names` holds `length` writable bytes.
104 let filled: isize = unsafe {
105 match handle {
106 Handle::Descriptor(file) => libc::flistxattr(file.as_raw_fd(), names.as_mut_ptr().cast(), names.len()),
107 Handle::Link(path) => libc::llistxattr(path.as_ptr(), names.as_mut_ptr().cast(), names.len()),
108 }
109 };
110 if filled <= 0 {
111 return Vec::new();
112 }
113 names.truncate(filled as usize);
114 return names
115 .split(|byte: &u8| { return *byte == 0; })
116 .filter(|name: &&[u8]| { return !name.is_empty(); })
117 .map(<[u8]>::to_vec)
118 .collect();
119}
120
121/// The value of one extended attribute, or nothing when it cannot be read.
122fn attribute_value(handle: Handle<'_>, name: &CStr) -> Option<Vec<u8>> {
123 // SAFETY: a null buffer with size 0 asks for the length of the value.
124 let size: isize = unsafe {
125 match handle {
126 Handle::Descriptor(file) => libc::fgetxattr(file.as_raw_fd(), name.as_ptr(), std::ptr::null_mut(), 0),
127 Handle::Link(path) => libc::lgetxattr(path.as_ptr(), name.as_ptr(), std::ptr::null_mut(), 0),
128 }
129 };
130 if size < 0 {
131 return None;
132 }
133 let mut value: Vec<u8> = vec![0; size as usize];
134 // SAFETY: `value` holds `size` writable bytes.
135 let read: isize = unsafe {
136 match handle {
137 Handle::Descriptor(file) => libc::fgetxattr(file.as_raw_fd(), name.as_ptr(), value.as_mut_ptr().cast(), value.len()),
138 Handle::Link(path) => libc::lgetxattr(path.as_ptr(), name.as_ptr(), value.as_mut_ptr().cast(), value.len()),
139 }
140 };
141 if read < 0 {
142 return None;
143 }
144 value.truncate(read as usize);
145 return Some(value);
146}
147
148/// Set one extended attribute; true when the operating system accepted it.
149fn set_attribute(handle: Handle<'_>, name: &CStr, value: &[u8]) -> bool {
150 // SAFETY: `value` holds `value.len()` initialised bytes; the names are NUL-terminated strings.
151 let result: libc::c_int = unsafe {
152 match handle {
153 Handle::Descriptor(file) => libc::fsetxattr(file.as_raw_fd(), name.as_ptr(), value.as_ptr().cast(), value.len(), 0),
154 Handle::Link(path) => libc::lsetxattr(path.as_ptr(), name.as_ptr(), value.as_ptr().cast(), value.len(), 0),
155 }
156 };
157 return result == 0;
158}
159
160/// Remove one extended attribute; true when it is gone.
161fn remove_attribute(handle: Handle<'_>, name: &CStr) -> bool {
162 // SAFETY: the names are NUL-terminated strings.
163 let result: libc::c_int = unsafe {
164 match handle {
165 Handle::Descriptor(file) => libc::fremovexattr(file.as_raw_fd(), name.as_ptr()),
166 Handle::Link(path) => libc::lremovexattr(path.as_ptr(), name.as_ptr()),
167 }
168 };
169 return result == 0;
170}
171
172/// What:
173/// Copy every extended attribute of `source` onto the handle, then remove the carried ones the source lacks.
174/// Why:
175/// User attributes and ACLs belong to the file. An attribute the source no longer has must not survive on the copy
176/// (a removed ACL entry would keep granting access); only the carried namespaces are removed, because the security
177/// label is the policy's. A refused attribute is counted, because the caller is not the privileged user that may set
178/// every namespace.
179///
180/// In TS you'd write (pseudocode):
181/// ```ts
182/// for (const name of await listxattr(source)) await setxattr(copy, name, await getxattr(source, name));
183/// ```
184fn copy_extended_attributes(source: &CStr, destination: Handle<'_>, remove_extras: bool, losses: &mut AttributeLosses) {
185 let wanted: Vec<Vec<u8>> = attribute_names(Handle::Link(source));
186 for name in &wanted {
187 let Ok(attribute) = CString::new(name.as_slice()) else { continue };
188 let Some(value) = attribute_value(Handle::Link(source), attribute.as_c_str()) else {
189 losses.extended_attributes += 1;
190 continue;
191 };
192 if !set_attribute(destination, attribute.as_c_str(), value.as_slice()) {
193 losses.extended_attributes += 1;
194 }
195 }
196 if !remove_extras {
197 return;
198 }
199 for name in attribute_names(destination) {
200 if !is_carried(name.as_slice()) || wanted.contains(&name) {
201 continue;
202 }
203 let Ok(attribute) = CString::new(name.as_slice()) else { continue };
204 if !remove_attribute(destination, attribute.as_c_str()) {
205 losses.extended_attributes += 1;
206 }
207 }
208}
209
210/// The carried extended attributes of a path (a link's own, not its target's), by name.
211fn carried_attributes(path: &CStr) -> BTreeMap<Vec<u8>, Vec<u8>> {
212 let mut found: BTreeMap<Vec<u8>, Vec<u8>> = BTreeMap::new();
213 for name in attribute_names(Handle::Link(path)) {
214 if !is_carried(name.as_slice()) {
215 continue;
216 }
217 let Ok(attribute) = CString::new(name.as_slice()) else { continue };
218 if let Some(value) = attribute_value(Handle::Link(path), attribute.as_c_str()) {
219 found.insert(name, value);
220 }
221 }
222 return found;
223}
224
225/// What:
226/// Whether two paths carry the same user attributes and ACLs.
227/// Why:
228/// Size, time and mode do not change when only an attribute does, so the equality of a copy and its original has to
229/// look at them too, or a later pass would call a file with an outdated ACL unchanged.
230///
231/// In TS you'd write (pseudocode):
232/// ```ts
233/// const same = await carriedAttributesEqual(source, destination);
234/// ```
235pub fn carried_attributes_equal(source: &Path, destination: &Path) -> bool {
236 let (Ok(from), Ok(to)) = (c_path("llistxattr", source), c_path("llistxattr", destination)) else { return false };
237 return carried_attributes(from.as_c_str()) == carried_attributes(to.as_c_str());
238}
239
240/// A failed owner, permission or time change that only a missing privilege explains.
241fn is_missing_privilege(error: &std::io::Error) -> bool {
242 return matches!(error.raw_os_error(), Some(libc::EPERM | libc::EACCES));
243}
244
245/// What:
246/// Decide what a failed permission or time change means: a missing privilege is counted, anything else is an error.
247/// Why:
248/// An entry owned by someone else cannot be changed by this user, and the report says how many; a refusal for another
249/// reason (no space for the metadata, a read-only filesystem) leaves a copy that is not what it is recorded to be.
250///
251/// In TS you'd write (pseudocode):
252/// ```ts
253/// if (error?.code === 'EPERM') refused += 1; else if (error) throw error;
254/// ```
255fn settle(outcome: std::io::Result<()>, action: &'static str, refused: &mut u64) -> Result<(), AttributeFailure> {
256 let Err(error) = outcome else { return Ok(()) };
257 if !is_missing_privilege(&error) {
258 return Err(AttributeFailure { action, error });
259 }
260 *refused += 1;
261 return Ok(());
262}
263
264/// What:
265/// Whether the copy already has the original's owner and group, each given as (user, group).
266/// Why:
267/// Both must agree before the change is skipped: a copy that differs in only one of them still needs it, and the pure
268/// comparison is tested on every combination without needing the privilege to make files of other owners.
269///
270/// In TS you'd write (pseudocode):
271/// ```ts
272/// const same = source.uid === current.uid && source.gid === current.gid;
273/// ```
274fn same_owner(source: (u32, u32), current: (u32, u32)) -> bool {
275 return source.0 == current.0 && source.1 == current.1;
276}
277
278/// What:
279/// Set the owner and group of the copy to those of the original when they differ from what the copy has now.
280/// Why:
281/// The copy's owner comes from its creator and its group from the creator's group or the parent's setgid
282/// directory, so the destination, not the caller, is what must be compared with the original.
283///
284/// In TS you'd write (pseudocode):
285/// ```ts
286/// if (copyStat.uid !== st.uid || copyStat.gid !== st.gid) await lchown(copy, st.uid, st.gid);
287/// ```
288fn set_owner(destination: Handle<'_>, current: &Metadata, source: &Metadata, losses: &mut AttributeLosses) {
289 if same_owner((source.uid(), source.gid()), (current.uid(), current.gid())) {
290 return;
291 }
292 // SAFETY: the descriptor or the NUL-terminated path is valid for the call.
293 let result: libc::c_int = unsafe {
294 match destination {
295 Handle::Descriptor(file) => libc::fchown(file.as_raw_fd(), source.uid(), source.gid()),
296 Handle::Link(path) => libc::lchown(path.as_ptr(), source.uid(), source.gid()),
297 }
298 };
299 if result != 0 {
300 losses.owner += 1;
301 }
302}
303
304/// What:
305/// Set the access and modification times of the copy, to the nanosecond.
306/// `futimens` acts on the descriptor; `utimensat` with `AT_SYMLINK_NOFOLLOW` acts on a symbolic link, not its target.
307/// Why:
308/// The index stat rewrite and Git's own stat check compare modification times.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// await lutimes(path, atime, mtime);
313/// ```
314fn set_times(destination: Handle<'_>, source: &Metadata) -> std::io::Result<()> {
315 let times: [libc::timespec; 2] = [
316 libc::timespec { tv_sec: source.atime(), tv_nsec: source.atime_nsec() },
317 libc::timespec { tv_sec: source.mtime(), tv_nsec: source.mtime_nsec() },
318 ];
319 // SAFETY: `times` is two `timespec` values alive for the call, and the descriptor or path is valid.
320 let result: libc::c_int = unsafe {
321 match destination {
322 Handle::Descriptor(file) => libc::futimens(file.as_raw_fd(), times.as_ptr()),
323 Handle::Link(path) => libc::utimensat(libc::AT_FDCWD, path.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW),
324 }
325 };
326 if result != 0 {
327 return Err(std::io::Error::last_os_error());
328 }
329 return Ok(());
330}
331
332/// The shared body of both public functions.
333fn apply(
334 source: &Path,
335 destination: Handle<'_>,
336 current: &Metadata,
337 metadata: &Metadata,
338 remove_extras: bool,
339 losses: &mut AttributeLosses,
340) -> Result<(), AttributeFailure> {
341 if let Ok(from) = c_path("llistxattr", source) {
342 copy_extended_attributes(from.as_c_str(), destination, remove_extras, losses);
343 }
344 set_owner(destination, current, metadata, losses);
345 if let Handle::Descriptor(file) = destination {
346 // SAFETY: the descriptor is open for the call.
347 let result: libc::c_int = unsafe { libc::fchmod(file.as_raw_fd(), metadata.mode() & 0o7777) };
348 let outcome: std::io::Result<()> = if result == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) };
349 settle(outcome, "set the permission bits of", &mut losses.mode)?;
350 }
351 settle(set_times(destination, metadata), "set the times of", &mut losses.times)?;
352 return Ok(());
353}
354
355/// What:
356/// Make the open copy carry the attributes of `source`: extended attributes, owner, permission bits and times, in this
357/// order.
358/// `remove_extras` also removes the carried attributes the source lacks.
359/// Why:
360/// Setting the owner clears the set-user-ID bits, so the permission bits come after it; the times come last,
361/// because the earlier calls change the change time but not the modification time, and writing the file's content
362/// must already be finished. The descriptor may be a regular file, a directory or a named pipe.
363///
364/// In TS you'd write (pseudocode):
365/// ```ts
366/// await preserveAttributes(source, copyHandle, metadata);
367/// ```
368pub fn preserve_on_descriptor(
369 source: &Path,
370 destination: &File,
371 metadata: &Metadata,
372 remove_extras: bool,
373 losses: &mut AttributeLosses,
374) -> Result<(), AttributeFailure> {
375 let current: Metadata = destination.metadata().map_err(|error: std::io::Error| { return AttributeFailure { action: "examine", error }; })?;
376 return apply(source, Handle::Descriptor(destination), ¤t, metadata, remove_extras, losses);
377}
378
379/// What:
380/// Make the symbolic link at `destination` carry the owner and times of the original link.
381/// Why:
382/// A link has no permission bits of its own and cannot be opened, so only the `l` calls on its path apply.
383///
384/// In TS you'd write (pseudocode):
385/// ```ts
386/// await lchown(link, st.uid, st.gid); await lutimes(link, st.atime, st.mtime);
387/// ```
388pub fn preserve_on_link(source: &Path, destination: &Path, metadata: &Metadata, losses: &mut AttributeLosses) -> Result<(), AttributeFailure> {
389 let name: CString = c_path("lchown", destination).map_err(|error| { return AttributeFailure { action: "address", error: std::io::Error::from_raw_os_error(error.errno) }; })?;
390 let current: Metadata = std::fs::symlink_metadata(destination).map_err(|error: std::io::Error| { return AttributeFailure { action: "examine", error }; })?;
391 return apply(source, Handle::Link(name.as_c_str()), ¤t, metadata, false, losses);
392}
393
394/// What:
395/// Open a named pipe without blocking, so attribute calls can use its descriptor.
396/// Why:
397/// Opening a pipe read-only with `O_NONBLOCK` succeeds at once whether or not a writer exists, and `O_NOFOLLOW`
398/// refuses a link put in its place.
399///
400/// In TS you'd write (pseudocode):
401/// ```ts
402/// const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
403/// ```
404pub fn open_fifo(path: &Path) -> std::io::Result<File> {
405 return std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW | libc::O_CLOEXEC).open(path);
406}
407
408/// What:
409/// Create a named pipe (FIFO) with the permission bits of the original.
410/// `mkfifo(3)` is the call; the umask applies, and `preserve_on_descriptor` sets the exact bits afterwards.
411/// Why:
412/// A FIFO is part of the tree; sockets and device nodes are not recreated (the report lists them).
413///
414/// In TS you'd write (pseudocode):
415/// ```ts
416/// await mkfifo(path, mode);
417/// ```
418pub fn make_fifo(path: &Path, mode: u32) -> std::io::Result<()> {
419 let name = c_path("mkfifo", path).map_err(|error| { return std::io::Error::from_raw_os_error(error.errno); })?;
420 // SAFETY: `name` is a NUL-terminated string that outlives the call.
421 let result: libc::c_int = unsafe { libc::mkfifo(name.as_ptr(), mode & 0o7777) };
422 if result != 0 {
423 return Err(std::io::Error::last_os_error());
424 }
425 return Ok(());
426}
427
428/// Attribute controls stay out of the release executable.
429#[cfg(test)]
430#[path = "subvolume_sync_attrs_tests.rs"]
431mod tests;