Skip to main content

tree_space/fs/
meta.rs

1//! File metadata for the Properties dialog.
2//!
3//! This is the non-visual half of the feature: a `stat(2)` snapshot plus the
4//! helpers needed to *display* and *modify* it — human-readable sizes, Unix
5//! permission math, owner/group name resolution, and image dimensions read
6//! straight from file headers. Nothing here builds widgets, so it is all
7//! exercised headlessly by `cargo test`.
8//!
9//! Only GIO is used (for the MIME content type and filesystem free space),
10//! never GTK.
11
12use std::fs;
13use std::io;
14use std::os::unix::fs::{MetadataExt, PermissionsExt};
15use std::path::{Path, PathBuf};
16use std::time::SystemTime;
17
18use relm4::gtk::gio;
19use relm4::gtk::gio::prelude::FileExt;
20
21/// One of the eight classic Unix access levels for a single triad (owner,
22/// group or other), in the order the menu presents them.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Access {
25    None,
26    Execute,
27    Write,
28    WriteExecute,
29    Read,
30    ReadExecute,
31    ReadWrite,
32    ReadWriteExecute,
33}
34
35impl Access {
36    /// All eight levels, in menu order.
37    pub const ALL: [Access; 8] = [
38        Access::None,
39        Access::Execute,
40        Access::Write,
41        Access::WriteExecute,
42        Access::Read,
43        Access::ReadExecute,
44        Access::ReadWrite,
45        Access::ReadWriteExecute,
46    ];
47
48    /// The 3-bit value for this level.
49    pub fn bits(self) -> u32 {
50        self as u32
51    }
52
53    /// Decode the low three bits of `bits`.
54    pub fn from_bits(bits: u32) -> Self {
55        match bits & 0b111 {
56            0 => Access::None,
57            1 => Access::Execute,
58            2 => Access::Write,
59            3 => Access::WriteExecute,
60            4 => Access::Read,
61            5 => Access::ReadExecute,
62            6 => Access::ReadWrite,
63            _ => Access::ReadWriteExecute,
64        }
65    }
66
67    /// The menu label.
68    pub fn label(self) -> &'static str {
69        match self {
70            Access::None => "None",
71            Access::Execute => "Execute",
72            Access::Write => "Write",
73            Access::WriteExecute => "Write & execute",
74            Access::Read => "Read",
75            Access::ReadExecute => "Read & execute",
76            Access::ReadWrite => "Read & write",
77            Access::ReadWriteExecute => "Read, write & execute",
78        }
79    }
80
81    /// The index of this level in [`Access::ALL`].
82    pub fn index(self) -> u32 {
83        self as u32
84    }
85
86    /// Whether any of the read/write/execute bits are set.
87    pub fn is_empty(self) -> bool {
88        self == Access::None
89    }
90}
91
92/// A full Unix permission set: the three triads plus the set-uid/set-gid/sticky
93/// bits.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct Permissions {
96    pub owner: Access,
97    pub group: Access,
98    pub other: Access,
99    pub set_uid: bool,
100    pub set_gid: bool,
101    pub sticky: bool,
102}
103
104impl Permissions {
105    /// Read the permission bits out of a raw `st_mode`.
106    pub fn from_mode(mode: u32) -> Self {
107        Permissions {
108            owner: Access::from_bits(mode >> 6),
109            group: Access::from_bits(mode >> 3),
110            other: Access::from_bits(mode),
111            set_uid: mode & 0o4000 != 0,
112            set_gid: mode & 0o2000 != 0,
113            sticky: mode & 0o1000 != 0,
114        }
115    }
116
117    /// Just the nine permission bits (no set-uid/set-gid/sticky).
118    pub fn mode_bits(self) -> u32 {
119        (self.owner.bits() << 6) | (self.group.bits() << 3) | self.other.bits()
120    }
121
122    /// The full `chmod` value, including the special bits.
123    pub fn to_mode(self) -> u32 {
124        let mut mode = self.mode_bits();
125        if self.set_uid {
126            mode |= 0o4000;
127        }
128        if self.set_gid {
129            mode |= 0o2000;
130        }
131        if self.sticky {
132            mode |= 0o1000;
133        }
134        mode
135    }
136}
137
138/// A `stat(2)` snapshot of one path, with the presentation-friendly fields the
139/// Properties dialog needs already resolved.
140#[derive(Debug, Clone)]
141pub struct FileMeta {
142    pub path: PathBuf,
143    pub name: String,
144    pub is_dir: bool,
145    pub is_symlink: bool,
146    /// The link target, when `is_symlink`.
147    pub link_target: Option<PathBuf>,
148    /// Size in bytes (the link's own size for a symlink).
149    pub size: u64,
150    /// The mode a `chmod` would read/write. For a symlink this is the target's
151    /// mode when it resolves, else the link's.
152    pub mode: u32,
153    pub permissions: Permissions,
154    pub uid: u32,
155    pub gid: u32,
156    /// Resolved user/group names, or the numeric id when unresolvable.
157    pub owner: String,
158    pub group: String,
159    pub owner_name: Option<String>,
160    pub group_name: Option<String>,
161    pub nlink: u64,
162    pub inode: u64,
163    pub mime_type: Option<String>,
164    pub type_description: Option<String>,
165    pub accessed: Option<SystemTime>,
166    pub modified: Option<SystemTime>,
167    pub created: Option<SystemTime>,
168}
169
170impl FileMeta {
171    /// The name shown in dialog titles and headers.
172    pub fn display_name(&self) -> String {
173        self.name.clone()
174    }
175
176    /// The `stat` size, or — for a directory — the number of entries in it.
177    /// Counting is best-effort; an unreadable directory yields `None`.
178    pub fn item_count(&self) -> Option<u64> {
179        if self.is_dir {
180            count_items(&self.path).ok()
181        } else {
182            None
183        }
184    }
185}
186
187/// Read a metadata snapshot for `path`. Follows symlinks for the size/times/
188/// mode (so permissions can be edited), but records the link itself too.
189pub fn read_meta(path: &Path) -> io::Result<FileMeta> {
190    let link_meta = fs::symlink_metadata(path)?;
191    let is_symlink = link_meta.file_type().is_symlink();
192    let link_target = if is_symlink { fs::read_link(path).ok() } else { None };
193
194    // Permissions/type/times come from the target when the link resolves, so
195    // editing them behaves like editing the file; a broken link falls back to
196    // the link's own metadata.
197    let followed = if is_symlink { fs::metadata(path).ok() } else { None };
198    let meta = followed.as_ref().unwrap_or(&link_meta);
199
200    let mode = meta.mode();
201    let uid = meta.uid();
202    let gid = meta.gid();
203    let owner_name = user_name(uid);
204    let group_name = group_name(gid);
205    let mime_type = content_type_for(path);
206    let type_description =
207        mime_type.as_deref().map(gio::content_type_get_description).map(|s| s.to_string());
208
209    let name = path
210        .file_name()
211        .map(|n| n.to_string_lossy().into_owned())
212        .unwrap_or_else(|| path.display().to_string());
213
214    Ok(FileMeta {
215        path: path.to_path_buf(),
216        name,
217        is_dir: meta.is_dir(),
218        is_symlink,
219        link_target,
220        size: meta.size(),
221        mode,
222        permissions: Permissions::from_mode(mode),
223        uid,
224        gid,
225        owner: owner_name.clone().unwrap_or_else(|| uid.to_string()),
226        group: group_name.clone().unwrap_or_else(|| gid.to_string()),
227        owner_name,
228        group_name,
229        nlink: meta.nlink(),
230        inode: meta.ino(),
231        mime_type,
232        type_description,
233        accessed: meta.accessed().ok(),
234        modified: meta.modified().ok(),
235        created: meta.created().ok(),
236    })
237}
238
239/// Count the entries directly inside `dir` (the "N items" line).
240pub fn count_items(dir: &Path) -> io::Result<u64> {
241    let mut count = 0;
242    for entry in fs::read_dir(dir)? {
243        entry?;
244        count += 1;
245    }
246    Ok(count)
247}
248
249/// The MIME content type GIO reports for `path` (e.g. `text/plain`).
250///
251/// Deliberately *not* `content_type_guess_for_tree`: that call exists to find a
252/// type common to a whole tree and returns an empty list for a single plain
253/// file, which silently disabled both "Open With..." and its default-app label.
254pub fn content_type_for(path: &Path) -> Option<String> {
255    let file = gio::File::for_path(path);
256    let info = file
257        .query_info(
258            gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
259            gio::FileQueryInfoFlags::NONE,
260            None::<&gio::Cancellable>,
261        )
262        .ok()?;
263    info.content_type().map(|s| s.to_string())
264}
265
266/// Free bytes on the filesystem holding `path`, if it can be queried.
267pub fn available_space(path: &Path) -> Option<u64> {
268    let file = gio::File::for_path(path);
269    let info = file
270        .query_filesystem_info(
271            gio::FILE_ATTRIBUTE_FILESYSTEM_FREE,
272            None::<&gio::Cancellable>,
273        )
274        .ok()?;
275    Some(info.attribute_uint64(gio::FILE_ATTRIBUTE_FILESYSTEM_FREE))
276}
277
278/// Apply a full permission set with `chmod(2)`.
279pub fn set_permissions(path: &Path, permissions: Permissions) -> io::Result<()> {
280    fs::set_permissions(path, fs::Permissions::from_mode(permissions.to_mode()))
281}
282
283/// Change the owner (`chown` uid only, keeping the group).
284pub fn set_owner_uid(path: &Path, uid: u32) -> io::Result<()> {
285    set_uint32_attribute(path, "unix::uid", uid)
286}
287
288/// Change the group (`chown` gid only, keeping the owner).
289pub fn set_group_gid(path: &Path, gid: u32) -> io::Result<()> {
290    set_uint32_attribute(path, "unix::gid", gid)
291}
292
293fn set_uint32_attribute(path: &Path, attribute: &str, value: u32) -> io::Result<()> {
294    let file = gio::File::for_path(path);
295    file.set_attribute_uint32(
296        attribute,
297        value,
298        gio::FileQueryInfoFlags::NONE,
299        None::<&gio::Cancellable>,
300    )
301    .map_err(|err| io::Error::other(err.to_string()))
302}
303
304/// Human-readable byte count in decimal units (1 kB = 1000 B), matching the
305/// `g_format_size` output Nautilus shows.
306pub fn format_size(bytes: u64) -> String {
307    const UNITS: [&str; 5] = ["kB", "MB", "GB", "TB", "PB"];
308    if bytes < 1000 {
309        return format!("{bytes} {}", if bytes == 1 { "byte" } else { "bytes" });
310    }
311    let mut value = bytes as f64;
312    let mut index = 0;
313    value /= 1000.0;
314    while value >= 1000.0 && index < UNITS.len() - 1 {
315        value /= 1000.0;
316        index += 1;
317    }
318    format!("{value:.1} {}", UNITS[index])
319}
320
321/// "1 item" / "N items".
322pub fn item_count_label(count: u64) -> String {
323    format!("{count} {}", if count == 1 { "item" } else { "items" })
324}
325
326/// Resolve a user id to a name via `/etc/passwd`.
327pub fn user_name(uid: u32) -> Option<String> {
328    parse_id_name(&fs::read_to_string("/etc/passwd").ok()?, uid)
329}
330
331/// Resolve a group id to a name via `/etc/group`.
332pub fn group_name(gid: u32) -> Option<String> {
333    parse_id_name(&fs::read_to_string("/etc/group").ok()?, gid)
334}
335
336/// Every `(id, name)` pair listed in `/etc/passwd` (for the Owner combo).
337pub fn list_users() -> Vec<(u32, String)> {
338    list_id_names("/etc/passwd")
339}
340
341/// Every `(id, name)` pair listed in `/etc/group` (for the Group combo).
342pub fn list_groups() -> Vec<(u32, String)> {
343    list_id_names("/etc/group")
344}
345
346fn list_id_names(path: &str) -> Vec<(u32, String)> {
347    let Ok(text) = fs::read_to_string(path) else {
348        return Vec::new();
349    };
350    let mut entries = Vec::new();
351    for line in text.lines() {
352        let line = line.trim();
353        if line.is_empty() || line.starts_with('#') {
354            continue;
355        }
356        let mut fields = line.split(':');
357        let Some(name) = fields.next() else { continue };
358        if fields.next().is_none() {
359            continue;
360        }
361        let Some(id) = fields.next().and_then(|f| f.parse::<u32>().ok()) else {
362            continue;
363        };
364        entries.push((id, name.to_string()));
365    }
366    entries
367}
368
369/// Find the name whose id field (the third colon-separated field, used for both
370/// `passwd` uid and `group` gid) equals `id`.
371fn parse_id_name(text: &str, id: u32) -> Option<String> {
372    for line in text.lines() {
373        let line = line.trim();
374        if line.is_empty() || line.starts_with('#') {
375            continue;
376        }
377        let mut fields = line.split(':');
378        let name = fields.next()?;
379        let _ = fields.next()?;
380        if fields.next()?.parse::<u32>().ok() == Some(id) {
381            return Some(name.to_string());
382        }
383    }
384    None
385}
386
387/// Pixel dimensions of an image, read from its header (PNG, JPEG, GIF, WebP or
388/// BMP). `None` for anything else, or a truncated/corrupt header.
389pub fn image_dimensions(path: &Path) -> Option<(u32, u32)> {
390    let data = fs::read(path).ok()?;
391    dimensions_from_bytes(&data)
392}
393
394fn dimensions_from_bytes(data: &[u8]) -> Option<(u32, u32)> {
395    if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
396        // IHDR chunk: width/height are big-endian u32 at offsets 16 and 20.
397        return Some((be32(data, 16)?, be32(data, 20)?));
398    }
399    if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
400        return Some((u32::from(le16(data, 6)?), u32::from(le16(data, 8)?)));
401    }
402    if data.starts_with(b"BM") {
403        // BITMAPINFOHEADER: signed little-endian width/height at 18 and 22.
404        let width = le32(data, 18)? as i32;
405        let height = le32(data, 22)? as i32;
406        if width > 0 && height > 0 {
407            return Some((width as u32, height as u32));
408        }
409    }
410    if data.starts_with(&[0xff, 0xd8]) {
411        return jpeg_dimensions(data);
412    }
413    if data.starts_with(b"RIFF") && data.get(8..12) == Some(b"WEBP") {
414        return webp_dimensions(data.get(12..)?);
415    }
416    None
417}
418
419fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
420    let mut i = 2;
421    while i + 1 < data.len() {
422        // Markers are 0xFF followed by a non-0xFF, non-zero type byte.
423        if data[i] != 0xff {
424            i += 1;
425            continue;
426        }
427        let mut marker = data[i + 1];
428        i += 2;
429        while marker == 0xff && i < data.len() {
430            marker = data[i];
431            i += 1;
432        }
433        match marker {
434            // Standalone markers (no length field).
435            0x01 | 0xd0..=0xd7 => continue,
436            // End of image / start of scan: no SOF will follow.
437            0xd9 | 0xda => return None,
438            _ => {}
439        }
440        if i + 1 >= data.len() {
441            return None;
442        }
443        let length = (usize::from(data[i]) << 8) | usize::from(data[i + 1]);
444        if length < 2 {
445            return None;
446        }
447        // SOF0..3 / SOF5..7 / SOF9..11 / SOF13..15 carry the frame size.
448        let is_sof = matches!(marker, 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf);
449        if is_sof {
450            return Some((be16(data, i + 5)?, be16(data, i + 3)?));
451        }
452        i += length;
453    }
454    None
455}
456
457fn webp_dimensions(data: &[u8]) -> Option<(u32, u32)> {
458    let payload = data.get(8..)?;
459    match data.get(0..4)? {
460        b"VP8 " => {
461            // Lossy: 3-byte frame tag, 3-byte start code, then 14-bit dims.
462            if payload.get(3..6) != Some(&[0x9d, 0x01, 0x2a]) {
463                return None;
464            }
465            let width = u32::from(le16(payload, 6)? & 0x3fff);
466            let height = u32::from(le16(payload, 8)? & 0x3fff);
467            Some((width, height))
468        }
469        b"VP8L" => {
470            // Lossless: 0x2F signature then two packed 14-bit lengths.
471            if payload.first() != Some(&0x2f) {
472                return None;
473            }
474            let bits = le32(payload, 1)?;
475            Some(((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1))
476        }
477        b"VP8X" => {
478            // Extended: 1 flags byte + 3 reserved, then 24-bit canvas minus one.
479            Some((le24(payload, 4)? + 1, le24(payload, 7)? + 1))
480        }
481        _ => None,
482    }
483}
484
485fn be16(data: &[u8], offset: usize) -> Option<u32> {
486    let bytes = data.get(offset..offset + 2)?;
487    Some(u32::from(u16::from_be_bytes([bytes[0], bytes[1]])))
488}
489
490fn be32(data: &[u8], offset: usize) -> Option<u32> {
491    let bytes = data.get(offset..offset + 4)?;
492    Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
493}
494
495fn le16(data: &[u8], offset: usize) -> Option<u16> {
496    let bytes = data.get(offset..offset + 2)?;
497    Some(u16::from_le_bytes([bytes[0], bytes[1]]))
498}
499
500fn le24(data: &[u8], offset: usize) -> Option<u32> {
501    let bytes = data.get(offset..offset + 3)?;
502    Some(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16))
503}
504
505fn le32(data: &[u8], offset: usize) -> Option<u32> {
506    let bytes = data.get(offset..offset + 4)?;
507    Some(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn access_levels_round_trip() {
516        for (bits, level) in Access::ALL.iter().enumerate() {
517            assert_eq!(Access::from_bits(bits as u32), *level);
518            assert_eq!(level.bits(), bits as u32);
519            assert_eq!(level.index(), bits as u32);
520        }
521    }
522
523    #[test]
524    fn permissions_round_trip_including_special_bits() {
525        for mode in [0o000, 0o644, 0o755, 0o4755, 0o2755, 0o1777, 0o7777] {
526            let permissions = Permissions::from_mode(mode);
527            assert_eq!(permissions.to_mode(), mode, "mode {mode:o}");
528        }
529    }
530
531    #[test]
532    fn permissions_split_into_triads() {
533        let permissions = Permissions::from_mode(0o754);
534        assert_eq!(permissions.owner, Access::ReadWriteExecute);
535        assert_eq!(permissions.group, Access::ReadExecute);
536        assert_eq!(permissions.other, Access::Read);
537        assert!(!permissions.set_uid);
538    }
539
540    #[test]
541    fn format_size_matches_nautilus_style() {
542        assert_eq!(format_size(0), "0 bytes");
543        assert_eq!(format_size(1), "1 byte");
544        assert_eq!(format_size(999), "999 bytes");
545        assert_eq!(format_size(1000), "1.0 kB");
546        assert_eq!(format_size(1500), "1.5 kB");
547        assert_eq!(format_size(1_234_567), "1.2 MB");
548        assert_eq!(format_size(3_000_000_000), "3.0 GB");
549        assert_eq!(format_size(1_000_000_000_000), "1.0 TB");
550    }
551
552    #[test]
553    fn item_count_label_is_pluralised() {
554        assert_eq!(item_count_label(0), "0 items");
555        assert_eq!(item_count_label(1), "1 item");
556        assert_eq!(item_count_label(2), "2 items");
557    }
558
559    #[test]
560    fn id_name_lookup_reads_the_third_field() {
561        let passwd = "root:x:0:0:root:/root:/bin/bash\n# comment\nalice:x:1000:1000::/home/alice:/bin/sh\n";
562        assert_eq!(parse_id_name(passwd, 0).as_deref(), Some("root"));
563        assert_eq!(parse_id_name(passwd, 1000).as_deref(), Some("alice"));
564        assert_eq!(parse_id_name(passwd, 9999), None);
565    }
566
567    #[test]
568    fn list_id_names_skips_comments_and_blanks() {
569        let group = "# group file\n\nwheel:x:10:alice\nusers:x:100:\n";
570        let entries = {
571            // Exercise the parsing path without touching /etc/group.
572            let mut entries = Vec::new();
573            for line in group.lines() {
574                if line.starts_with('#') || line.is_empty() {
575                    continue;
576                }
577                let mut fields = line.split(':');
578                if let (Some(name), Some(_), Some(id)) =
579                    (fields.next(), fields.next(), fields.next())
580                    && let Ok(id) = id.parse::<u32>()
581                {
582                    entries.push((id, name.to_string()));
583                }
584            }
585            entries
586        };
587        assert_eq!(entries, vec![(10, "wheel".to_string()), (100, "users".to_string())]);
588    }
589
590    #[test]
591    fn image_dimensions_parses_png() {
592        let mut png = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
593        png.extend_from_slice(&[0, 0, 0, 13]); // IHDR length
594        png.extend_from_slice(b"IHDR");
595        png.extend_from_slice(&640u32.to_be_bytes());
596        png.extend_from_slice(&480u32.to_be_bytes());
597        assert_eq!(dimensions_from_bytes(&png), Some((640, 480)));
598    }
599
600    #[test]
601    fn image_dimensions_parses_gif() {
602        let mut gif = b"GIF89a".to_vec();
603        gif.extend_from_slice(&320u16.to_le_bytes());
604        gif.extend_from_slice(&200u16.to_le_bytes());
605        assert_eq!(dimensions_from_bytes(&gif), Some((320, 200)));
606    }
607
608    #[test]
609    fn image_dimensions_parses_bmp() {
610        let mut bmp = b"BM".to_vec();
611        bmp.resize(18, 0);
612        bmp.extend_from_slice(&64i32.to_le_bytes());
613        bmp.extend_from_slice(&48i32.to_le_bytes());
614        assert_eq!(dimensions_from_bytes(&bmp), Some((64, 48)));
615    }
616
617    #[test]
618    fn image_dimensions_parses_jpeg_sof() {
619        // SOI, APP0 segment (skipped), then SOF0 carrying 800x600.
620        let mut jpeg = vec![0xff, 0xd8];
621        jpeg.extend_from_slice(&[0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]);
622        jpeg.extend_from_slice(&[0xff, 0xc0, 0x00, 0x11, 0x08]);
623        jpeg.extend_from_slice(&800u16.to_be_bytes());
624        jpeg.extend_from_slice(&600u16.to_be_bytes());
625        jpeg.extend_from_slice(&[0x03, 0x01, 0x11, 0x00]);
626        assert_eq!(dimensions_from_bytes(&jpeg), Some((600, 800)));
627    }
628
629    #[test]
630    fn image_dimensions_parses_webp_vp8() {
631        let mut webp = b"RIFF".to_vec();
632        webp.extend_from_slice(&0u32.to_le_bytes());
633        webp.extend_from_slice(b"WEBP");
634        webp.extend_from_slice(b"VP8 ");
635        webp.extend_from_slice(&[0; 4]); // chunk size (unused)
636        webp.extend_from_slice(&[0, 0, 0, 0x9d, 0x01, 0x2a]);
637        webp.extend_from_slice(&256u16.to_le_bytes());
638        webp.extend_from_slice(&128u16.to_le_bytes());
639        assert_eq!(dimensions_from_bytes(&webp), Some((256, 128)));
640    }
641
642    #[test]
643    fn image_dimensions_rejects_unknown() {
644        assert_eq!(dimensions_from_bytes(b"not an image at all"), None);
645        assert_eq!(dimensions_from_bytes(&[0xff, 0xd8, 0xff, 0xd9]), None);
646    }
647
648    #[test]
649    fn read_meta_reports_regular_file_fields() {
650        let dir = tempfile::tempdir().unwrap();
651        let file = dir.path().join("note.txt");
652        fs::write(&file, b"hello").unwrap();
653        fs::set_permissions(&file, fs::Permissions::from_mode(0o640)).unwrap();
654
655        let meta = read_meta(&file).unwrap();
656        assert_eq!(meta.name, "note.txt");
657        assert!(!meta.is_dir);
658        assert!(!meta.is_symlink);
659        assert_eq!(meta.size, 5);
660        assert_eq!(meta.permissions.owner, Access::ReadWrite);
661        assert_eq!(meta.permissions.group, Access::Read);
662        assert_eq!(meta.permissions.other, Access::None);
663        assert_eq!(meta.uid, fs::metadata(&file).unwrap().uid());
664        assert_eq!(meta.owner, user_name(meta.uid).unwrap_or_else(|| meta.uid.to_string()));
665    }
666
667    #[test]
668    fn read_meta_detects_symlinks_and_their_target() {
669        let dir = tempfile::tempdir().unwrap();
670        let target = dir.path().join("target.txt");
671        fs::write(&target, b"data").unwrap();
672        let link = dir.path().join("link.txt");
673        std::os::unix::fs::symlink(&target, &link).unwrap();
674
675        let meta = read_meta(&link).unwrap();
676        assert!(meta.is_symlink);
677        assert_eq!(meta.link_target.as_deref(), Some(target.as_path()));
678        assert_eq!(meta.size, 4);
679        assert!(!meta.is_dir);
680    }
681
682    #[test]
683    fn count_items_counts_directory_entries() {
684        let dir = tempfile::tempdir().unwrap();
685        fs::write(dir.path().join("a"), b"").unwrap();
686        fs::write(dir.path().join("b"), b"").unwrap();
687        fs::create_dir(dir.path().join("sub")).unwrap();
688        assert_eq!(count_items(dir.path()).unwrap(), 3);
689    }
690
691    #[test]
692    fn set_permissions_round_trips_through_read_meta() {
693        let dir = tempfile::tempdir().unwrap();
694        let file = dir.path().join("exec");
695        fs::write(&file, b"#!/bin/sh\n").unwrap();
696        let permissions = Permissions {
697            owner: Access::ReadWriteExecute,
698            group: Access::ReadExecute,
699            other: Access::None,
700            set_uid: false,
701            set_gid: false,
702            sticky: false,
703        };
704        set_permissions(&file, permissions).unwrap();
705        let meta = read_meta(&file).unwrap();
706        assert_eq!(meta.permissions, permissions);
707        assert_eq!(meta.mode & 0o777, 0o750);
708    }
709}