use std::fs::{File, Metadata, OpenOptions};
use std::io;
use std::mem::{MaybeUninit, size_of};
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
use std::os::windows::io::AsRawHandle;
use std::path::Path;
use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, HANDLE};
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT,
FILE_ATTRIBUTE_TAG_INFO, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS,
FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_SHARE_DELETE, FILE_SHARE_READ,
FILE_SHARE_WRITE, FileAttributeTagInfo, FileBasicInfo, FileIdInfo, GetFileInformationByHandle,
GetFileInformationByHandleEx,
};
use crate::{Attrs, EntryKind};
const WINDOWS_TO_UNIX_EPOCH_100NS: i128 = 116_444_736_000_000_000;
const REPARSE_TAG_NAME_SURROGATE: u32 = 0x2000_0000;
pub(super) fn observe(
path: &Path,
listed: impl FnOnce() -> io::Result<Metadata>,
) -> io::Result<(EntryKind, Attrs)> {
let file = match open_for_attributes(path) {
Ok(file) => file,
Err(error) if is_locked_or_denied(&error) => {
return match listed() {
Ok(meta) => Ok(observe_listed(&meta)),
Err(_) => Err(error),
};
}
Err(error) => return Err(error),
};
let observed = query(&file)?;
Ok((observed.kind(), observed.attrs()))
}
pub(super) fn attrs_from_file(file: &File) -> io::Result<Attrs> {
Ok(query(file)?.attrs())
}
fn open_for_attributes(path: &Path) -> io::Result<File> {
OpenOptions::new()
.access_mode(0)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
.open(path)
}
fn is_locked_or_denied(error: &io::Error) -> bool {
error
.raw_os_error()
.and_then(|code| u32::try_from(code).ok())
.is_some_and(|code| code == ERROR_SHARING_VIOLATION || code == ERROR_ACCESS_DENIED)
}
fn observe_listed(meta: &Metadata) -> (EntryKind, Attrs) {
let file_type = meta.file_type();
let kind = if file_type.is_symlink() {
EntryKind::Symlink
} else if file_type.is_dir() {
EntryKind::Dir
} else {
EntryKind::File
};
let size = meta.file_size();
let write_time = i64::try_from(meta.last_write_time()).unwrap_or(i64::MAX);
let attrs = Attrs {
size,
allocated: size,
mtime_ns: windows_time_to_unix_ns(write_time),
ctime_ns: 0,
inode: 0,
dev: 0,
};
(kind, attrs)
}
struct Observed {
basic: FILE_BASIC_INFO,
tag: FILE_ATTRIBUTE_TAG_INFO,
identity: BY_HANDLE_FILE_INFORMATION,
file_id: u64,
}
impl Observed {
fn kind(&self) -> EntryKind {
kind_from_attributes(self.tag.FileAttributes, self.tag.ReparseTag)
}
fn attrs(&self) -> Attrs {
let size =
(u64::from(self.identity.nFileSizeHigh) << 32) | u64::from(self.identity.nFileSizeLow);
Attrs {
size,
allocated: size,
mtime_ns: windows_time_to_unix_ns(self.basic.LastWriteTime),
ctime_ns: windows_time_to_unix_ns(self.basic.ChangeTime),
inode: self.file_id,
dev: u64::from(self.identity.dwVolumeSerialNumber),
}
}
}
fn kind_from_attributes(attributes: u32, reparse_tag: u32) -> EntryKind {
if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
&& reparse_tag & REPARSE_TAG_NAME_SURROGATE != 0
{
EntryKind::Symlink
} else if attributes & FILE_ATTRIBUTE_DIRECTORY != 0 {
EntryKind::Dir
} else {
EntryKind::File
}
}
const MAX_OBSERVATION_READS: usize = 4;
fn query(file: &File) -> io::Result<Observed> {
let handle = file.as_raw_handle() as HANDLE;
let mut previous = query_once(handle)?;
for _ in 1..MAX_OBSERVATION_READS {
let next = query_once(handle)?;
if same_observation(&previous, &next) {
return Ok(next);
}
previous = next;
}
Err(io::Error::other("file changed while Windows metadata was observed"))
}
pub(super) fn volume_serial(path: &Path) -> io::Result<u64> {
let file = match open_for_attributes(path) {
Ok(file) => file,
Err(error) if is_locked_or_denied(&error) => return Ok(0),
Err(error) => return Err(error),
};
let handle = file.as_raw_handle() as HANDLE;
let mut identity = MaybeUninit::<BY_HANDLE_FILE_INFORMATION>::uninit();
let identity = unsafe {
if GetFileInformationByHandle(handle, identity.as_mut_ptr()) == 0 {
return Err(io::Error::last_os_error());
}
identity.assume_init()
};
Ok(u64::from(identity.dwVolumeSerialNumber))
}
fn query_once(handle: HANDLE) -> io::Result<Observed> {
let mut basic = MaybeUninit::<FILE_BASIC_INFO>::uninit();
let mut tag = MaybeUninit::<FILE_ATTRIBUTE_TAG_INFO>::uninit();
let mut identity = MaybeUninit::<BY_HANDLE_FILE_INFORMATION>::uninit();
unsafe {
if GetFileInformationByHandleEx(
handle,
FileBasicInfo,
basic.as_mut_ptr().cast(),
u32::try_from(size_of::<FILE_BASIC_INFO>()).expect("FILE_BASIC_INFO fits u32"),
) == 0
{
return Err(io::Error::last_os_error());
}
if GetFileInformationByHandleEx(
handle,
FileAttributeTagInfo,
tag.as_mut_ptr().cast(),
u32::try_from(size_of::<FILE_ATTRIBUTE_TAG_INFO>())
.expect("FILE_ATTRIBUTE_TAG_INFO fits u32"),
) == 0
{
return Err(io::Error::last_os_error());
}
if GetFileInformationByHandle(handle, identity.as_mut_ptr()) == 0 {
return Err(io::Error::last_os_error());
}
let identity = identity.assume_init();
Ok(Observed {
basic: basic.assume_init(),
tag: tag.assume_init(),
file_id: file_id(handle, &identity),
identity,
})
}
}
fn file_id(handle: HANDLE, identity: &BY_HANDLE_FILE_INFORMATION) -> u64 {
let index = (u64::from(identity.nFileIndexHigh) << 32) | u64::from(identity.nFileIndexLow);
let mut info = MaybeUninit::<FILE_ID_INFO>::uninit();
unsafe {
if GetFileInformationByHandleEx(
handle,
FileIdInfo,
info.as_mut_ptr().cast(),
u32::try_from(size_of::<FILE_ID_INFO>()).expect("FILE_ID_INFO fits u32"),
) == 0
{
return index;
}
fold_file_id(info.assume_init().FileId.Identifier)
}
}
fn fold_file_id(identifier: [u8; 16]) -> u64 {
let (low, high) = identifier.split_at(8);
let low = u64::from_le_bytes(low.try_into().expect("eight bytes"));
let high = u64::from_le_bytes(high.try_into().expect("eight bytes"));
if high == 0 {
return low;
}
low ^ high.rotate_left(32).wrapping_mul(0x9E37_79B9_7F4A_7C15)
}
fn same_observation(left: &Observed, right: &Observed) -> bool {
left.basic.LastWriteTime == right.basic.LastWriteTime
&& left.basic.ChangeTime == right.basic.ChangeTime
&& left.basic.FileAttributes == right.basic.FileAttributes
&& left.tag.FileAttributes == right.tag.FileAttributes
&& left.tag.ReparseTag == right.tag.ReparseTag
&& left.identity.dwVolumeSerialNumber == right.identity.dwVolumeSerialNumber
&& left.identity.nFileSizeHigh == right.identity.nFileSizeHigh
&& left.identity.nFileSizeLow == right.identity.nFileSizeLow
&& left.identity.nFileIndexHigh == right.identity.nFileIndexHigh
&& left.identity.nFileIndexLow == right.identity.nFileIndexLow
&& left.file_id == right.file_id
}
fn windows_time_to_unix_ns(ticks: i64) -> i64 {
if ticks == 0 {
return 0;
}
let nanos = (i128::from(ticks) - WINDOWS_TO_UNIX_EPOCH_100NS) * 100;
match i64::try_from(nanos) {
Ok(nanos) => nanos,
Err(_) if nanos < 0 => i64::MIN,
Err(_) => i64::MAX,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_epoch_conversion_is_signed_and_saturating() {
assert_eq!(windows_time_to_unix_ns(116_444_736_000_000_000), 0);
assert_eq!(windows_time_to_unix_ns(116_444_735_999_999_999), -100);
assert_eq!(windows_time_to_unix_ns(0), 0);
assert_eq!(windows_time_to_unix_ns(1), i64::MIN);
assert_eq!(windows_time_to_unix_ns(i64::MIN), i64::MIN);
assert_eq!(windows_time_to_unix_ns(i64::MAX), i64::MAX);
}
#[test]
fn only_locked_and_denied_opens_fall_back_to_listing_data() {
let os_error = |code: u32| io::Error::from_raw_os_error(i32::try_from(code).expect("code"));
assert!(is_locked_or_denied(&os_error(ERROR_ACCESS_DENIED)));
assert!(is_locked_or_denied(&os_error(ERROR_SHARING_VIOLATION)));
assert!(!is_locked_or_denied(&os_error(2)));
assert!(!is_locked_or_denied(&os_error(3)));
assert!(!is_locked_or_denied(&io::Error::other("not an OS error")));
}
#[test]
fn a_root_changing_underneath_still_reports_its_volume() {
use std::sync::atomic::{AtomicBool, Ordering};
struct StopOnDrop<'a>(&'a AtomicBool);
impl Drop for StopOnDrop<'_> {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
let root = tempfile::tempdir().expect("tempdir");
let (_, observed) = observe(root.path(), || panic!("a directory opens")).expect("observe");
let stop = AtomicBool::new(false);
std::thread::scope(|scope| {
let _stop_on_unwind = StopOnDrop(&stop);
scope.spawn(|| {
let mut round = 0u32;
while !stop.load(Ordering::Relaxed) {
let child = root.path().join(format!("c{}", round % 8));
let _ = std::fs::create_dir(&child);
let _ = std::fs::remove_dir(&child);
round = round.wrapping_add(1);
}
});
for _ in 0..2_000 {
let serial = volume_serial(root.path());
assert_eq!(serial.ok(), Some(observed.dev), "the volume is read, not observed");
}
});
}
#[test]
fn listing_data_observes_kind_size_and_write_time_with_identity_unavailable() {
let root = tempfile::tempdir().expect("tempdir");
let file = root.path().join("listed.txt");
std::fs::write(&file, b"12345").expect("write");
let dir = root.path().join("sub");
std::fs::create_dir(&dir).expect("mkdir");
let (kind, listed) = observe_listed(&std::fs::symlink_metadata(&file).expect("metadata"));
let (_, opened) = observe(&file, || panic!("an ordinary file opens")).expect("observe");
assert_eq!(kind, EntryKind::File);
assert_eq!(listed.size, 5);
assert_eq!(listed.mtime_ns, opened.mtime_ns, "one write time from either source");
assert_eq!((listed.ctime_ns, listed.inode, listed.dev), (0, 0, 0));
assert_ne!((opened.inode, opened.dev), (0, 0), "the handle identifies the entry");
let (kind, _) = observe_listed(&std::fs::symlink_metadata(&dir).expect("metadata"));
assert_eq!(kind, EntryKind::Dir);
}
#[test]
fn file_identity_keeps_a_zero_extended_index_and_separates_128_bit_ids() {
fn identifier(low: u64, high: u64) -> [u8; 16] {
let mut bytes = [0; 16];
bytes[..8].copy_from_slice(&low.to_le_bytes());
bytes[8..].copy_from_slice(&high.to_le_bytes());
bytes
}
let index = 0x0005_0000_0000_1234;
assert_eq!(fold_file_id(identifier(index, 0)), index, "NTFS-shaped ids keep the index");
let a = fold_file_id(identifier(7, 5));
assert_ne!(a, fold_file_id(identifier(7, 6)), "ids sharing the low half differ");
assert_ne!(a, fold_file_id(identifier(8, 5)), "ids sharing the high half differ");
assert_ne!(a, fold_file_id(identifier(5, 7)), "ids with the halves exchanged differ");
assert_ne!(a, 7, "a 128-bit id is not its truncation");
}
#[test]
fn only_name_surrogate_reparse_tags_are_link_like() {
const IO_REPARSE_TAG_SYMLINK: u32 = 0xa000_000c;
const IO_REPARSE_TAG_WOF: u32 = 0x8000_0017;
assert_eq!(
kind_from_attributes(FILE_ATTRIBUTE_REPARSE_POINT, IO_REPARSE_TAG_SYMLINK),
EntryKind::Symlink
);
assert_eq!(
kind_from_attributes(FILE_ATTRIBUTE_REPARSE_POINT, IO_REPARSE_TAG_WOF),
EntryKind::File
);
assert_eq!(
kind_from_attributes(
FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY,
IO_REPARSE_TAG_WOF,
),
EntryKind::Dir
);
}
}