endbasic_std/storage/
fs.rs

1// EndBASIC
2// Copyright 2021 Julio Merino
3//
4// Licensed under the Apache License, Version 2.0 (the "License"); you may not
5// use this file except in compliance with the License.  You may obtain a copy
6// of the License at:
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
13// License for the specific language governing permissions and limitations
14// under the License.
15
16//! File system-based implementation of the storage system.
17
18use crate::storage::{Drive, DriveFactory, DriveFiles, Metadata};
19use async_trait::async_trait;
20use std::collections::BTreeMap;
21use std::fs::{self, File, OpenOptions};
22use std::io::{self, Read, Write};
23use std::path::PathBuf;
24use std::str;
25
26/// A drive that is backed by an on-disk directory.
27pub struct DirectoryDrive {
28    /// Path to the directory containing all entries backed by this drive.  The directory may
29    /// contain files that are not EndBASIC programs, and that's OK, but those files will not be
30    /// accessible through this interface.
31    dir: PathBuf,
32}
33
34impl DirectoryDrive {
35    /// Creates a new drive backed by the `dir` directory.
36    pub fn new<P: Into<PathBuf>>(dir: P) -> io::Result<Self> {
37        let dir = dir.into();
38
39        // Obtain the canonical path to the underlying directory, which we need for system_path to
40        // make sense.  Unfortunately, we must ensure the directory exists in order to do this.
41        let dir = match dir.canonicalize() {
42            Ok(dir) => dir,
43            Err(e) if e.kind() == io::ErrorKind::NotFound => {
44                fs::create_dir_all(&dir)?;
45                dir.canonicalize()?
46            }
47            Err(e) => return Err(e),
48        };
49
50        Ok(Self { dir })
51    }
52}
53
54#[async_trait(?Send)]
55impl Drive for DirectoryDrive {
56    async fn delete(&mut self, name: &str) -> io::Result<()> {
57        let path = self.dir.join(name);
58        fs::remove_file(path)
59    }
60
61    async fn enumerate(&self) -> io::Result<DriveFiles> {
62        let mut entries = BTreeMap::default();
63        match fs::read_dir(&self.dir) {
64            Ok(dirents) => {
65                for de in dirents {
66                    let de = de?;
67
68                    let file_type = de.file_type()?;
69                    if !file_type.is_file() && !file_type.is_symlink() {
70                        // Silently ignore entries we cannot handle.
71                        continue;
72                    }
73
74                    // This follows symlinks for cross-platform simplicity, but it is ugly.  I don't
75                    // expect symlinks in the programs directory anyway.  If we want to handle this
76                    // better, we'll have to add a way to report file types.
77                    let metadata = fs::metadata(de.path())?;
78                    let offset = match time::UtcOffset::current_local_offset() {
79                        Ok(offset) => offset,
80                        Err(_) => time::UtcOffset::UTC,
81                    };
82                    let date = time::OffsetDateTime::from(metadata.modified()?).to_offset(offset);
83                    let length = metadata.len();
84
85                    entries.insert(
86                        de.file_name().to_string_lossy().to_string(),
87                        Metadata { date, length },
88                    );
89                }
90            }
91            Err(e) => {
92                if e.kind() != io::ErrorKind::NotFound {
93                    return Err(e);
94                }
95            }
96        }
97        // TODO(jmmv): Calculate total and free disk space.
98        Ok(DriveFiles::new(entries, None, None))
99    }
100
101    async fn get(&self, name: &str) -> io::Result<String> {
102        let path = self.dir.join(name);
103        let input = File::open(path)?;
104        let mut content = String::new();
105        io::BufReader::new(input).read_to_string(&mut content)?;
106        Ok(content)
107    }
108
109    async fn put(&mut self, name: &str, content: &str) -> io::Result<()> {
110        let path = self.dir.join(name);
111        let output = OpenOptions::new().create(true).write(true).truncate(true).open(path)?;
112        let mut writer = io::BufWriter::new(output);
113        writer.write_all(content.as_bytes())
114    }
115
116    fn system_path(&self, name: &str) -> Option<PathBuf> {
117        Some(self.dir.join(name))
118    }
119}
120
121/// Factory for directory-backed drives.
122#[derive(Default)]
123pub struct DirectoryDriveFactory {}
124
125impl DriveFactory for DirectoryDriveFactory {
126    fn create(&self, target: &str) -> io::Result<Box<dyn Drive>> {
127        if !target.is_empty() {
128            Ok(Box::from(DirectoryDrive::new(target)?))
129        } else {
130            Err(io::Error::new(
131                io::ErrorKind::InvalidInput,
132                "Must specify a directory to mount a disk-backed drive",
133            ))
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use futures_lite::future::block_on;
142    use std::fs;
143    use std::io::{BufRead, Write};
144    use std::path::Path;
145
146    /// Reads `path` and checks that its contents match `exp_lines`.
147    fn check_file(path: &Path, exp_lines: &[&str]) {
148        let file = File::open(path).unwrap();
149        let reader = io::BufReader::new(file);
150        let mut lines = vec![];
151        for line in reader.lines() {
152            lines.push(line.unwrap());
153        }
154        assert_eq!(exp_lines, lines.as_slice());
155    }
156
157    /// Creates `path` with the contents in `lines` and with a deterministic modification time.
158    fn write_file(path: &Path, lines: &[&str]) {
159        let mut file = OpenOptions::new()
160            .create(true)
161            .truncate(false) // Should not be creating the same file more than once.
162            .write(true)
163            .open(path)
164            .unwrap();
165        for line in lines {
166            file.write_fmt(format_args!("{}\n", line)).unwrap();
167        }
168        drop(file);
169
170        filetime::set_file_mtime(path, filetime::FileTime::from_unix_time(1_588_757_875, 0))
171            .unwrap();
172    }
173
174    #[test]
175    fn test_directorydrive_delete_ok() {
176        let dir = tempfile::tempdir().unwrap();
177        write_file(&dir.path().join("a.bas"), &[]);
178        write_file(&dir.path().join("a.bat"), &[]);
179
180        let mut drive = DirectoryDrive::new(dir.path()).unwrap();
181        block_on(drive.delete("a.bas")).unwrap();
182        assert!(!dir.path().join("a.bas").exists());
183        assert!(dir.path().join("a.bat").exists());
184    }
185
186    #[test]
187    fn test_directorydrive_delete_missing_file() {
188        let dir = tempfile::tempdir().unwrap();
189        let mut drive = DirectoryDrive::new(dir.path()).unwrap();
190        assert_eq!(io::ErrorKind::NotFound, block_on(drive.delete("a.bas")).unwrap_err().kind());
191    }
192
193    #[test]
194    fn test_directorydrive_enumerate_nothing() {
195        let dir = tempfile::tempdir().unwrap();
196
197        let drive = DirectoryDrive::new(dir.path()).unwrap();
198        assert!(block_on(drive.enumerate()).unwrap().dirents().is_empty());
199    }
200
201    #[test]
202    fn test_directorydrive_enumerate_some_files() {
203        let dir = tempfile::tempdir().unwrap();
204        write_file(&dir.path().join("empty.bas"), &[]);
205        write_file(&dir.path().join("some file.bas"), &["this is not empty"]);
206
207        let drive = DirectoryDrive::new(dir.path()).unwrap();
208        let files = block_on(drive.enumerate()).unwrap();
209        assert_eq!(2, files.dirents().len());
210        let date = time::OffsetDateTime::from_unix_timestamp(1_588_757_875).unwrap();
211        assert_eq!(&Metadata { date, length: 0 }, files.dirents().get("empty.bas").unwrap());
212        assert_eq!(&Metadata { date, length: 18 }, files.dirents().get("some file.bas").unwrap());
213    }
214
215    #[test]
216    fn test_directorydrive_enumerate_treats_missing_dir_as_empty() {
217        let dir = tempfile::tempdir().unwrap();
218        let drive = DirectoryDrive::new(dir.path().join("does-not-exist")).unwrap();
219        assert!(block_on(drive.enumerate()).unwrap().dirents().is_empty());
220    }
221
222    #[test]
223    fn test_directorydrive_enumerate_ignores_non_files() {
224        let dir = tempfile::tempdir().unwrap();
225        fs::create_dir(dir.path().join("will-be-ignored")).unwrap();
226        let drive = DirectoryDrive::new(dir.path()).unwrap();
227        assert!(block_on(drive.enumerate()).unwrap().dirents().is_empty());
228    }
229
230    #[cfg(not(target_os = "windows"))]
231    #[test]
232    fn test_directorydrive_enumerate_follows_symlinks() {
233        use std::os::unix::fs as unix_fs;
234
235        let dir = tempfile::tempdir().unwrap();
236        write_file(&dir.path().join("some file.bas"), &["this is not empty"]);
237        unix_fs::symlink(Path::new("some file.bas"), dir.path().join("a link.bas")).unwrap();
238
239        let drive = DirectoryDrive::new(dir.path()).unwrap();
240        let files = block_on(drive.enumerate()).unwrap();
241        assert_eq!(2, files.dirents().len());
242        let metadata = Metadata {
243            date: time::OffsetDateTime::from_unix_timestamp(1_588_757_875).unwrap(),
244            length: 18,
245        };
246        assert_eq!(&metadata, files.dirents().get("some file.bas").unwrap());
247        assert_eq!(&metadata, files.dirents().get("a link.bas").unwrap());
248    }
249
250    #[test]
251    fn test_directorydrive_enumerate_fails_on_non_directory() {
252        let dir = tempfile::tempdir().unwrap();
253        let file = dir.path().join("not-a-dir");
254        write_file(&file, &[]);
255        let drive = DirectoryDrive::new(&file).unwrap();
256        // TODO(jmmv): Check for the specific error that's returned.  We used to check against
257        // `Other` but Rust 1.55 started returning `NotADirectory` instead -- and unfortunately
258        // using the latter relies on an unstable feature.  So addressing this is non-trivial
259        // right now, but will be over time.
260        block_on(drive.enumerate()).unwrap_err();
261    }
262
263    #[test]
264    fn test_directorydrive_get() {
265        let dir = tempfile::tempdir().unwrap();
266        write_file(&dir.path().join("some file.bas"), &["one line", "two lines"]);
267
268        let drive = DirectoryDrive::new(dir.path()).unwrap();
269        assert_eq!("one line\ntwo lines\n", block_on(drive.get("some file.bas")).unwrap());
270    }
271
272    #[test]
273    fn test_directorydrive_put() {
274        let dir = tempfile::tempdir().unwrap();
275
276        let mut drive = DirectoryDrive::new(dir.path()).unwrap();
277        block_on(drive.put("some file.bas", "a b c\nd e\n")).unwrap();
278        check_file(&dir.path().join("some file.bas"), &["a b c", "d e"]);
279    }
280
281    #[test]
282    fn test_directorydrive_system_path() {
283        let dir = tempfile::tempdir().unwrap();
284
285        let drive = DirectoryDrive::new(dir.path()).unwrap();
286        assert_eq!(
287            dir.path().canonicalize().unwrap().join("foo"),
288            drive.system_path("foo").unwrap()
289        );
290    }
291}