1use 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#[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 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 pub fn bits(self) -> u32 {
50 self as u32
51 }
52
53 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 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 pub fn index(self) -> u32 {
83 self as u32
84 }
85
86 pub fn is_empty(self) -> bool {
88 self == Access::None
89 }
90}
91
92#[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 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 pub fn mode_bits(self) -> u32 {
119 (self.owner.bits() << 6) | (self.group.bits() << 3) | self.other.bits()
120 }
121
122 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#[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 pub link_target: Option<PathBuf>,
148 pub size: u64,
150 pub mode: u32,
153 pub permissions: Permissions,
154 pub uid: u32,
155 pub gid: u32,
156 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 pub fn display_name(&self) -> String {
173 self.name.clone()
174 }
175
176 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
187pub 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 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
239pub 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
249pub 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
266pub 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
278pub fn set_permissions(path: &Path, permissions: Permissions) -> io::Result<()> {
280 fs::set_permissions(path, fs::Permissions::from_mode(permissions.to_mode()))
281}
282
283pub fn set_owner_uid(path: &Path, uid: u32) -> io::Result<()> {
285 set_uint32_attribute(path, "unix::uid", uid)
286}
287
288pub 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
304pub 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
321pub fn item_count_label(count: u64) -> String {
323 format!("{count} {}", if count == 1 { "item" } else { "items" })
324}
325
326pub fn user_name(uid: u32) -> Option<String> {
328 parse_id_name(&fs::read_to_string("/etc/passwd").ok()?, uid)
329}
330
331pub fn group_name(gid: u32) -> Option<String> {
333 parse_id_name(&fs::read_to_string("/etc/group").ok()?, gid)
334}
335
336pub fn list_users() -> Vec<(u32, String)> {
338 list_id_names("/etc/passwd")
339}
340
341pub 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
369fn 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
387pub 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 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 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 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 0x01 | 0xd0..=0xd7 => continue,
436 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 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 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 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 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 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]); 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 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]); 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}