use std::ffi::OsString;
use std::fmt::Write as _;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use super::error::{Result, SafetyLockError};
use super::roots::RootIdentity;
pub const NATIVE_BYTES_TAG: &str = "os-bytes:";
pub fn encode_native_path(path: &Path) -> String {
match path.to_str() {
Some(text) if !text.starts_with(NATIVE_BYTES_TAG) => text.to_owned(),
_ => {
let bytes = path.as_os_str().as_bytes();
let mut spelling = String::with_capacity(NATIVE_BYTES_TAG.len() + bytes.len() * 2);
spelling.push_str(NATIVE_BYTES_TAG);
for byte in bytes {
let _ = write!(spelling, "{byte:02x}");
}
spelling
}
}
}
pub fn decode_native_path(spelling: &str) -> Result<PathBuf> {
let Some(hex) = spelling.strip_prefix(NATIVE_BYTES_TAG) else {
return Ok(PathBuf::from(spelling));
};
if !hex.len().is_multiple_of(2) {
return Err(SafetyLockError::UnreadableSpelling {
spelling: spelling.to_owned(),
reason: format!(
"the tagged encoding has an odd number of hex digits ({})",
hex.len()
),
});
}
let mut bytes = Vec::with_capacity(hex.len() / 2);
for pair in hex.as_bytes().chunks(2) {
let digits =
std::str::from_utf8(pair).map_err(|_| SafetyLockError::UnreadableSpelling {
spelling: spelling.to_owned(),
reason: "the tagged encoding contains non-ASCII characters".to_owned(),
})?;
let byte =
u8::from_str_radix(digits, 16).map_err(|_| SafetyLockError::UnreadableSpelling {
spelling: spelling.to_owned(),
reason: format!("`{digits}` is not a pair of hex digits"),
})?;
bytes.push(byte);
}
Ok(PathBuf::from(OsString::from_vec(bytes)))
}
pub trait PathProbe: Send + Sync {
fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf>;
fn is_directory(&self, path: &Path) -> bool;
fn is_readable_dir(&self, path: &Path) -> bool;
}
#[derive(Debug)]
pub(super) enum UnusableRoot {
Relative,
Missing,
Unresolvable(std::io::Error),
NotADirectory,
Unreadable,
NotAnIdentity(SafetyLockError),
}
pub(super) fn canonical_root_identity(
candidate: &Path,
probe: &dyn PathProbe,
) -> std::result::Result<RootIdentity, UnusableRoot> {
if !candidate.is_absolute() {
return Err(UnusableRoot::Relative);
}
let canonical = probe.canonicalize(candidate).map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
UnusableRoot::Missing
} else {
UnusableRoot::Unresolvable(err)
}
})?;
if !probe.is_directory(&canonical) {
return Err(UnusableRoot::NotADirectory);
}
if !probe.is_readable_dir(&canonical) {
return Err(UnusableRoot::Unreadable);
}
RootIdentity::new(canonical).map_err(UnusableRoot::NotAnIdentity)
}
#[derive(Debug, Default, Clone, Copy)]
pub struct OsPathProbe;
impl PathProbe for OsPathProbe {
fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
std::fs::canonicalize(path)
}
fn is_directory(&self, path: &Path) -> bool {
path.is_dir()
}
fn is_readable_dir(&self, path: &Path) -> bool {
if !path.is_dir() {
return false;
}
if std::fs::metadata(path.join(".")).is_err() {
return false;
}
match std::fs::read_dir(path) {
Ok(mut entries) => entries.all(|entry| entry.is_ok()),
Err(_) => false,
}
}
}
#[cfg(test)]
mod tests {
use super::super::test_probe::FakeProbe;
use super::*;
#[test]
fn a_relative_candidate_is_refused_without_touching_the_filesystem() {
let probe = FakeProbe::dir("dots");
let failure = canonical_root_identity(Path::new("dots"), &probe).unwrap_err();
assert!(
matches!(failure, UnusableRoot::Relative),
"unexpected failure: {failure:?}"
);
assert!(
probe.canonicalized().is_empty(),
"a relative candidate reached the filesystem probe"
);
}
fn non_unicode_path(suffix: &[u8]) -> PathBuf {
let mut bytes = b"/tmp/".to_vec();
bytes.extend_from_slice(suffix);
PathBuf::from(OsString::from_vec(bytes))
}
#[test]
fn utf8_paths_spell_plainly() {
assert_eq!(
encode_native_path(Path::new("/home/alice/dotfiles")),
"/home/alice/dotfiles"
);
}
#[test]
fn utf8_paths_round_trip() {
let path = Path::new("/home/alice/dot files/über");
let spelling = encode_native_path(path);
assert_eq!(decode_native_path(&spelling).unwrap(), path);
}
#[test]
fn non_unicode_paths_spell_tagged_and_round_trip() {
let path = non_unicode_path(b"\x80dots");
let spelling = encode_native_path(&path);
assert!(
spelling.starts_with(NATIVE_BYTES_TAG),
"non-Unicode path spelled plainly: {spelling}"
);
assert_eq!(spelling, "os-bytes:2f746d702f80646f7473");
assert_eq!(decode_native_path(&spelling).unwrap(), path);
}
#[test]
fn lossy_collisions_keep_distinct_spellings() {
let one = non_unicode_path(b"\x80");
let other = non_unicode_path(b"\x81");
assert_eq!(
one.to_string_lossy(),
other.to_string_lossy(),
"test premise: these two paths render identically when lossy"
);
assert_ne!(encode_native_path(&one), encode_native_path(&other));
assert_eq!(decode_native_path(&encode_native_path(&one)).unwrap(), one);
assert_eq!(
decode_native_path(&encode_native_path(&other)).unwrap(),
other
);
}
#[test]
fn plain_text_starting_with_the_tag_is_encoded_not_confused() {
let path = PathBuf::from(format!("{NATIVE_BYTES_TAG}deadbeef"));
let spelling = encode_native_path(&path);
assert_ne!(spelling, path.to_str().unwrap());
assert_eq!(decode_native_path(&spelling).unwrap(), path);
}
#[test]
fn truncated_tagged_spelling_is_rejected() {
let err = decode_native_path("os-bytes:2f7").unwrap_err();
assert!(
matches!(err, SafetyLockError::UnreadableSpelling { .. }),
"unexpected error: {err}"
);
}
#[test]
fn non_hex_tagged_spelling_is_rejected() {
let err = decode_native_path("os-bytes:zz").unwrap_err();
assert!(
matches!(err, SafetyLockError::UnreadableSpelling { .. }),
"unexpected error: {err}"
);
}
#[test]
fn os_probe_canonicalizes_and_reports_readable_dirs() {
let dir = tempfile::tempdir().unwrap();
let probe = OsPathProbe;
let canonical = probe.canonicalize(dir.path()).unwrap();
assert!(probe.is_directory(&canonical));
assert!(probe.is_readable_dir(&canonical));
let file = canonical.join("not-a-dir");
std::fs::write(&file, b"").unwrap();
assert!(!probe.is_directory(&file));
assert!(!probe.is_readable_dir(&file));
assert!(!probe.is_directory(&canonical.join("missing")));
assert!(!probe.is_readable_dir(&canonical.join("missing")));
}
fn probe_dir_at_mode(parent: &Path, name: &str, mode: u32) -> (bool, bool) {
use std::os::unix::fs::PermissionsExt;
let dir = parent.join(name);
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("pack"), b"").unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(mode)).unwrap();
let probe = OsPathProbe;
let answers = (probe.is_directory(&dir), probe.is_readable_dir(&dir));
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
answers
}
fn chmod_blocks_this_process(parent: &Path) -> bool {
let (_, readable) = probe_dir_at_mode(parent, "dac-probe", 0o000);
!readable
}
#[test]
fn os_probe_separates_being_a_directory_from_being_readable() {
let parent = tempfile::tempdir().unwrap();
if !chmod_blocks_this_process(parent.path()) {
eprintln!(
"skipping os_probe_separates_being_a_directory_from_being_readable: \
process bypasses DAC permissions (running as root?)"
);
return;
}
let (is_dir, readable) = probe_dir_at_mode(parent.path(), "locked", 0o000);
assert!(is_dir);
assert!(!readable);
}
#[test]
fn os_probe_rejects_a_directory_it_can_list_but_not_walk() {
let parent = tempfile::tempdir().unwrap();
if !chmod_blocks_this_process(parent.path()) {
eprintln!(
"skipping os_probe_rejects_a_directory_it_can_list_but_not_walk: \
process bypasses DAC permissions (running as root?)"
);
return;
}
let (is_dir, readable) = probe_dir_at_mode(parent.path(), "no-search", 0o400);
assert!(is_dir, "a mode-0400 directory is still a directory");
assert!(
!readable,
"a directory whose children cannot be reached is not a usable root"
);
let (is_dir, readable) = probe_dir_at_mode(parent.path(), "ordinary", 0o700);
assert!(is_dir);
assert!(readable);
}
}