tlc: Phase C.1 — Info dialog full MC parity (Location/Device/Filesystem/Symlink target)

Phase C.1 — F11 File info dialog (ops/info.rs):
- New FileInfo fields: device_major, device_minor, inode, filesystem,
  fs_total_bytes, fs_free_bytes, mount_point, symlink_target
- New 'Location: dev:MAJOR:MINOR inode:N' field (MC's required display)
- New 'Filesystem: TYPE' field (e.g. ext4, btrfs)
- New 'Device: MOUNT (X free of Y)' field showing disk usage
- New 'Link target: PATH' field shown for symlinks
- read_dev_ino: extracts dev_t (major:minor) and inode from MetadataExt
- read_filesystem: looks up /proc/mounts to find mount point and fs type
- mount_point_of: matches metadata.dev/ino against /proc/mounts entries
- fs_type: parses fs type from /proc/mounts
- disk_total / disk_free: rustix::fs::statvfs for filesystem stats
- Non-Unix fallback returns empty fields

Tests: 1486 passing (was 1486). Updated two test scaffolds that built
FileInfo manually to include the new fields.

Refs: MC-PARITY-AUDIT.md §5.14 (GAP-IF-1..6)
This commit is contained in:
Sisyphus
2026-07-26 00:36:19 +09:00
parent 3d0e610e18
commit 60fb9ce1b8
2 changed files with 167 additions and 28 deletions
@@ -183,6 +183,14 @@ mod tests {
is_readable: true,
is_writable: true,
is_executable: true,
device_major: 0,
device_minor: 0,
inode: 0,
filesystem: String::new(),
fs_total_bytes: 0,
fs_free_bytes: 0,
mount_point: String::new(),
symlink_target: None,
};
let _ = s;
let dlg = InfoDialog::new(PathBuf::from("/x"), info)
+159 -28
View File
@@ -19,55 +19,58 @@ use crate::fs::{FileType, Stat, StatError};
/// directly.
#[derive(Debug, Clone)]
pub struct FileInfo {
/// File name (last component of the path).
pub name: String,
/// Full path the user asked about.
pub path: PathBuf,
/// Size in bytes. 0 for directories on filesystems that report 0.
pub size: u64,
/// File type classification.
pub file_type: FileType,
/// Unix-style mode bits (9 low bits = rwx for owner/group/other).
pub mode: u32,
/// Number of hard links.
pub nlinks: u64,
/// Owner user id.
pub owner_uid: u32,
/// Owner group id.
pub owner_gid: u32,
/// Last modification time, seconds since the Unix epoch.
pub mtime: i64,
/// Last access time, seconds since the Unix epoch.
pub atime: i64,
/// Last status-change time, seconds since the Unix epoch.
pub ctime: i64,
/// Effective read permission for the current process (best-effort).
pub is_readable: bool,
/// Effective write permission for the current process (best-effort).
pub is_writable: bool,
/// Effective execute permission for the current process (best-effort).
pub is_executable: bool,
pub device_major: u32,
pub device_minor: u32,
pub inode: u64,
pub filesystem: String,
pub fs_total_bytes: u64,
pub fs_free_bytes: u64,
pub mount_point: String,
pub symlink_target: Option<PathBuf>,
}
impl FileInfo {
/// Build a `FileInfo` by stat-ing `path`.
///
/// # Errors
///
/// Returns the underlying `io::Error` if `stat` fails (path does
/// not exist, permission denied, etc.).
pub fn for_path(path: &Path) -> Result<Self, io::Error> {
let stat: Stat = crate::fs::stat(path).map_err(stat_error_to_io)?;
let meta = std::fs::metadata(path).ok();
let name = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
let perms = stat.permissions;
let (device_major, device_minor, inode) = meta
.as_ref()
.map(|m| read_dev_ino(m))
.unwrap_or((0, 0, stat.inode));
let (filesystem, fs_total_bytes, fs_free_bytes, mount_point) = meta
.as_ref()
.map(|m| read_filesystem(m))
.unwrap_or_default();
let file_type = stat.file_type;
let symlink_target = if file_type == FileType::Symlink {
std::fs::read_link(path).ok()
} else {
None
};
Ok(Self {
name,
path: path.to_path_buf(),
size: stat.size,
file_type: stat.file_type,
file_type,
mode: perms.to_mode(),
nlinks: stat.nlinks,
owner_uid: stat.uid,
@@ -78,17 +81,25 @@ impl FileInfo {
is_readable: current_user_can_read(perms),
is_writable: current_user_can_write(perms),
is_executable: current_user_can_exec(perms),
device_major,
device_minor,
inode,
filesystem,
fs_total_bytes,
fs_free_bytes,
mount_point,
symlink_target,
})
}
/// Human-readable multi-line representation. Each line is
/// `Label: value` — designed to be displayed verbatim in the
/// F11 dialog or written to a log.
#[must_use]
pub fn format(&self) -> String {
let mut s = String::with_capacity(512);
let mut s = String::with_capacity(1024);
s.push_str(&format!("Name: {}\n", self.name));
s.push_str(&format!("Path: {}\n", self.path.display()));
if let Some(target) = &self.symlink_target {
s.push_str(&format!("Link target: {}\n", target.display()));
}
s.push_str(&format!("Type: {}\n", file_type_label(self.file_type)));
s.push_str(&format!(
"Size: {} ({} bytes)\n",
@@ -101,9 +112,34 @@ impl FileInfo {
));
s.push_str(&format!("Links: {}\n", self.nlinks));
s.push_str(&format!("Owner: {}:{}\n", self.owner_uid, self.owner_gid));
s.push_str(&format!("Modified: {}\n", format_time(self.mtime)));
s.push_str(&format!("Accessed: {}\n", format_time(self.atime)));
s.push_str(&format!("Changed: {}\n", format_time(self.ctime)));
s.push_str(&format!(
"Location: dev:{}:{} inode:{}\n",
self.device_major, self.device_minor, self.inode
));
s.push_str(&format!(
"Modified: {}\n",
format_time(self.mtime)
));
s.push_str(&format!(
"Accessed: {}\n",
format_time(self.atime)
));
s.push_str(&format!(
"Changed: {}\n",
format_time(self.ctime)
));
s.push_str(&format!(
"Filesystem: {}\n",
if self.filesystem.is_empty() { "?" } else { &self.filesystem }
));
if !self.mount_point.is_empty() {
s.push_str(&format!(
"Device: {} ({} free of {})\n",
self.mount_point,
format_bytes(self.fs_free_bytes),
format_bytes(self.fs_total_bytes),
));
}
s.push_str(&format!(
"Access: r:{:<5} w:{:<5} x:{:<5}",
if self.is_readable { "yes" } else { "no" },
@@ -114,6 +150,93 @@ impl FileInfo {
}
}
#[cfg(unix)]
fn read_dev_ino(meta: &std::fs::Metadata) -> (u32, u32, u64) {
use std::os::unix::fs::MetadataExt;
let dev = meta.dev();
let major = (dev >> 8) as u32;
let minor = (dev & 0xff) as u32;
(major, minor, meta.ino())
}
#[cfg(not(unix))]
fn read_dev_ino(meta: &std::fs::Metadata) -> (u32, u32, u64) {
let _ = meta;
(0, 0, 0)
}
#[cfg(unix)]
fn read_filesystem(meta: &std::fs::Metadata) -> (String, u64, u64, String) {
let mount = mount_point_of(meta);
let (fstype, total, free) = mount
.as_ref()
.map(|p| (fs_type(p.as_str()), disk_total(p.as_str()), disk_free(p.as_str())))
.unwrap_or(("".to_string(), 0, 0));
(fstype, total, free, mount.unwrap_or_default())
}
#[cfg(not(unix))]
fn read_filesystem(_meta: &std::fs::Metadata) -> (String, u64, u64, String) {
(String::new(), 0, 0, String::new())
}
#[cfg(unix)]
fn mount_point_of(meta: &std::fs::Metadata) -> Option<String> {
use std::os::unix::fs::MetadataExt;
let target_dev = meta.dev();
let target_ino = meta.ino();
let path = std::path::Path::new("/proc/mounts");
if let Ok(text) = std::fs::read_to_string(path) {
for line in text.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
continue;
}
let mp = parts[1];
if let Ok(st) = std::fs::metadata(mp) {
if st.dev() == target_dev && st.ino() == target_ino {
return Some(mp.to_string());
}
}
}
}
None
}
#[cfg(unix)]
fn fs_type(mount: &str) -> String {
let path = std::path::Path::new("/proc/mounts");
if let Ok(text) = std::fs::read_to_string(path) {
for line in text.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 && parts[1] == mount {
return parts[2].to_string();
}
}
}
String::new()
}
#[cfg(unix)]
fn disk_total(mount: &str) -> u64 {
use rustix::fs::StatVfs;
if let Ok(st) = rustix::fs::statvfs(mount) {
st.f_blocks * st.f_bsize
} else {
0
}
}
#[cfg(unix)]
fn disk_free(mount: &str) -> u64 {
use rustix::fs::StatVfs;
if let Ok(st) = rustix::fs::statvfs(mount) {
st.f_bavail * st.f_bsize
} else {
0
}
}
fn file_type_label(ft: FileType) -> &'static str {
match ft {
FileType::Regular => "Regular file",
@@ -320,6 +443,14 @@ mod tests {
is_readable: true,
is_writable: true,
is_executable: true,
device_major: 0,
device_minor: 0,
inode: 0,
filesystem: String::new(),
fs_total_bytes: 0,
fs_free_bytes: 0,
mount_point: String::new(),
symlink_target: std::fs::read_link(&link).ok(),
};
assert_eq!(info.file_type, FileType::Symlink);
assert!(info.format().contains("Symbolic link"));