Skip to main content

nd_300/platform/
invoking_user.rs

1//! Resolve the real desktop user when ND300 is running through `sudo`.
2//!
3//! Network repairs need root, but reports and user-scoped tools must continue
4//! to use the invoking user's home directory and ownership.  We deliberately
5//! trust only the numeric `SUDO_UID`/`SUDO_GID` pair and confirm the UID via
6//! the system passwd database; `SUDO_USER`, `HOME`, and `USER` are not treated
7//! as identity authorities.
8
9use std::ffi::{CStr, CString, OsStr};
10use std::fs::File;
11use std::io::{self, Write};
12use std::os::fd::{AsRawFd, FromRawFd, RawFd};
13use std::os::unix::ffi::OsStrExt;
14use std::os::unix::fs::PermissionsExt;
15use std::path::{Path, PathBuf};
16
17/// The account that launched ND300, even when the process is elevated by
18/// `sudo`.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct InvokingUser {
21    uid: libc::uid_t,
22    gid: libc::gid_t,
23    username: String,
24    home: PathBuf,
25    effective_uid: libc::uid_t,
26}
27
28impl InvokingUser {
29    /// Resolve the invoking account from numeric sudo metadata and the passwd
30    /// database. Without a valid sudo UID, this resolves the effective user.
31    pub fn detect() -> io::Result<Self> {
32        // SAFETY: geteuid has no preconditions and does not dereference
33        // pointers.
34        let effective_uid = unsafe { libc::geteuid() };
35
36        // A present-but-invalid SUDO_UID must fail closed. Silently treating a
37        // malformed value as "no sudo" would redirect reports and Cargo work
38        // into root's home, recreating the /var/root bug this context prevents.
39        let sudo_uid = if effective_uid == 0 {
40            match std::env::var("SUDO_UID") {
41                Ok(value) => Some(value.parse::<libc::uid_t>().map_err(|_| {
42                    io::Error::new(io::ErrorKind::InvalidData, "SUDO_UID is not numeric")
43                })?),
44                Err(std::env::VarError::NotPresent) => None,
45                Err(std::env::VarError::NotUnicode(_)) => {
46                    return Err(io::Error::new(
47                        io::ErrorKind::InvalidData,
48                        "SUDO_UID is not valid Unicode",
49                    ));
50                }
51            }
52        } else {
53            None
54        };
55        let uid = sudo_uid.unwrap_or(effective_uid);
56        let passwd = passwd_entry(uid)?;
57
58        let gid = if sudo_uid.is_some() {
59            let sudo_gid = match std::env::var("SUDO_GID") {
60                Ok(value) => Some(value),
61                Err(std::env::VarError::NotPresent) => None,
62                Err(std::env::VarError::NotUnicode(_)) => {
63                    return Err(io::Error::new(
64                        io::ErrorKind::InvalidData,
65                        "SUDO_GID is not valid Unicode",
66                    ));
67                }
68            };
69            validated_sudo_gid(sudo_gid.as_deref(), passwd.gid)?
70        } else {
71            passwd.gid
72        };
73
74        if !passwd.home.is_absolute() || passwd.home.as_os_str().is_empty() {
75            return Err(io::Error::new(
76                io::ErrorKind::InvalidData,
77                "invoking user's passwd entry has no absolute home directory",
78            ));
79        }
80
81        Ok(Self {
82            uid,
83            gid,
84            username: passwd.username,
85            home: passwd.home,
86            effective_uid,
87        })
88    }
89
90    pub fn uid(&self) -> libc::uid_t {
91        self.uid
92    }
93
94    pub fn gid(&self) -> libc::gid_t {
95        self.gid
96    }
97
98    pub fn username(&self) -> &str {
99        &self.username
100    }
101
102    pub fn home(&self) -> &Path {
103        &self.home
104    }
105
106    /// Cargo home for the invoking account. An explicit `CARGO_HOME` is
107    /// honored only when ND300 is not crossing from root to another user;
108    /// sudo-inherited root environment variables must never redirect a user
109    /// update into `/var/root`.
110    pub fn cargo_home(&self) -> PathBuf {
111        if !self.is_different_from_effective_user() {
112            if let Some(path) = std::env::var_os("CARGO_HOME").map(PathBuf::from) {
113                if path.is_absolute() {
114                    return path;
115                }
116            }
117        }
118        self.home.join(".cargo")
119    }
120
121    pub fn is_different_from_effective_user(&self) -> bool {
122        self.uid != self.effective_uid
123    }
124
125    /// Run a user-scoped child as the invoking account rather than root.
126    pub fn configure_command(&self, command: &mut tokio::process::Command) {
127        command.env("HOME", &self.home);
128        command.env("USER", &self.username);
129        command.env("LOGNAME", &self.username);
130        command.env("CARGO_HOME", self.cargo_home());
131        if self.is_different_from_effective_user() {
132            // Tokio forwards these to std::process::Command. Rust's Unix child
133            // setup calls setgid, then clears supplementary groups with
134            // setgroups(0, NULL), then calls setuid. Do not add a pre_exec
135            // initgroups callback here: NSS/initgroups is not async-signal-safe
136            // after fork in this multi-threaded process.
137            command.uid(self.uid);
138            command.gid(self.gid);
139        }
140    }
141
142    /// Write a private file through a same-directory temporary file, then
143    /// atomically rename it into place. The file is mode 0600 and is owned by
144    /// the invoking user even when ND300 itself is root.
145    pub fn write_private_atomic(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
146        let parent = path.parent().ok_or_else(|| {
147            io::Error::new(io::ErrorKind::InvalidInput, "output path has no parent")
148        })?;
149        let directory = match parent.strip_prefix(&self.home) {
150            Ok(_) => self.open_directory_beneath_home(parent, true)?,
151            Err(_) if !self.is_different_from_effective_user() => {
152                // A direct, non-elevated invocation may intentionally use an
153                // absolute XDG_CONFIG_HOME outside the passwd home. There is no
154                // cross-user privilege boundary in that case, but still pin the
155                // final directory with O_NOFOLLOW before touching a filename.
156                self.ensure_directory(parent)?;
157                open_directory_nofollow(parent, self.uid)?
158            }
159            Err(_) => {
160                return Err(io::Error::new(
161                    io::ErrorKind::PermissionDenied,
162                    "elevated output path is outside the invoking user's home",
163                ));
164            }
165        };
166        let target_name = path.file_name().ok_or_else(|| {
167            io::Error::new(io::ErrorKind::InvalidInput, "output path has no filename")
168        })?;
169        let target_name = component_cstring(target_name)?;
170
171        let mut temp_name = None;
172        let mut temp_file = None;
173        for attempt in 0..100_u32 {
174            let candidate = format!(".nd300-{}.{}.tmp", std::process::id(), attempt);
175            let candidate_c = CString::new(candidate.as_bytes()).expect("fixed temporary name");
176            match openat_create_new(directory.as_raw_fd(), &candidate_c, 0o600) {
177                Ok(file) => {
178                    temp_name = Some(candidate_c);
179                    temp_file = Some(file);
180                    break;
181                }
182                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
183                Err(error) => return Err(error),
184            }
185        }
186
187        let temp_name = temp_name.ok_or_else(|| {
188            io::Error::new(
189                io::ErrorKind::AlreadyExists,
190                "could not allocate a private temporary report file",
191            )
192        })?;
193        let mut file = temp_file.expect("temporary path and file are created together");
194
195        let result = (|| {
196            file.write_all(bytes)?;
197            file.sync_all()?;
198            file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
199            if self.is_different_from_effective_user() {
200                fchown(&file, self.uid, self.gid)?;
201                file.sync_all()?;
202            }
203            drop(file);
204            renameat_same_directory(directory.as_raw_fd(), &temp_name, &target_name)?;
205            directory.sync_all()?;
206            Ok(())
207        })();
208
209        if result.is_err() {
210            let _ = unlinkat_file(directory.as_raw_fd(), &temp_name);
211        }
212        result
213    }
214
215    /// Ensure a user-scoped directory exists and belongs to the invoking user.
216    pub fn ensure_directory(&self, path: &Path) -> io::Result<()> {
217        match std::fs::symlink_metadata(path) {
218            Ok(metadata) => {
219                if metadata.file_type().is_symlink() || !metadata.is_dir() {
220                    return Err(io::Error::new(
221                        io::ErrorKind::InvalidInput,
222                        "output parent is a symlink or is not a directory",
223                    ));
224                }
225                // Never chown a pre-existing user-controlled directory.
226                Ok(())
227            }
228            Err(error) if error.kind() == io::ErrorKind::NotFound => {
229                let parent = path.parent().ok_or_else(|| {
230                    io::Error::new(io::ErrorKind::InvalidInput, "directory has no parent")
231                })?;
232                self.ensure_directory(parent)?;
233                std::fs::create_dir(path)?;
234                let directory = File::open(path)?;
235                if self.is_different_from_effective_user() {
236                    fchown(&directory, self.uid, self.gid)?;
237                }
238                Ok(())
239            }
240            Err(error) => Err(error),
241        }
242    }
243
244    /// Validate and create a report directory strictly beneath the passwd home
245    /// without following a symlink out of that tree.
246    pub fn ensure_directory_beneath_home(&self, path: &Path) -> io::Result<()> {
247        self.open_directory_beneath_home(path, true).map(drop)
248    }
249
250    /// Open a stable descriptor for a directory beneath the passwd home.
251    /// Every component is traversed relative to the previously opened
252    /// descriptor with O_NOFOLLOW, so a user-controlled symlink swap cannot
253    /// redirect an elevated report or updater operation outside that home.
254    pub(crate) fn open_directory_beneath_home(
255        &self,
256        path: &Path,
257        create: bool,
258    ) -> io::Result<File> {
259        let relative = path.strip_prefix(&self.home).map_err(|_| {
260            io::Error::new(
261                io::ErrorKind::PermissionDenied,
262                "output directory is outside the invoking user's home",
263            )
264        })?;
265        if relative
266            .components()
267            .any(|component| !matches!(component, std::path::Component::Normal(_)))
268        {
269            return Err(io::Error::new(
270                io::ErrorKind::PermissionDenied,
271                "output directory contains unsafe path components",
272            ));
273        }
274
275        let canonical_home = std::fs::canonicalize(&self.home)?;
276        let mut directory = open_directory_nofollow(&canonical_home, self.uid)?;
277        for component in relative.components() {
278            let std::path::Component::Normal(name) = component else {
279                return Err(io::Error::new(
280                    io::ErrorKind::PermissionDenied,
281                    "output directory contains unsafe path components",
282                ));
283            };
284            let name = component_cstring(name)?;
285            directory = match openat_directory(directory.as_raw_fd(), &name, self.uid) {
286                Ok(child) => child,
287                Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
288                    mkdirat_owned(directory.as_raw_fd(), &name, self.uid, self.gid)?;
289                    openat_directory(directory.as_raw_fd(), &name, self.uid)?
290                }
291                Err(error) => return Err(error),
292            };
293        }
294        Ok(directory)
295    }
296}
297
298fn component_cstring(name: &OsStr) -> io::Result<CString> {
299    if name.as_bytes().is_empty() || name.as_bytes().contains(&b'/') {
300        return Err(io::Error::new(
301            io::ErrorKind::InvalidInput,
302            "path component is empty or contains a separator",
303        ));
304    }
305    CString::new(name.as_bytes()).map_err(|_| {
306        io::Error::new(
307            io::ErrorKind::InvalidInput,
308            "path component contains a NUL byte",
309        )
310    })
311}
312
313fn open_directory_nofollow(path: &Path, expected_uid: libc::uid_t) -> io::Result<File> {
314    let path = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
315        io::Error::new(
316            io::ErrorKind::InvalidInput,
317            "directory path contains a NUL byte",
318        )
319    })?;
320    // SAFETY: path is a live NUL-terminated string. The returned descriptor is
321    // immediately owned by File on success.
322    let fd = unsafe {
323        libc::open(
324            path.as_ptr(),
325            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
326        )
327    };
328    if fd < 0 {
329        return Err(normalize_nofollow_error(io::Error::last_os_error()));
330    }
331    // SAFETY: open returned a new owned descriptor.
332    let directory = unsafe { File::from_raw_fd(fd) };
333    verify_directory_owner(&directory, expected_uid)?;
334    Ok(directory)
335}
336
337fn openat_directory(parent: RawFd, name: &CStr, expected_uid: libc::uid_t) -> io::Result<File> {
338    let directory = openat_directory_unchecked(parent, name)?;
339    verify_directory_owner(&directory, expected_uid)?;
340    Ok(directory)
341}
342
343fn openat_directory_unchecked(parent: RawFd, name: &CStr) -> io::Result<File> {
344    // SAFETY: parent is a live directory fd and name is NUL-terminated.
345    let fd = unsafe {
346        libc::openat(
347            parent,
348            name.as_ptr(),
349            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
350        )
351    };
352    if fd < 0 {
353        return Err(normalize_nofollow_error(io::Error::last_os_error()));
354    }
355    // SAFETY: openat returned a new owned descriptor.
356    Ok(unsafe { File::from_raw_fd(fd) })
357}
358
359fn verify_directory_owner(directory: &File, expected_uid: libc::uid_t) -> io::Result<()> {
360    let mut stat = std::mem::MaybeUninit::<libc::stat>::uninit();
361    // SAFETY: stat points to writable storage and the fd is live.
362    if unsafe { libc::fstat(directory.as_raw_fd(), stat.as_mut_ptr()) } != 0 {
363        return Err(io::Error::last_os_error());
364    }
365    // SAFETY: fstat succeeded and initialized stat.
366    let stat = unsafe { stat.assume_init() };
367    if stat.st_mode & libc::S_IFMT != libc::S_IFDIR {
368        return Err(io::Error::new(
369            io::ErrorKind::InvalidInput,
370            "opened path is not a directory",
371        ));
372    }
373    if stat.st_uid != expected_uid {
374        return Err(io::Error::new(
375            io::ErrorKind::PermissionDenied,
376            format!(
377                "user-scoped directory is owned by uid {}, expected uid {}",
378                stat.st_uid, expected_uid
379            ),
380        ));
381    }
382    Ok(())
383}
384
385fn mkdirat_owned(parent: RawFd, name: &CStr, uid: libc::uid_t, gid: libc::gid_t) -> io::Result<()> {
386    // SAFETY: parent is a live directory fd and name is NUL-terminated.
387    let created = if unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) } != 0 {
388        let error = io::Error::last_os_error();
389        if error.kind() != io::ErrorKind::AlreadyExists {
390            return Err(error);
391        }
392        false
393    } else {
394        true
395    };
396    if created {
397        // An elevated process creates the component as root. Pin it without
398        // following links, transfer ownership through the descriptor, then
399        // verify the final type and owner before traversing it.
400        let child = openat_directory_unchecked(parent, name)?;
401        child.set_permissions(std::fs::Permissions::from_mode(0o700))?;
402        fchown(&child, uid, gid)?;
403        verify_directory_owner(&child, uid)
404    } else {
405        openat_directory(parent, name, uid).map(drop)
406    }
407}
408
409fn openat_create_new(parent: RawFd, name: &CStr, mode: libc::mode_t) -> io::Result<File> {
410    // SAFETY: parent is a live directory fd and name is NUL-terminated.
411    let fd = unsafe {
412        libc::openat(
413            parent,
414            name.as_ptr(),
415            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
416            libc::c_uint::from(mode),
417        )
418    };
419    if fd < 0 {
420        Err(normalize_nofollow_error(io::Error::last_os_error()))
421    } else {
422        // SAFETY: openat returned a new owned descriptor.
423        Ok(unsafe { File::from_raw_fd(fd) })
424    }
425}
426
427fn renameat_same_directory(parent: RawFd, old: &CStr, new: &CStr) -> io::Result<()> {
428    // SAFETY: both names are NUL-terminated and parent is a live directory fd.
429    if unsafe { libc::renameat(parent, old.as_ptr(), parent, new.as_ptr()) } == 0 {
430        Ok(())
431    } else {
432        Err(io::Error::last_os_error())
433    }
434}
435
436fn unlinkat_file(parent: RawFd, name: &CStr) -> io::Result<()> {
437    // SAFETY: name is NUL-terminated and parent is a live directory fd.
438    if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } == 0 {
439        Ok(())
440    } else {
441        Err(io::Error::last_os_error())
442    }
443}
444
445fn normalize_nofollow_error(error: io::Error) -> io::Error {
446    if matches!(
447        error.raw_os_error(),
448        Some(libc::ELOOP) | Some(libc::ENOTDIR)
449    ) {
450        io::Error::new(
451            io::ErrorKind::InvalidInput,
452            "directory path contains a symbolic link or non-directory component",
453        )
454    } else {
455        error
456    }
457}
458
459fn validated_sudo_gid(value: Option<&str>, passwd_gid: libc::gid_t) -> io::Result<libc::gid_t> {
460    let Some(value) = value else {
461        return Ok(passwd_gid);
462    };
463    let supplied = value
464        .parse::<libc::gid_t>()
465        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "SUDO_GID is not numeric"))?;
466    if supplied != passwd_gid {
467        return Err(io::Error::new(
468            io::ErrorKind::InvalidData,
469            format!(
470                "SUDO_GID {} does not match passwd primary gid {}",
471                supplied, passwd_gid
472            ),
473        ));
474    }
475    Ok(passwd_gid)
476}
477
478#[derive(Debug)]
479struct PasswdEntry {
480    gid: libc::gid_t,
481    username: String,
482    home: PathBuf,
483}
484
485fn passwd_entry(uid: libc::uid_t) -> io::Result<PasswdEntry> {
486    // 16 KiB is comfortably above typical _SC_GETPW_R_SIZE_MAX values while
487    // remaining bounded if the platform reports no recommendation.
488    let recommended = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
489    let size = if recommended > 0 {
490        usize::try_from(recommended)
491            .unwrap_or(16 * 1024)
492            .clamp(1024, 64 * 1024)
493    } else {
494        16 * 1024
495    };
496    let mut buffer = vec![0_u8; size];
497    let mut passwd = std::mem::MaybeUninit::<libc::passwd>::uninit();
498    let mut result = std::ptr::null_mut();
499
500    // SAFETY: passwd points to writable storage, buffer is valid for its
501    // length, and result is an out-pointer. We copy strings before the buffer
502    // leaves scope.
503    let status = unsafe {
504        libc::getpwuid_r(
505            uid,
506            passwd.as_mut_ptr(),
507            buffer.as_mut_ptr().cast(),
508            buffer.len(),
509            &mut result,
510        )
511    };
512    if status != 0 {
513        return Err(io::Error::from_raw_os_error(status));
514    }
515    if result.is_null() {
516        return Err(io::Error::new(
517            io::ErrorKind::NotFound,
518            format!("no passwd entry for uid {uid}"),
519        ));
520    }
521
522    // SAFETY: getpwuid_r succeeded and returned the same initialized passwd
523    // storage through `result`; its string pointers remain valid in buffer.
524    let passwd = unsafe { passwd.assume_init() };
525    if passwd.pw_name.is_null() || passwd.pw_dir.is_null() {
526        return Err(io::Error::new(
527            io::ErrorKind::InvalidData,
528            "passwd entry is missing a username or home directory",
529        ));
530    }
531    let username = unsafe { CStr::from_ptr(passwd.pw_name) }
532        .to_string_lossy()
533        .into_owned();
534    let home = PathBuf::from(
535        unsafe { CStr::from_ptr(passwd.pw_dir) }
536            .to_string_lossy()
537            .into_owned(),
538    );
539
540    Ok(PasswdEntry {
541        gid: passwd.pw_gid,
542        username,
543        home,
544    })
545}
546
547fn fchown(file: &File, uid: libc::uid_t, gid: libc::gid_t) -> io::Result<()> {
548    // SAFETY: the fd belongs to the live File, and uid/gid are plain values.
549    let status = unsafe { libc::fchown(file.as_raw_fd(), uid, gid) };
550    if status == 0 {
551        Ok(())
552    } else {
553        Err(io::Error::last_os_error())
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn current_account_resolves_to_an_absolute_home() {
563        let user = InvokingUser::detect().expect("current passwd entry");
564        assert!(user.home().is_absolute());
565        assert!(!user.username().is_empty());
566    }
567
568    #[test]
569    fn private_atomic_write_uses_mode_0600() {
570        let user = InvokingUser::detect().expect("current passwd entry");
571        let root =
572            std::env::temp_dir().join(format!("nd300-invoking-user-test-{}", std::process::id()));
573        let _ = std::fs::remove_dir_all(&root);
574        std::fs::create_dir_all(&root).unwrap();
575        let path = root.join("report.md");
576
577        user.write_private_atomic(&path, b"private\n").unwrap();
578
579        let metadata = std::fs::metadata(&path).unwrap();
580        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
581        assert_eq!(std::fs::read(&path).unwrap(), b"private\n");
582        let _ = std::fs::remove_dir_all(root);
583    }
584
585    #[test]
586    fn sudo_gid_must_match_the_passwd_primary_gid() {
587        assert_eq!(validated_sudo_gid(None, 501).unwrap(), 501);
588        assert_eq!(validated_sudo_gid(Some("501"), 501).unwrap(), 501);
589        assert!(validated_sudo_gid(Some("20"), 501).is_err());
590        assert!(validated_sudo_gid(Some("not-a-number"), 501).is_err());
591    }
592
593    #[cfg(unix)]
594    #[test]
595    fn private_write_refuses_a_symlink_parent() {
596        use std::os::unix::fs::symlink;
597
598        let user = InvokingUser::detect().expect("current passwd entry");
599        let root =
600            std::env::temp_dir().join(format!("nd300-symlink-parent-test-{}", std::process::id()));
601        let target = root.join("target");
602        let link = root.join("linked-parent");
603        let _ = std::fs::remove_dir_all(&root);
604        std::fs::create_dir_all(&target).unwrap();
605        symlink(&target, &link).unwrap();
606
607        let error = user
608            .write_private_atomic(&link.join("report.md"), b"must not write\n")
609            .unwrap_err();
610        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
611        assert!(!target.join("report.md").exists());
612        let _ = std::fs::remove_dir_all(root);
613    }
614
615    #[test]
616    fn home_relative_write_refuses_an_intermediate_symlink() {
617        use std::os::unix::fs::symlink;
618
619        let root = std::env::temp_dir().join(format!(
620            "nd300-home-walk-symlink-test-{}",
621            std::process::id()
622        ));
623        let home = root.join("home");
624        let outside = root.join("outside");
625        let _ = std::fs::remove_dir_all(&root);
626        std::fs::create_dir_all(&home).unwrap();
627        std::fs::create_dir_all(&outside).unwrap();
628        symlink(&outside, home.join("Downloads")).unwrap();
629
630        let mut user = InvokingUser::detect().expect("current passwd entry");
631        user.home = home.clone();
632        let error = user
633            .write_private_atomic(&home.join("Downloads/report.md"), b"private\n")
634            .unwrap_err();
635        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
636        assert!(!outside.join("report.md").exists());
637        let _ = std::fs::remove_dir_all(root);
638    }
639
640    #[test]
641    fn private_atomic_write_replaces_a_target_symlink_without_following_it() {
642        use std::os::unix::fs::symlink;
643
644        let root = std::env::temp_dir().join(format!(
645            "nd300-report-target-symlink-test-{}",
646            std::process::id()
647        ));
648        let home = root.join("home");
649        let downloads = home.join("Downloads");
650        let victim = root.join("victim.txt");
651        let report = downloads.join("report.md");
652        let _ = std::fs::remove_dir_all(&root);
653        std::fs::create_dir_all(&downloads).unwrap();
654        std::fs::write(&victim, b"do not modify\n").unwrap();
655        symlink(&victim, &report).unwrap();
656
657        let mut user = InvokingUser::detect().expect("current passwd entry");
658        user.home = home;
659        user.write_private_atomic(&report, b"new report\n").unwrap();
660
661        assert_eq!(std::fs::read(&victim).unwrap(), b"do not modify\n");
662        assert_eq!(std::fs::read(&report).unwrap(), b"new report\n");
663        assert!(!std::fs::symlink_metadata(&report)
664            .unwrap()
665            .file_type()
666            .is_symlink());
667        let _ = std::fs::remove_dir_all(root);
668    }
669}