Skip to main content

ocy_core/
filesystem.rs

1use crate::models::FileInfo;
2use crate::models::SimpleFileKind;
3use eyre::Context;
4use eyre::Report;
5use eyre::Result;
6use std::collections::HashSet;
7use std::fs::{self, DirEntry, Metadata};
8#[cfg(unix)]
9use std::os::unix::fs::MetadataExt;
10#[cfg(windows)]
11use std::os::windows::fs::MetadataExt;
12use std::path::Path;
13
14/// The contents of a directory, together with any per-entry failures.
15///
16/// Reading a directory is not all-or-nothing. A hidden sibling is silently unreclaimed
17/// disk space, so one bad entry must not discard the rest of the listing.
18///
19/// Two different things could discard it, and they are handled differently. A name that
20/// does not decode as UTF-8 is not an error at all -- see [`FileInfo::name`], which keeps
21/// the decoded form for matching and the original [`std::path::PathBuf`] for every
22/// filesystem operation. `errors` covers the remaining case: an entry whose type cannot be
23/// determined, which is rare and genuinely unusable.
24#[derive(Debug, Default)]
25pub struct DirListing {
26    pub entries: Vec<FileInfo>,
27    pub errors: Vec<Report>,
28}
29
30pub trait FileSystem {
31    fn current_directory(&self) -> Result<FileInfo> {
32        self.directory_from_current::<&str>(None)
33    }
34
35    fn directory_from_current<P: AsRef<Path>>(&self, path: Option<P>) -> Result<FileInfo>;
36
37    fn list_files(&self, file: &FileInfo) -> Result<DirListing>;
38
39    fn file_size(&self, file: &FileInfo) -> Result<u64>;
40
41    /// The filesystem this entry lives on, where that is knowable.
42    ///
43    /// [`None`] means the question cannot be answered, which callers treat as "do not
44    /// restrict" rather than as a mount-point crossing.
45    fn device_id(&self, _file: &FileInfo) -> Option<u64> {
46        None
47    }
48}
49
50pub trait FileSystemClean {
51    fn remove_file(&self, file: &FileInfo) -> Result<()>;
52}
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
55pub struct RealFileSystem;
56
57impl FileSystem for RealFileSystem {
58    fn directory_from_current<P: AsRef<Path>>(&self, path: Option<P>) -> Result<FileInfo> {
59        let mut path_buf = std::env::current_dir()?;
60        if let Some(p) = path {
61            path_buf.push(p);
62        }
63
64        Ok(FileInfo::new(
65            path_buf,
66            "".into(),
67            SimpleFileKind::Directory,
68        ))
69    }
70
71    fn list_files(&self, file: &FileInfo) -> Result<DirListing> {
72        let read_dir = fs::read_dir(&file.path)
73            .with_context(|| format!("cannot read directory {}", file.path.display()))?;
74
75        Ok(read_dir.fold(DirListing::default(), |mut listing, entry| {
76            match entry
77                .context("failed to read dir entry")
78                .and_then(|entry| describe_entry(&entry))
79            {
80                Ok(info) => listing.entries.push(info),
81                Err(report) => listing.errors.push(report),
82            }
83            listing
84        }))
85    }
86
87    fn file_size(&self, file: &FileInfo) -> Result<u64> {
88        RealFileSystem::reclaimable_size(&file.path)
89    }
90
91    #[cfg(unix)]
92    fn device_id(&self, file: &FileInfo) -> Option<u64> {
93        fs::symlink_metadata(&file.path).ok().map(|m| m.dev())
94    }
95}
96
97impl RealFileSystem {
98    /// The number of bytes actually freed by deleting `path`.
99    ///
100    /// Symbolic links are never followed. Deleting a link removes only the link, so
101    /// descending into its target would attribute another tree's bytes to this candidate
102    /// and promise space that the clean cannot deliver. Not following them also makes
103    /// link cycles harmless.
104    ///
105    /// On Unix the figure is allocated blocks rather than apparent length, so sparse files
106    /// are not overstated, and each inode is counted once so hard links are not
107    /// double-counted.
108    pub fn reclaimable_size<P>(path: P) -> Result<u64>
109    where
110        P: AsRef<Path>,
111    {
112        let mut seen_inodes = HashSet::new();
113        let mut pending = vec![path.as_ref().to_path_buf()];
114        let mut total = 0;
115
116        while let Some(current) = pending.pop() {
117            let metadata = fs::symlink_metadata(&current)
118                .with_context(|| format!("cannot stat {}", current.display()))?;
119
120            if metadata.is_dir() {
121                let read_dir = fs::read_dir(&current)
122                    .with_context(|| format!("cannot read directory {}", current.display()))?;
123                for entry in read_dir {
124                    pending.push(entry?.path());
125                }
126            }
127
128            if counts_towards_total(&metadata, &mut seen_inodes) {
129                total += allocated_bytes(&metadata);
130            }
131        }
132
133        Ok(total)
134    }
135}
136
137/// Describe one directory entry.
138///
139/// Only the type lookup can fail. The name is decoded lossily rather than validated: rules
140/// match on names, and a name that does not decode cannot match one anyway, so refusing it
141/// would discard a perfectly good entry -- and, before this was split out, its siblings too.
142fn describe_entry(entry: &DirEntry) -> Result<FileInfo> {
143    let file_type = entry
144        .file_type()
145        .with_context(|| format!("cannot determine type of {}", entry.path().display()))?;
146
147    let kind = if file_type.is_symlink() {
148        SimpleFileKind::Symlink
149    } else if file_type.is_dir() {
150        SimpleFileKind::Directory
151    } else {
152        SimpleFileKind::File
153    };
154
155    Ok(FileInfo::new(
156        entry.path(),
157        entry.file_name().to_string_lossy().into_owned(),
158        kind,
159    ))
160}
161
162#[cfg(unix)]
163fn allocated_bytes(metadata: &Metadata) -> u64 {
164    metadata.blocks() * 512
165}
166
167#[cfg(not(unix))]
168fn allocated_bytes(metadata: &Metadata) -> u64 {
169    metadata.len()
170}
171
172/// Whether this entry's bytes have not already been counted through another hard link.
173#[cfg(unix)]
174fn counts_towards_total(metadata: &Metadata, seen_inodes: &mut HashSet<(u64, u64)>) -> bool {
175    metadata.nlink() <= 1 || seen_inodes.insert((metadata.dev(), metadata.ino()))
176}
177
178#[cfg(not(unix))]
179fn counts_towards_total(_metadata: &Metadata, _seen_inodes: &mut HashSet<(u64, u64)>) -> bool {
180    true
181}
182
183impl FileSystemClean for RealFileSystem {
184    fn remove_file(&self, file: &FileInfo) -> Result<()> {
185        match file.kind {
186            SimpleFileKind::Directory => fs::remove_dir_all(&file.path)
187                .with_context(|| format!("cannot remove directory {}", file.path.display())),
188            SimpleFileKind::File => fs::remove_file(&file.path)
189                .with_context(|| format!("cannot remove {}", file.path.display())),
190            SimpleFileKind::Symlink => remove_symlink(&file.path)
191                .with_context(|| format!("cannot remove link {}", file.path.display())),
192        }
193    }
194}
195
196/// Remove a symbolic link itself, never its target.
197#[cfg(not(windows))]
198fn remove_symlink(path: &Path) -> std::io::Result<()> {
199    fs::remove_file(path)
200}
201
202/// Remove a symbolic link itself, never its target.
203///
204/// Windows needs the directory form of the call for a link that points at a directory --
205/// `DeleteFile` fails on one, and `RemoveDirectory` fails on the file form. Both symlinks
206/// and junctions carry `FILE_ATTRIBUTE_DIRECTORY` when they name a directory, so the
207/// attribute decides which call to make. This never recurses, so the target is untouched.
208#[cfg(windows)]
209fn remove_symlink(path: &Path) -> std::io::Result<()> {
210    const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
211
212    if fs::symlink_metadata(path)?.file_attributes() & FILE_ATTRIBUTE_DIRECTORY == 0 {
213        fs::remove_file(path)
214    } else {
215        fs::remove_dir(path)
216    }
217}
218
219#[cfg(test)]
220mod tests;