Skip to content

Commit f95d43c

Browse files
authored
Merge pull request #983 from allisonkarlitskaya/utf8-lint
lints: add check for non-utf-8 filesystem content
2 parents 9270cdb + 23063f3 commit f95d43c

File tree

2 files changed

+172
-3
lines changed

2 files changed

+172
-3
lines changed

lib/src/lints.rs

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,26 @@
44
55
use std::env::consts::ARCH;
66

7-
use anyhow::Result;
7+
use anyhow::{bail, ensure, Result};
88
use cap_std::fs::Dir;
99
use cap_std_ext::cap_std;
1010
use cap_std_ext::dirext::CapStdExtDirExt as _;
1111
use fn_error_context::context;
1212

13+
use crate::utils::openat2_with_retry;
14+
1315
/// check for the existence of the /var/run directory
1416
/// if it exists we need to check that it links to /run if not error
1517
/// if it does not exist error.
1618
#[context("Linting")]
1719
pub(crate) fn lint(root: &Dir) -> Result<()> {
18-
let lints = [check_var_run, check_kernel, check_parse_kargs, check_usretc];
20+
let lints = [
21+
check_var_run,
22+
check_kernel,
23+
check_parse_kargs,
24+
check_usretc,
25+
check_utf8,
26+
];
1927
for lint in lints {
2028
lint(&root)?;
2129
}
@@ -59,12 +67,72 @@ fn check_kernel(root: &Dir) -> Result<()> {
5967
Ok(())
6068
}
6169

70+
/// Open the target directory, but return Ok(None) if this would cross a mount point.
71+
fn open_dir_noxdev(
72+
parent: &Dir,
73+
path: impl AsRef<std::path::Path>,
74+
) -> std::io::Result<Option<Dir>> {
75+
use rustix::fs::{Mode, OFlags, ResolveFlags};
76+
match openat2_with_retry(
77+
parent,
78+
path,
79+
OFlags::CLOEXEC | OFlags::DIRECTORY | OFlags::NOFOLLOW,
80+
Mode::empty(),
81+
ResolveFlags::NO_XDEV | ResolveFlags::BENEATH,
82+
) {
83+
Ok(r) => Ok(Some(Dir::reopen_dir(&r)?)),
84+
Err(e) if e == rustix::io::Errno::XDEV => Ok(None),
85+
Err(e) => return Err(e.into()),
86+
}
87+
}
88+
89+
fn check_utf8(dir: &Dir) -> Result<()> {
90+
for entry in dir.entries()? {
91+
let entry = entry?;
92+
let name = entry.file_name();
93+
94+
let Some(strname) = &name.to_str() else {
95+
// will escape nicely like "abc\xFFdéf"
96+
bail!("/: Found non-utf8 filename {name:?}");
97+
};
98+
99+
let ifmt = entry.file_type()?;
100+
if ifmt.is_symlink() {
101+
let target = dir.read_link_contents(&name)?;
102+
ensure!(
103+
target.to_str().is_some(),
104+
"/{strname}: Found non-utf8 symlink target"
105+
);
106+
} else if ifmt.is_dir() {
107+
let Some(subdir) = open_dir_noxdev(dir, entry.file_name())? else {
108+
continue;
109+
};
110+
if let Err(err) = check_utf8(&subdir) {
111+
// Try to do the path pasting only in the event of an error
112+
bail!("/{strname}{err:?}");
113+
}
114+
}
115+
}
116+
Ok(())
117+
}
118+
62119
#[cfg(test)]
63120
fn fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
64121
let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
65122
Ok(tempdir)
66123
}
67124

125+
#[test]
126+
fn test_open_noxdev() -> Result<()> {
127+
let root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
128+
// This hard requires the host setup to have /usr/bin on the same filesystem as /
129+
let usr = Dir::open_ambient_dir("/usr", cap_std::ambient_authority())?;
130+
assert!(open_dir_noxdev(&usr, "bin").unwrap().is_some());
131+
// Requires a mounted /proc, but that also seems ane.
132+
assert!(open_dir_noxdev(&root, "proc").unwrap().is_none());
133+
Ok(())
134+
}
135+
68136
#[test]
69137
fn test_var_run() -> Result<()> {
70138
let root = &fixture()?;
@@ -117,3 +185,75 @@ fn test_usr_etc() -> Result<()> {
117185
check_usretc(root).unwrap();
118186
Ok(())
119187
}
188+
189+
#[test]
190+
fn test_non_utf8() {
191+
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
192+
193+
let root = &fixture().unwrap();
194+
195+
// Try to create some adversarial symlink situations to ensure the walk doesn't crash
196+
root.create_dir("subdir").unwrap();
197+
// Self-referential symlinks
198+
root.symlink("self", "self").unwrap();
199+
// Infinitely looping dir symlinks
200+
root.symlink("..", "subdir/parent").unwrap();
201+
// Broken symlinks
202+
root.symlink("does-not-exist", "broken").unwrap();
203+
// Out-of-scope symlinks
204+
root.symlink("../../x", "escape").unwrap();
205+
// Should be fine
206+
check_utf8(root).unwrap();
207+
208+
// But this will cause an issue
209+
let baddir = OsStr::from_bytes(b"subdir/2/bad\xffdir");
210+
root.create_dir("subdir/2").unwrap();
211+
root.create_dir(baddir).unwrap();
212+
let Err(err) = check_utf8(root) else {
213+
unreachable!("Didn't fail");
214+
};
215+
assert_eq!(
216+
err.to_string(),
217+
r#"/subdir/2/: Found non-utf8 filename "bad\xFFdir""#
218+
);
219+
root.remove_dir(baddir).unwrap(); // Get rid of the problem
220+
check_utf8(root).unwrap(); // Check it
221+
222+
// Create a new problem in the form of a regular file
223+
let badfile = OsStr::from_bytes(b"regular\xff");
224+
root.write(badfile, b"Hello, world!\n").unwrap();
225+
let Err(err) = check_utf8(root) else {
226+
unreachable!("Didn't fail");
227+
};
228+
assert_eq!(
229+
err.to_string(),
230+
r#"/: Found non-utf8 filename "regular\xFF""#
231+
);
232+
root.remove_file(badfile).unwrap(); // Get rid of the problem
233+
check_utf8(root).unwrap(); // Check it
234+
235+
// And now test invalid symlink targets
236+
root.symlink(badfile, "subdir/good-name").unwrap();
237+
let Err(err) = check_utf8(root) else {
238+
unreachable!("Didn't fail");
239+
};
240+
assert_eq!(
241+
err.to_string(),
242+
r#"/subdir/good-name: Found non-utf8 symlink target"#
243+
);
244+
root.remove_file("subdir/good-name").unwrap(); // Get rid of the problem
245+
check_utf8(root).unwrap(); // Check it
246+
247+
// Finally, test a self-referential symlink with an invalid name.
248+
// We should spot the invalid name before we check the target.
249+
root.symlink(badfile, badfile).unwrap();
250+
let Err(err) = check_utf8(root) else {
251+
unreachable!("Didn't fail");
252+
};
253+
assert_eq!(
254+
err.to_string(),
255+
r#"/: Found non-utf8 filename "regular\xFF""#
256+
);
257+
root.remove_file(badfile).unwrap(); // Get rid of the problem
258+
check_utf8(root).unwrap(); // Check it
259+
}

lib/src/utils.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::future::Future;
22
use std::io::Write;
3-
use std::os::fd::BorrowedFd;
3+
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
4+
use std::path::Path;
45
use std::process::Command;
56
use std::time::Duration;
67

@@ -20,6 +21,7 @@ use libsystemd::logging::journal_print;
2021
use ostree::glib;
2122
use ostree_ext::container::SignatureSource;
2223
use ostree_ext::ostree;
24+
use rustix::path::Arg;
2325

2426
/// Try to look for keys injected by e.g. rpm-ostree requesting machine-local
2527
/// changes; if any are present, return `true`.
@@ -56,6 +58,33 @@ pub(crate) fn deployment_fd(
5658
sysroot_dir.open_dir(&dirpath).map_err(Into::into)
5759
}
5860

61+
/// A thin wrapper for [`openat2`] but that retries on interruption.
62+
pub fn openat2_with_retry(
63+
dirfd: impl AsFd,
64+
path: impl AsRef<Path>,
65+
oflags: rustix::fs::OFlags,
66+
mode: rustix::fs::Mode,
67+
resolve: rustix::fs::ResolveFlags,
68+
) -> rustix::io::Result<OwnedFd> {
69+
let dirfd = dirfd.as_fd();
70+
let path = path.as_ref();
71+
// We loop forever on EAGAIN right now. The cap-std version loops just 4 times,
72+
// which seems really arbitrary.
73+
path.into_with_c_str(|path_c_str| 'start: loop {
74+
match rustix::fs::openat2(dirfd, path_c_str, oflags, mode, resolve) {
75+
Ok(file) => {
76+
return Ok(file);
77+
}
78+
Err(rustix::io::Errno::AGAIN | rustix::io::Errno::INTR) => {
79+
continue 'start;
80+
}
81+
Err(e) => {
82+
return Err(e);
83+
}
84+
}
85+
})
86+
}
87+
5988
/// Given an mount option string list like foo,bar=baz,something=else,ro parse it and find
6089
/// the first entry like $optname=
6190
/// This will not match a bare `optname` without an equals.

0 commit comments

Comments
 (0)