#![doc = include_str!("../README.md")]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![cfg_attr(docsrs, allow(unused_attributes))]
#![deny(missing_docs)]
use std::{ffi::OsStr, io, path::Path};
#[cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "watchos",
target_os = "tvos",
target_os = "visionos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "dragonfly",
))]
#[path = "bsd.rs"]
mod os;
#[cfg(target_os = "netbsd")]
#[path = "netbsd.rs"]
mod os;
#[cfg(target_os = "linux")]
#[path = "linux.rs"]
mod os;
#[cfg(windows)]
#[path = "windows.rs"]
mod os;
const INLINE_CAPACITY: usize = 56;
#[cfg(unix)]
#[cfg_attr(not(tarpaulin), inline(always))]
fn find_byte(needle: u8, haystack: &[u8]) -> Option<usize> {
#[cfg(miri)]
{
haystack.iter().position(|&b| b == needle)
}
#[cfg(not(miri))]
{
memchr::memchr(needle, haystack)
}
}
#[derive(Clone, Debug)]
enum SmallBytes {
Inline {
data: [u8; INLINE_CAPACITY],
len: u8,
},
Heap(bytes::Bytes),
}
impl SmallBytes {
#[cfg_attr(not(tarpaulin), inline(always))]
fn from_bytes(bytes: &[u8]) -> Self {
if bytes.len() <= INLINE_CAPACITY {
let mut data = [0u8; INLINE_CAPACITY];
data[..bytes.len()].copy_from_slice(bytes);
Self::Inline {
data,
len: bytes.len() as u8,
}
} else {
Self::Heap(bytes::Bytes::copy_from_slice(bytes))
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn as_bytes(&self) -> &[u8] {
match self {
Self::Inline { data, len } => &data[..*len as usize],
Self::Heap(b) => b,
}
}
#[cfg(unix)]
#[cfg_attr(not(tarpaulin), inline(always))]
fn as_path(&self) -> &Path {
use std::os::unix::ffi::OsStrExt;
Path::new(OsStr::from_bytes(self.as_bytes()))
}
#[cfg(unix)]
#[cfg_attr(not(tarpaulin), inline(always))]
fn as_os_str(&self) -> &OsStr {
use std::os::unix::ffi::OsStrExt;
OsStr::from_bytes(self.as_bytes())
}
#[cfg(windows)]
#[cfg_attr(not(tarpaulin), inline(always))]
fn as_path(&self) -> &Path {
Path::new(self.as_str())
}
#[cfg(windows)]
#[cfg_attr(not(tarpaulin), inline(always))]
fn as_os_str(&self) -> &OsStr {
OsStr::new(self.as_str())
}
#[cfg(windows)]
#[cfg_attr(not(tarpaulin), inline(always))]
fn as_str(&self) -> &str {
core::str::from_utf8(self.as_bytes())
.expect("Windows volume/mount names are always valid ASCII/UTF-8")
}
}
impl PartialEq for SmallBytes {
#[inline]
fn eq(&self, other: &Self) -> bool {
self.as_bytes() == other.as_bytes()
}
}
impl Eq for SmallBytes {}
#[cfg(windows)]
impl core::hash::Hash for SmallBytes {
#[inline]
fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.as_bytes().hash(state);
}
}
#[derive(Clone)]
pub struct MountPoint {
pub(crate) mount_point: SmallBytes,
pub(crate) device: SmallBytes,
pub(crate) is_ejectable: bool,
#[cfg(feature = "disk-usage")]
pub(crate) total_bytes: u64,
#[cfg(feature = "disk-usage")]
pub(crate) available_bytes: u64,
}
impl PartialEq for MountPoint {
#[inline]
fn eq(&self, other: &Self) -> bool {
self.mount_point == other.mount_point
&& self.device == other.device
&& self.is_ejectable == other.is_ejectable
}
}
impl Eq for MountPoint {}
impl MountPoint {
#[inline]
pub fn mount_point(&self) -> &Path {
self.mount_point.as_path()
}
#[inline]
pub fn device(&self) -> &OsStr {
self.device.as_os_str()
}
#[inline]
pub fn is_ejectable(&self) -> bool {
self.is_ejectable
}
#[cfg(feature = "disk-usage")]
#[cfg_attr(docsrs, doc(cfg(feature = "disk-usage")))]
#[inline]
pub fn total_bytes(&self) -> u64 {
self.total_bytes
}
#[cfg(feature = "disk-usage")]
#[cfg_attr(docsrs, doc(cfg(feature = "disk-usage")))]
#[inline]
pub fn available_bytes(&self) -> u64 {
self.available_bytes
}
#[cfg(feature = "disk-usage")]
#[cfg_attr(docsrs, doc(cfg(feature = "disk-usage")))]
#[inline]
pub fn used_bytes(&self) -> u64 {
self.total_bytes.saturating_sub(self.available_bytes)
}
}
impl core::fmt::Debug for MountPoint {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let mut s = f.debug_struct("MountPoint");
s.field("mount_point", &self.mount_point())
.field("device", &self.device())
.field("is_ejectable", &self.is_ejectable);
#[cfg(feature = "disk-usage")]
s.field("total_bytes", &self.total_bytes)
.field("available_bytes", &self.available_bytes);
s.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct PathLocation {
inner: os::Inner,
}
impl PathLocation {
#[inline]
pub fn mount_info(&self) -> &MountPoint {
self.inner.mount_info()
}
#[inline]
pub fn mount_point(&self) -> &Path {
self.inner.mount_info().mount_point()
}
#[inline]
pub fn device(&self) -> &OsStr {
self.inner.mount_info().device()
}
#[inline]
pub fn canonical_path(&self) -> &Path {
self.inner.canonical_path()
}
#[inline]
pub fn relative_path(&self) -> &Path {
self.inner.relative_path()
}
#[inline]
pub fn is_ejectable(&self) -> bool {
self.inner.mount_info().is_ejectable()
}
#[cfg(feature = "disk-usage")]
#[cfg_attr(docsrs, doc(cfg(feature = "disk-usage")))]
#[inline]
pub fn total_bytes(&self) -> u64 {
self.inner.mount_info().total_bytes()
}
#[cfg(feature = "disk-usage")]
#[cfg_attr(docsrs, doc(cfg(feature = "disk-usage")))]
#[inline]
pub fn available_bytes(&self) -> u64 {
self.inner.mount_info().available_bytes()
}
#[cfg(feature = "disk-usage")]
#[cfg_attr(docsrs, doc(cfg(feature = "disk-usage")))]
#[inline]
pub fn used_bytes(&self) -> u64 {
self.inner.mount_info().used_bytes()
}
}
impl core::fmt::Debug for PathLocation {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let mut s = f.debug_struct("PathLocation");
s.field("canonical_path", &self.canonical_path())
.field("mount_point", &self.mount_point())
.field("device", &self.device())
.field("is_ejectable", &self.is_ejectable());
#[cfg(feature = "disk-usage")]
s.field("total_bytes", &self.total_bytes())
.field("available_bytes", &self.available_bytes());
s.field("relative_path", &self.relative_path()).finish()
}
}
#[cfg(feature = "list")]
#[cfg_attr(docsrs, doc(cfg(feature = "list")))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ListOptions {
ejectable_only: bool,
non_ejectable_only: bool,
}
#[cfg(feature = "list")]
impl ListOptions {
#[inline]
pub const fn all() -> Self {
Self {
ejectable_only: false,
non_ejectable_only: false,
}
}
#[inline]
pub const fn ejectable_only() -> Self {
Self {
ejectable_only: true,
non_ejectable_only: false,
}
}
#[inline]
pub const fn non_ejectable_only() -> Self {
Self {
ejectable_only: false,
non_ejectable_only: true,
}
}
#[inline]
pub const fn set_ejectable_only(mut self, ejectable_only: bool) -> Self {
self.ejectable_only = ejectable_only;
if ejectable_only {
self.non_ejectable_only = false;
}
self
}
#[inline]
pub const fn set_non_ejectable_only(mut self, non_ejectable_only: bool) -> Self {
self.non_ejectable_only = non_ejectable_only;
if non_ejectable_only {
self.ejectable_only = false;
}
self
}
#[inline]
pub const fn is_ejectable_only(&self) -> bool {
self.ejectable_only
}
#[inline]
pub const fn is_non_ejectable_only(&self) -> bool {
self.non_ejectable_only
}
}
#[cfg(feature = "list")]
impl Default for ListOptions {
#[inline]
fn default() -> Self {
Self::all()
}
}
pub fn resolve(path: impl AsRef<Path>) -> io::Result<PathLocation> {
os::resolve(path.as_ref()).map(|inner| PathLocation { inner })
}
#[cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "watchos",
target_os = "tvos",
target_os = "visionos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "dragonfly",
target_os = "netbsd",
target_os = "linux",
windows,
))]
#[cfg_attr(
docsrs,
doc(cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "watchos",
target_os = "tvos",
target_os = "visionos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "dragonfly",
target_os = "netbsd",
target_os = "linux",
windows,
)))
)]
pub fn root() -> io::Result<PathLocation> {
#[cfg(not(windows))]
let path = std::path::PathBuf::from("/");
#[cfg(windows)]
let path = {
let drive = std::env::var_os("SystemDrive").unwrap_or_else(|| "C:".into());
let mut p = std::path::PathBuf::from(drive);
p.push("\\");
p
};
resolve(&path)
}
#[cfg(feature = "list")]
#[cfg_attr(docsrs, doc(cfg(feature = "list")))]
pub fn list_with(opts: ListOptions) -> io::Result<Vec<MountPoint>> {
os::list(opts)
}
#[cfg(feature = "list")]
#[cfg_attr(docsrs, doc(cfg(feature = "list")))]
pub fn list() -> io::Result<Vec<MountPoint>> {
os::list(ListOptions::all())
}
#[cfg(feature = "list")]
#[cfg_attr(docsrs, doc(cfg(feature = "list")))]
pub fn list_ejectable() -> io::Result<Vec<MountPoint>> {
os::list(ListOptions::ejectable_only())
}
#[cfg(feature = "list")]
#[cfg_attr(docsrs, doc(cfg(feature = "list")))]
pub fn list_non_ejectable() -> io::Result<Vec<MountPoint>> {
os::list(ListOptions::non_ejectable_only())
}
#[cfg(test)]
mod tests {
use super::*;
fn root_path() -> &'static str {
if cfg!(windows) { "C:\\" } else { "/" }
}
fn nonexistent_path() -> &'static str {
if cfg!(windows) {
"Z:\\nonexistent\\path\\xyz"
} else {
"/nonexistent/path/that/does/not/exist"
}
}
#[test]
fn test_root() {
let info = resolve(root_path()).unwrap();
assert!(info.mount_point().is_absolute());
assert!(!info.device().is_empty());
assert_eq!(info.relative_path(), Path::new(""));
println!("Root disk info: {:?}", info);
}
#[test]
fn test_root_fn() {
let info = root().unwrap();
assert!(info.mount_point().is_absolute());
assert!(!info.device().is_empty());
assert_eq!(info.relative_path(), Path::new(""));
if cfg!(windows) {
assert!(info.canonical_path().is_absolute());
} else {
assert_eq!(info.canonical_path(), info.mount_point());
}
}
#[test]
fn test_root_fn_matches_resolve() {
let from_root = root().unwrap();
let from_resolve = resolve(root_path()).unwrap();
assert_eq!(from_root.mount_point(), from_resolve.mount_point());
assert_eq!(from_root.device(), from_resolve.device());
assert_eq!(from_root.is_ejectable(), from_resolve.is_ejectable());
assert_eq!(from_root.canonical_path(), from_resolve.canonical_path());
}
#[test]
fn test_existing_path() {
let info = resolve(env!("CARGO_MANIFEST_DIR")).unwrap();
assert!(info.mount_point().is_absolute());
assert!(!info.device().is_empty());
assert!(!info.relative_path().as_os_str().is_empty());
assert!(info.canonical_path().is_absolute());
println!("Current directory disk info: {:?}", info);
}
#[test]
fn test_is_ejectable() {
let info = resolve(root_path()).unwrap();
assert!(!info.is_ejectable(), "root disk should not be ejectable");
}
#[test]
fn test_nonexistent_path() {
let result = resolve(nonexistent_path());
assert!(result.is_err());
}
#[test]
fn test_file_path() {
let info = resolve(file!()).unwrap();
assert!(info.mount_point().is_absolute());
assert!(!info.device().is_empty());
}
#[test]
#[cfg(unix)]
fn test_symlink_path() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target_file");
std::fs::write(&target, b"hello").unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
let info_target = resolve(&target).unwrap();
let info_link = resolve(&link).unwrap();
assert_eq!(info_target.mount_point(), info_link.mount_point());
assert_eq!(info_target.device(), info_link.device());
assert_eq!(info_target.canonical_path(), info_link.canonical_path());
}
#[test]
fn test_repeated_lookups_hit_cache() {
let info1 = resolve(root_path()).unwrap();
let info2 = resolve(root_path()).unwrap();
assert_eq!(info1.mount_point(), info2.mount_point());
assert_eq!(info1.device(), info2.device());
}
#[cfg(feature = "list")]
#[test]
fn test_list() {
let mounts = list().unwrap();
assert!(!mounts.is_empty(), "should have at least one mount");
for m in &mounts {
assert!(
m.mount_point().is_absolute(),
"mount point should be absolute: {:?}",
m
);
assert!(
!m.device().is_empty(),
"device should not be empty: {:?}",
m
);
}
println!("Found {} mounts", mounts.len());
for m in &mounts {
println!(" {:?}", m);
}
}
#[cfg(feature = "list")]
#[test]
fn test_list_ejectable() {
let mounts = list_ejectable().unwrap();
for m in &mounts {
assert!(
m.is_ejectable(),
"should only contain ejectable mounts: {:?}",
m
);
}
println!("Found {} ejectable mounts", mounts.len());
}
#[cfg(feature = "list")]
#[test]
fn test_list_non_ejectable() {
let mounts = list_non_ejectable().unwrap();
for m in &mounts {
assert!(
!m.is_ejectable(),
"should only contain non-ejectable mounts: {:?}",
m
);
}
println!("Found {} non-ejectable mounts", mounts.len());
}
#[cfg(feature = "list")]
#[test]
fn test_list_with() {
let all = list_with(ListOptions::all()).unwrap();
let ejectable = list_with(ListOptions::ejectable_only()).unwrap();
let non_ejectable = list_with(ListOptions::non_ejectable_only()).unwrap();
assert!(ejectable.len() <= all.len());
assert!(non_ejectable.len() <= all.len());
assert_eq!(ejectable.len() + non_ejectable.len(), all.len());
for m in &ejectable {
assert!(m.is_ejectable());
}
for m in &non_ejectable {
assert!(!m.is_ejectable());
}
}
#[cfg(feature = "list")]
#[test]
fn test_list_options_default() {
let opts = ListOptions::default();
assert!(!opts.is_ejectable_only());
assert!(!opts.is_non_ejectable_only());
}
#[cfg(feature = "list")]
#[test]
fn test_list_options_builder() {
let opts = ListOptions::all().set_ejectable_only(true);
assert!(opts.is_ejectable_only());
let opts2 = opts.set_ejectable_only(false);
assert!(!opts2.is_ejectable_only());
let opts3 = ListOptions::all().set_non_ejectable_only(true);
assert!(opts3.is_non_ejectable_only());
let opts4 = opts3.set_non_ejectable_only(false);
assert!(!opts4.is_non_ejectable_only());
let opts5 = ListOptions::non_ejectable_only().set_ejectable_only(true);
assert!(opts5.is_ejectable_only());
assert!(!opts5.is_non_ejectable_only());
let opts6 = ListOptions::ejectable_only().set_non_ejectable_only(true);
assert!(opts6.is_non_ejectable_only());
assert!(!opts6.is_ejectable_only());
}
#[test]
fn test_canonical_path() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.txt");
std::fs::write(&file, b"test").unwrap();
let info = resolve(&file).unwrap();
let canonical = info.canonical_path();
assert!(canonical.is_absolute());
assert!(canonical.exists());
assert!(canonical.ends_with("test.txt"));
}
#[test]
fn test_canonical_path_resolves_dot_dot() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("a/b");
std::fs::create_dir_all(&sub).unwrap();
let dotdot = sub.join("../b");
let info = resolve(&dotdot).unwrap();
let canonical = info.canonical_path();
assert!(!canonical.to_string_lossy().contains(".."));
assert!(canonical.ends_with("a/b"));
}
#[test]
fn test_mount_info() {
let info = resolve(root_path()).unwrap();
let mi = info.mount_info();
assert_eq!(mi.mount_point(), info.mount_point());
assert_eq!(mi.device(), info.device());
assert_eq!(mi.is_ejectable(), info.is_ejectable());
#[cfg(feature = "disk-usage")]
{
assert_eq!(mi.total_bytes(), info.total_bytes());
assert_eq!(mi.available_bytes(), info.available_bytes());
assert_eq!(mi.used_bytes(), info.used_bytes());
}
}
#[cfg(feature = "disk-usage")]
#[test]
fn test_disk_usage() {
let info = resolve(root_path()).unwrap();
assert!(info.total_bytes() > 0, "total_bytes should be > 0");
assert!(
info.available_bytes() <= info.total_bytes(),
"available should not exceed total"
);
assert_eq!(
info.used_bytes(),
info.total_bytes() - info.available_bytes(),
"used = total - available"
);
println!(
"Root disk: total={}, available={}, used={}",
info.total_bytes(),
info.available_bytes(),
info.used_bytes()
);
}
#[cfg(all(feature = "list", feature = "disk-usage"))]
#[test]
fn test_list_disk_usage() {
let mounts = list().unwrap();
for m in &mounts {
if m.total_bytes() > 0 {
assert!(
m.available_bytes() <= m.total_bytes(),
"available should not exceed total for {:?}",
m.mount_point()
);
}
}
}
#[test]
fn test_deep_nested_path() {
let dir = tempfile::tempdir().unwrap();
let deep = dir.path().join("a/b/c/d/e");
std::fs::create_dir_all(&deep).unwrap();
let info = resolve(&deep).unwrap();
assert!(info.mount_point().is_absolute());
assert!(!info.relative_path().as_os_str().is_empty());
let canonical = info.canonical_path();
assert!(canonical.is_absolute());
assert!(canonical.ends_with("a/b/c/d/e"));
}
#[test]
fn test_relative_path_is_relative() {
let info = resolve(env!("CARGO_MANIFEST_DIR")).unwrap();
assert!(info.relative_path().is_relative());
}
#[test]
fn test_temp_dir() {
let dir = tempfile::tempdir().unwrap();
let info = resolve(dir.path()).unwrap();
assert!(info.mount_point().is_absolute());
assert!(!info.device().is_empty());
}
#[test]
fn test_struct_size() {
let size = core::mem::size_of::<PathLocation>();
println!("PathLocation size: {size} bytes");
assert!(
size < 256,
"PathLocation should be compact, got {size} bytes"
);
}
#[test]
fn test_smallbytes_inline() {
let data = b"hello";
let sb = SmallBytes::from_bytes(data);
assert_eq!(sb.as_bytes(), data);
assert!(matches!(sb, SmallBytes::Inline { .. }));
}
#[test]
fn test_smallbytes_heap() {
let data = vec![b'x'; INLINE_CAPACITY + 1];
let sb = SmallBytes::from_bytes(&data);
assert_eq!(sb.as_bytes(), &data[..]);
assert!(matches!(sb, SmallBytes::Heap(_)));
}
#[test]
fn test_smallbytes_exact_capacity() {
let data = vec![b'a'; INLINE_CAPACITY];
let sb = SmallBytes::from_bytes(&data);
assert_eq!(sb.as_bytes(), &data[..]);
assert!(matches!(sb, SmallBytes::Inline { .. }));
}
#[test]
fn test_smallbytes_empty() {
let sb = SmallBytes::from_bytes(b"");
assert_eq!(sb.as_bytes(), b"");
assert!(matches!(sb, SmallBytes::Inline { len: 0, .. }));
}
#[test]
fn test_smallbytes_clone_inline() {
let sb = SmallBytes::from_bytes(b"/dev/sda1");
let cloned = sb.clone();
assert_eq!(sb.as_bytes(), cloned.as_bytes());
}
#[test]
fn test_smallbytes_clone_heap() {
let data = vec![b'z'; INLINE_CAPACITY + 10];
let sb = SmallBytes::from_bytes(&data);
let cloned = sb.clone();
assert_eq!(sb.as_bytes(), cloned.as_bytes());
assert!(matches!(cloned, SmallBytes::Heap(_)));
}
#[test]
fn test_smallbytes_eq() {
let a = SmallBytes::from_bytes(b"test");
let b = SmallBytes::from_bytes(b"test");
let c = SmallBytes::from_bytes(b"other");
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn test_smallbytes_eq_across_variants() {
let data = vec![b'y'; INLINE_CAPACITY];
let inline = SmallBytes::from_bytes(&data);
let heap = SmallBytes::Heap(bytes::Bytes::from(data.clone()));
assert_eq!(inline, heap);
}
#[cfg(windows)]
#[test]
fn test_smallbytes_hash_consistency() {
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
};
let a = SmallBytes::from_bytes(b"mount");
let b = SmallBytes::from_bytes(b"mount");
let mut ha = DefaultHasher::new();
let mut hb = DefaultHasher::new();
a.hash(&mut ha);
b.hash(&mut hb);
assert_eq!(ha.finish(), hb.finish());
}
#[cfg(unix)]
#[test]
fn test_smallbytes_as_path() {
let sb = SmallBytes::from_bytes(b"/tmp");
assert_eq!(sb.as_path(), Path::new("/tmp"));
}
#[cfg(unix)]
#[test]
fn test_smallbytes_as_os_str() {
let sb = SmallBytes::from_bytes(b"device");
assert_eq!(sb.as_os_str(), OsStr::new("device"));
}
#[cfg(unix)]
#[test]
fn test_smallbytes_as_path_heap() {
let data = vec![b'/'; INLINE_CAPACITY + 1];
let sb = SmallBytes::from_bytes(&data);
let path = sb.as_path();
assert_eq!(path.as_os_str().len(), INLINE_CAPACITY + 1);
}
#[cfg(target_os = "macos")]
#[test]
fn test_non_firmlinked_data_volume_path() {
let path = std::path::Path::new("/System/Volumes/Data/.fseventsd");
if !path.exists() {
return;
}
let info = resolve(path).unwrap();
assert_eq!(
info.mount_point(),
Path::new("/System/Volumes/Data"),
"expected data volume mount point"
);
assert_eq!(
info.relative_path(),
Path::new(".fseventsd"),
"relative path should be the directory name"
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_data_volume_mount_point_itself() {
let path = std::path::Path::new("/System/Volumes/Data");
if !path.exists() {
return;
}
let info = resolve(path).unwrap();
assert_eq!(info.mount_point(), Path::new("/System/Volumes/Data"));
assert_eq!(info.relative_path(), Path::new(""));
}
}