notnow/
paths.rs

1// Copyright (C) 2024 Daniel Mueller <deso@posteo.net>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use std::borrow::Cow;
5use std::env::current_dir;
6use std::ffi::OsStr;
7use std::fs::canonicalize;
8use std::io;
9use std::io::ErrorKind;
10use std::os::unix::ffi::OsStrExt as _;
11use std::path::Component;
12use std::path::Path;
13use std::path::PathBuf;
14
15use anyhow::anyhow;
16use anyhow::Context as _;
17use anyhow::Result;
18
19use dirs::cache_dir;
20use dirs::config_dir;
21
22
23/// Normalize a path, removing current and parent directory components
24/// (if possible).
25// Compared to Cargo's "reference" implementation
26// https://github.com/rust-lang/cargo/blob/fede83ccf973457de319ba6fa0e36ead454d2e20/src/cargo/util/paths.rs#L61
27// we correctly handle something like '../x' (by leaving it alone). On
28// the downside, we can end up with '..' components unresolved, if they
29// are at the beginning of the path.
30fn normalize(path: &Path) -> PathBuf {
31  let components = path.components();
32  let path = PathBuf::with_capacity(path.as_os_str().len());
33
34  let mut path = components.fold(path, |mut path, component| {
35    match component {
36      Component::Prefix(..) | Component::RootDir => (),
37      Component::CurDir => return path,
38      Component::ParentDir => {
39        if let Some(prev) = path.components().next_back() {
40          match prev {
41            Component::CurDir => {
42              // SANITY: We can never have a current directory component
43              //         inside `path` because we never added one to
44              //         begin with.
45              unreachable!()
46            },
47            Component::Prefix(..) | Component::RootDir | Component::ParentDir => (),
48            Component::Normal(..) => {
49              path.pop();
50              return path
51            },
52          }
53        }
54      },
55      Component::Normal(c) => {
56        path.push(c);
57        return path
58      },
59    }
60
61    path.push(component.as_os_str());
62    path
63  });
64
65  let () = path.shrink_to_fit();
66  path
67}
68
69
70/// Perform best-effort canonicalization on the provided path.
71///
72/// Path components that do not exist do not cause the function to fail
73/// and will be included in the result, with only normalization
74/// performed on them.
75fn canonicalize_non_strict(path: &Path) -> io::Result<PathBuf> {
76  let mut path = path;
77  let input = path;
78
79  let resolved = loop {
80    match canonicalize(path) {
81      Ok(resolved) => break Cow::Owned(resolved),
82      Err(err) if err.kind() == ErrorKind::NotFound => (),
83      e => return e,
84    }
85
86    match path.parent() {
87      None => {
88        // We have reached the root. No point in attempting to
89        // canonicalize further. We are done.
90        path = Path::new("");
91        break Cow::Borrowed(path)
92      },
93      Some(parent) if parent == Path::new("") => {
94        // `path` is a relative path with a single component, so resolve
95        // it to the current directory.
96        path = parent;
97        break Cow::Owned(current_dir()?)
98      },
99      Some(parent) => {
100        // We need a bit of a dance here in order to get the parent path
101        // but including the trailing path separator. That's necessary
102        // for our path "subtraction" below to work correctly.
103        let parent_len = parent.as_os_str().as_bytes().len();
104        let path_bytes = path.as_os_str().as_bytes();
105        // SANITY: We know that `path` has a parent (a true substring).
106        //         Given that we are dealing with paths, we also know
107        //         that a trailing path separator *must* exist, meaning
108        //         we will always be in bounds.
109        path = Path::new(OsStr::from_bytes(
110          path_bytes
111            .get(parent_len + 1..)
112            .expect("constructed path has no trailing separator"),
113        ));
114      },
115    }
116  };
117
118  let input_bytes = input.as_os_str().as_bytes();
119  let path_len = path.as_os_str().as_bytes().len();
120  // SANITY: We know that `path` is a substring of `input` and so we can
121  //         never be out-of-bounds here.
122  let unresolved = input_bytes
123    .get(path_len..)
124    .expect("failed to access input path sub-string");
125  let complete = resolved.join(OsStr::from_bytes(unresolved));
126  // We need to make sure to normalize the result here, because while
127  // the unresolved part does not actually exist on the file system, it
128  // could still contain symbolic references to the current or parent
129  // directory that we do not want in the result.
130  let normalized = normalize(&complete);
131  Ok(normalized)
132}
133
134
135/// A type taking care of the program's path handling needs.
136#[derive(Debug)]
137pub struct Paths {
138  /// The path to the configuration directory.
139  ///
140  /// This path will always be normalized.
141  config_dir: PathBuf,
142  /// The path to the directory containing "ephemeral" state.
143  state_dir: PathBuf,
144}
145
146impl Paths {
147  /// Instantiate a new `Paths` object, optionally using `config_dir` as
148  /// the directory storing configuration data (including tasks).
149  pub fn new(config_dir: Option<PathBuf>) -> Result<Self> {
150    let config_dir = if let Some(config_dir) = config_dir {
151      config_dir
152    } else {
153      self::config_dir()
154        .ok_or_else(|| anyhow!("unable to determine config directory"))?
155        .join("notnow")
156    };
157    let config_dir = canonicalize_non_strict(&config_dir)
158      .with_context(|| format!("failed to canonicalize path `{}`", config_dir.display()))?;
159
160    let mut config_dir_rel = config_dir.components();
161    let _root = config_dir_rel.next();
162    let config_dir_rel = config_dir_rel.as_path();
163    debug_assert!(config_dir_rel.is_relative(), "{config_dir_rel:?}");
164
165    let state_dir = cache_dir()
166      .ok_or_else(|| anyhow!("unable to determine cache directory"))?
167      .join("notnow")
168      .join(config_dir_rel);
169
170    let slf = Self {
171      config_dir,
172      state_dir,
173    };
174    Ok(slf)
175  }
176
177  /// Retrieve the path to the program's configuration directory.
178  pub fn ui_config_dir(&self) -> &Path {
179    &self.config_dir
180  }
181
182  /// Retrieve the file name of the program's UI configuration.
183  pub fn ui_config_file(&self) -> &OsStr {
184    OsStr::new("notnow.json")
185  }
186
187  /// Retrieve the path to the program's task directory.
188  pub fn tasks_dir(&self) -> PathBuf {
189    self.ui_config_dir().join("tasks")
190  }
191
192  /// Retrieve the path to the program's "volatile" UI state directory.
193  pub fn ui_state_dir(&self) -> &Path {
194    &self.state_dir
195  }
196
197  /// Retrieve the file name of the program's "volatile" UI state.
198  pub fn ui_state_file(&self) -> &OsStr {
199    OsStr::new("ui-state.json")
200  }
201
202  /// Retrieve the path to the program's lock file.
203  pub(crate) fn lock_file(&self) -> PathBuf {
204    self.state_dir.join("notnow.lock")
205  }
206}
207
208
209#[cfg(test)]
210mod tests {
211  use super::*;
212
213  use tempfile::TempDir;
214
215
216  /// Check that we can normalize paths as expected.
217  #[test]
218  fn path_normalization() {
219    assert_eq!(normalize(Path::new("tmp/foobar/..")), Path::new("tmp"));
220    assert_eq!(normalize(Path::new("/tmp/foobar/..")), Path::new("/tmp"));
221    assert_eq!(normalize(Path::new("/tmp/.")), Path::new("/tmp"));
222    assert_eq!(normalize(Path::new("/tmp/./blah")), Path::new("/tmp/blah"));
223    assert_eq!(normalize(Path::new("/tmp/../blah")), Path::new("/blah"));
224    assert_eq!(normalize(Path::new("./foo")), Path::new("foo"));
225    assert_eq!(
226      normalize(Path::new("./foo/")).as_os_str(),
227      Path::new("foo").as_os_str()
228    );
229    assert_eq!(normalize(Path::new("foo")), Path::new("foo"));
230    assert_eq!(
231      normalize(Path::new("foo/")).as_os_str(),
232      Path::new("foo").as_os_str()
233    );
234    assert_eq!(normalize(Path::new("../foo")), Path::new("../foo"));
235    assert_eq!(normalize(Path::new("../foo/")), Path::new("../foo"));
236    assert_eq!(
237      normalize(Path::new("./././relative-dir-that-does-not-exist/../file")),
238      Path::new("file")
239    );
240  }
241
242  /// Test that we can canonicalize paths on a best-effort basis.
243  #[test]
244  fn non_strict_canonicalization() {
245    let dir = current_dir().unwrap();
246    let path = Path::new("relative-path-that-does-not-exist");
247    let real = canonicalize_non_strict(path).unwrap();
248    assert_eq!(real, dir.join(path));
249
250    let dir = current_dir().unwrap();
251    let path = Path::new("relative-path-that-does-not-exist/");
252    let real = canonicalize_non_strict(path).unwrap();
253    assert_eq!(
254      real.as_os_str(),
255      dir
256        .join(Path::new("relative-path-that-does-not-exist"))
257        .as_os_str()
258    );
259
260    let path = Path::new("relative-dir-that-does-not-exist/file");
261    let real = canonicalize_non_strict(path).unwrap();
262    assert_eq!(real, dir.join(path));
263
264    let path = Path::new("./relative-dir-that-does-not-exist/file");
265    let real = canonicalize_non_strict(path).unwrap();
266    assert_eq!(real, dir.join(normalize(path)));
267
268    let path = Path::new("./././relative-dir-that-does-not-exist/../file");
269    let real = canonicalize_non_strict(path).unwrap();
270    assert_eq!(real, dir.join("file"));
271
272    let path = Path::new("../relative-path-that-does-not-exist");
273    let real = canonicalize_non_strict(path).unwrap();
274    assert_eq!(
275      real,
276      dir
277        .parent()
278        .unwrap()
279        .join("relative-path-that-does-not-exist")
280    );
281
282    let path = Path::new("../relative-dir-that-does-not-exist/file");
283    let real = canonicalize_non_strict(path).unwrap();
284    assert_eq!(
285      real,
286      dir
287        .parent()
288        .unwrap()
289        .join("relative-dir-that-does-not-exist/file")
290    );
291
292    let path = Path::new("/absolute-path-that-does-not-exist");
293    let real = canonicalize_non_strict(path).unwrap();
294    assert_eq!(real, path);
295
296    let path = Path::new("/absolute-dir-that-does-not-exist/file");
297    let real = canonicalize_non_strict(path).unwrap();
298    assert_eq!(real, path);
299
300    let dir = TempDir::new().unwrap();
301    let dir = dir.path();
302
303    let path = dir;
304    let real = canonicalize_non_strict(path).unwrap();
305    assert_eq!(real, path);
306
307    let path = dir.join("foobar");
308    let real = canonicalize_non_strict(&path).unwrap();
309    assert_eq!(real, path);
310  }
311
312  /// Make sure that we can instantiate a `Paths` object properly.
313  #[test]
314  fn paths_instantiation() {
315    let _paths = Paths::new(None).unwrap();
316
317    let dir = TempDir::new().unwrap();
318    let path = dir.path().join("i").join("do").join("not").join("exist");
319    let _paths = Paths::new(Some(path)).unwrap();
320  }
321}