1use 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#[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 pub fn detect() -> io::Result<Self> {
32 let effective_uid = unsafe { libc::geteuid() };
35
36 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 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 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 command.uid(self.uid);
138 command.gid(self.gid);
139 }
140 }
141
142 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 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 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 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 pub fn ensure_directory_beneath_home(&self, path: &Path) -> io::Result<()> {
247 self.open_directory_beneath_home(path, true).map(drop)
248 }
249
250 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 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 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 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 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 if unsafe { libc::fstat(directory.as_raw_fd(), stat.as_mut_ptr()) } != 0 {
363 return Err(io::Error::last_os_error());
364 }
365 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 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 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 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 Ok(unsafe { File::from_raw_fd(fd) })
424 }
425}
426
427fn renameat_same_directory(parent: RawFd, old: &CStr, new: &CStr) -> io::Result<()> {
428 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 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 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 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 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 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}