use anyhow::Result;
use std::path::{Path, PathBuf};
pub(crate) fn read_mountinfo() -> Result<String> {
use anyhow::Context;
std::fs::read_to_string("/proc/self/mountinfo").context("read /proc/self/mountinfo")
}
pub(crate) fn needle_from_path(path: &Path) -> Result<String> {
let mountinfo = read_mountinfo()?;
needle_from_path_with_mountinfo(path, &mountinfo)
}
pub(crate) fn needle_from_path_with_mountinfo(path: &Path, mountinfo: &str) -> Result<String> {
use anyhow::Context;
use std::fs;
use std::os::unix::fs::MetadataExt;
let meta = fs::metadata(path)
.with_context(|| format!("stat lockfile {} for holder lookup", path.display()))?;
let inode = meta.ino();
let (major, minor) =
mount_major_minor_for_path_with_contents(path, mountinfo).with_context(|| {
format!(
"resolve kernel major:minor for {} via /proc/self/mountinfo",
path.display()
)
})?;
Ok(format!("{major:02x}:{minor:02x}:{inode}"))
}
fn mount_major_minor_for_path_with_contents(path: &Path, contents: &str) -> Result<(u32, u32)> {
use std::fs;
let canon: PathBuf = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
mount_major_minor_for_path_from_contents(contents, &canon)
}
pub(crate) fn mount_major_minor_for_path_from_contents(
contents: &str,
path: &Path,
) -> Result<(u32, u32)> {
let mut best: Option<(usize, u32, u32)> = None;
for line in contents.lines() {
let mut fields = line.split_whitespace();
let _mount_id = fields.next();
let _parent_id = fields.next();
let major_minor = match fields.next() {
Some(s) => s,
None => continue,
};
let _root = fields.next();
let mount_point_raw = match fields.next() {
Some(s) => s,
None => continue,
};
let mount_point = unescape_mountinfo_field(mount_point_raw);
if !path_starts_with(path, Path::new(mount_point.as_ref())) {
continue;
}
let (major, minor) = match parse_major_minor(major_minor) {
Some(mm) => mm,
None => continue,
};
let len = mount_point.len();
if best.is_none_or(|(best_len, _, _)| len > best_len) {
best = Some((len, major, minor));
}
}
match best {
Some((_, major, minor)) => Ok((major, minor)),
None => anyhow::bail!(
"no mountinfo entry covers {} — is /proc mounted?",
path.display()
),
}
}
fn unescape_mountinfo_field(raw: &str) -> std::borrow::Cow<'_, str> {
if !raw.contains('\\') {
return std::borrow::Cow::Borrowed(raw);
}
let bytes = raw.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\'
&& i + 3 < bytes.len()
&& is_octal_digit(bytes[i + 1])
&& is_octal_digit(bytes[i + 2])
&& is_octal_digit(bytes[i + 3])
{
let val = ((bytes[i + 1] - b'0') as u16) << 6
| ((bytes[i + 2] - b'0') as u16) << 3
| (bytes[i + 3] - b'0') as u16;
if val <= 0xff {
out.push(val as u8);
i += 4;
} else {
out.push(bytes[i]);
i += 1;
}
} else {
out.push(bytes[i]);
i += 1;
}
}
std::borrow::Cow::Owned(String::from_utf8_lossy(&out).into_owned())
}
#[inline]
fn is_octal_digit(b: u8) -> bool {
(b'0'..=b'7').contains(&b)
}
fn path_starts_with(path: &Path, prefix: &Path) -> bool {
path.starts_with(prefix)
}
fn parse_major_minor(s: &str) -> Option<(u32, u32)> {
let (maj, min) = s.split_once(':')?;
Some((maj.parse().ok()?, min.parse().ok()?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::flock::primitives::materialize;
#[test]
fn mountinfo_single_mount_hits_right_major_minor() {
let mountinfo = "\
22 28 0:21 / /tmp rw,nosuid,nodev shared:5 - tmpfs tmpfs rw,size=8g
";
let (major, minor) =
mount_major_minor_for_path_from_contents(mountinfo, Path::new("/tmp/ktstr-llc-0.lock"))
.expect("tmp mount covers the lockfile path");
assert_eq!((major, minor), (0, 21));
}
#[test]
fn mountinfo_longest_prefix_wins_for_bind_over_tmpfs() {
let mountinfo = "\
22 28 0:21 / /tmp rw,nosuid,nodev shared:5 - tmpfs tmpfs rw,size=8g
35 22 0:99 / /tmp/ktstr-cache rw,nosuid - tmpfs tmpfs rw,size=1g
";
let (major, minor) = mount_major_minor_for_path_from_contents(
mountinfo,
Path::new("/tmp/ktstr-cache/entry.lock"),
)
.expect("bind mount wins longest-prefix match");
assert_eq!((major, minor), (0, 99), "bind's major:minor expected");
}
#[test]
fn mountinfo_uncovered_path_errors() {
let mountinfo = "\
22 28 0:21 / /tmp rw - tmpfs tmpfs rw
";
let err = mount_major_minor_for_path_from_contents(
mountinfo,
Path::new("/var/log/unrelated.lock"),
)
.expect_err("no mountinfo entry covers /var/log/...");
let msg = format!("{err:#}");
assert!(msg.contains("no mountinfo entry covers"), "msg={msg}");
}
#[test]
fn mountinfo_respects_component_boundary() {
let mountinfo = "\
22 28 0:21 / /tmp rw - tmpfs tmpfs rw
35 22 0:99 / /tmp/foobar rw - tmpfs tmpfs rw
";
let (major, minor) =
mount_major_minor_for_path_from_contents(mountinfo, Path::new("/tmp/foo/entry.lock"))
.expect("path under /tmp (not /tmp/foobar) resolves to the tmp mount");
assert_eq!(
(major, minor),
(0, 21),
"/tmp/foo must NOT match the /tmp/foobar mount",
);
let (major, minor) = mount_major_minor_for_path_from_contents(
mountinfo,
Path::new("/tmp/foobar/entry.lock"),
)
.expect("path under /tmp/foobar resolves to the /tmp/foobar mount");
assert_eq!(
(major, minor),
(0, 99),
"/tmp/foobar/ must match the /tmp/foobar mount, not the /tmp one",
);
}
#[test]
fn mountinfo_skips_malformed_major_minor() {
let mountinfo = "\
22 28 BAD:NUMBER / /tmp rw - tmpfs tmpfs rw
35 28 0:42 / /tmp rw - tmpfs tmpfs rw
";
let (major, minor) =
mount_major_minor_for_path_from_contents(mountinfo, Path::new("/tmp/entry.lock"))
.expect("second (valid) line still matches after malformed first");
assert_eq!((major, minor), (0, 42));
}
#[test]
fn mountinfo_skips_truncated_lines() {
let mountinfo = "\
22 28 0:21
35 28 0:42 / /tmp rw - tmpfs tmpfs rw
";
let (major, minor) =
mount_major_minor_for_path_from_contents(mountinfo, Path::new("/tmp/entry.lock"))
.expect("truncated line skipped; second line matches");
assert_eq!((major, minor), (0, 42));
}
#[test]
fn mountinfo_unescapes_space_in_mount_point() {
let mountinfo = "\
22 28 0:77 / /mnt/my\\040dir rw,nosuid - tmpfs tmpfs rw
";
let (major, minor) = mount_major_minor_for_path_from_contents(
mountinfo,
Path::new("/mnt/my dir/cache.lock"),
)
.expect(
"mount point with `\\040`-escaped space must unescape to real \
space and match the query path's literal space",
);
assert_eq!((major, minor), (0, 77));
}
#[test]
fn mountinfo_unescapes_tab_in_mount_point() {
let mountinfo = "\
22 28 0:78 / /mnt/tab\\011dir rw,nosuid - tmpfs tmpfs rw
";
let (major, minor) = mount_major_minor_for_path_from_contents(
mountinfo,
Path::new("/mnt/tab\tdir/cache.lock"),
)
.expect("mount point with `\\011` must unescape to real tab");
assert_eq!((major, minor), (0, 78));
}
#[test]
fn mountinfo_unescapes_backslash_in_mount_point() {
let mountinfo = "\
22 28 0:79 / /mnt/bs\\134dir rw,nosuid - tmpfs tmpfs rw
";
let (major, minor) = mount_major_minor_for_path_from_contents(
mountinfo,
Path::new("/mnt/bs\\dir/cache.lock"),
)
.expect("mount point with `\\134` must unescape to real backslash");
assert_eq!((major, minor), (0, 79));
}
#[test]
fn unescape_mountinfo_field_borrows_when_no_escapes() {
let raw = "/tmp";
let decoded = unescape_mountinfo_field(raw);
match decoded {
std::borrow::Cow::Borrowed(b) => assert_eq!(b, raw),
std::borrow::Cow::Owned(_) => {
panic!("unescape must return Cow::Borrowed when input has no `\\`")
}
}
}
#[test]
fn unescape_mountinfo_field_handles_multiple_escapes() {
let raw = "/a\\040b\\011c";
let decoded = unescape_mountinfo_field(raw);
assert_eq!(decoded.as_ref(), "/a b\tc");
}
#[test]
fn unescape_mountinfo_field_preserves_non_octal_backslash() {
let raw = "/bad\\9suffix";
let decoded = unescape_mountinfo_field(raw);
assert_eq!(decoded.as_ref(), "/bad\\9suffix");
let raw = "/trunc\\04";
let decoded = unescape_mountinfo_field(raw);
assert_eq!(decoded.as_ref(), "/trunc\\04");
}
#[test]
fn unescape_mountinfo_field_preserves_out_of_range_octal() {
let decoded = unescape_mountinfo_field("\\377");
assert_eq!(decoded.as_ref(), "\u{FFFD}");
let decoded = unescape_mountinfo_field("\\400");
assert_eq!(decoded.as_ref(), "\\400");
let decoded = unescape_mountinfo_field("\\777");
assert_eq!(decoded.as_ref(), "\\777");
}
#[test]
fn is_octal_digit_rejects_8_and_9() {
for b in b'0'..=b'7' {
assert!(is_octal_digit(b), "byte 0x{b:02x} must be octal");
}
assert!(!is_octal_digit(b'8'), "byte 0x38 must NOT be octal");
assert!(!is_octal_digit(b'9'), "byte 0x39 must NOT be octal");
assert!(!is_octal_digit(b'a'), "non-digit must NOT be octal");
assert!(!is_octal_digit(b'/'), "byte before '0' must NOT be octal");
}
#[test]
fn path_starts_with_respects_component_boundary() {
assert!(
path_starts_with(Path::new("/tmp/foo"), Path::new("/tmp")),
"/tmp/foo must start with /tmp",
);
assert!(
path_starts_with(Path::new("/tmp/foo/bar"), Path::new("/tmp/foo")),
"/tmp/foo/bar must start with /tmp/foo (deeper component path)",
);
assert!(
!path_starts_with(Path::new("/tmp/foobar"), Path::new("/tmp/foo")),
"/tmp/foobar must NOT start with /tmp/foo (component boundary)",
);
assert!(
path_starts_with(Path::new("/tmp"), Path::new("/tmp")),
"/tmp must start with itself (identity)",
);
assert!(
!path_starts_with(Path::new("/"), Path::new("/tmp")),
"/ is a parent of /tmp, not a child — must NOT match",
);
}
#[test]
fn parse_major_minor_happy_path() {
assert_eq!(parse_major_minor("0:21"), Some((0, 21)));
assert_eq!(parse_major_minor("259:3"), Some((259, 3)));
}
#[test]
fn parse_major_minor_missing_colon() {
assert_eq!(parse_major_minor("notvalid"), None);
assert_eq!(parse_major_minor(""), None);
}
#[test]
fn parse_major_minor_non_numeric() {
assert_eq!(parse_major_minor("abc:21"), None);
assert_eq!(parse_major_minor("0:xyz"), None);
assert_eq!(parse_major_minor(":"), None);
}
#[test]
fn parse_major_minor_negative_numbers() {
assert_eq!(parse_major_minor("-1:0"), None);
assert_eq!(parse_major_minor("0:-1"), None);
}
#[test]
fn needle_cached_mountinfo_equals_uncached() {
use tempfile::TempDir;
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("cache-equivalence.lock");
materialize(&path).expect("materialize lockfile");
let uncached = needle_from_path(&path).expect("uncached needle");
let mountinfo = read_mountinfo().expect("read mountinfo");
let cached = needle_from_path_with_mountinfo(&path, &mountinfo).expect("cached needle");
assert_eq!(
cached, uncached,
"cached and uncached paths must produce byte-identical needles \
for the same lockfile. Divergence means DISCOVER's /proc/locks \
lookup would miss holders the one-shot path would see. \
uncached={uncached} cached={cached}",
);
}
#[test]
fn mount_major_minor_wrapper_matches_parser_seam() {
let mountinfo = "\
22 28 0:21 / /tmp rw,nosuid,nodev shared:5 - tmpfs tmpfs rw,size=8g
";
let path = Path::new("/tmp");
let (wrapper_major, wrapper_minor) =
mount_major_minor_for_path_with_contents(path, mountinfo)
.expect("wrapper must resolve /tmp under synthetic mountinfo");
let (parser_major, parser_minor) =
mount_major_minor_for_path_from_contents(mountinfo, path)
.expect("parser seam must resolve /tmp");
assert_eq!(
(wrapper_major, wrapper_minor),
(parser_major, parser_minor),
"wrapper + parser must produce the same (major, minor) for the \
same (path, mountinfo). Divergence means the cached DISCOVER \
path is reading different mount state than the uncached \
one-shot path would.",
);
assert_eq!((wrapper_major, wrapper_minor), (0, 21));
}
}