anvilsign in

collin/browser-terminal-extension

1//! Directories, for the omnibar's path rows.
2//!
3//! The panel is a web page: it cannot stat a directory, and the one question it
4//! has to answer before it can offer anything sensible is whether the path in
5//! the box exists yet. So it asks, one directory at a time, and this module is
6//! what answers — the names inside a directory and whether it is there at all.
7//!
8//! Deliberately narrow. Names only, never contents; one directory per question,
9//! never a walk; and nothing outside the directory that was asked about. The
10//! socket is authenticated and the thing on the other end of it is a terminal,
11//! so this is not a privilege boundary — it is a ceiling on the shape of what
12//! can leave the machine by accident.
13
14use std::path::{Path, PathBuf};
15
16/// The longest path the panel may ask about. Longer than any real one, short
17/// enough that a query cannot be used to push work at the daemon.
18pub const MAX_PATH: usize = 4096;
19
20/// The most names returned per directory, each of dirs and files. A source tree
21/// with more entries than this exists; a *project directory* with more of them
22/// that you would then pick one of by typing does not.
23const MAX_ENTRIES: usize = 300;
24
25/// What is at a path.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Kind {
28 Dir,
29 File,
30 Missing,
31 /// There, or possibly there, and not ours to look at. Deliberately one
32 /// answer rather than two: the panel says "cannot read" either way, and
33 /// distinguishing them would be reporting on a directory we were refused.
34 Denied,
35}
36
37impl Kind {
38 pub fn as_str(self) -> &'static str {
39 match self {
40 Kind::Dir => "dir",
41 Kind::File => "file",
42 Kind::Missing => "missing",
43 Kind::Denied => "denied",
44 }
45 }
46}
47
48/// One directory, as the omnibar needs it.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Listing {
51 /// The expanded path. The panel matches this against the working
52 /// directories tmux reports, which is how a path you already have a session
53 /// on becomes a switch rather than a second session.
54 pub path: PathBuf,
55 pub kind: Kind,
56 /// How many directories `mkdir -p` would have to create to make this one.
57 /// Zero when it is already there. The panel says so before you press Enter,
58 /// because "create one directory" and "create four" are different answers
59 /// to a typo.
60 pub creates: usize,
61 /// Directory names inside it, sorted, visible ones first — the panel
62 /// filters these by what you have typed of the next component.
63 pub dirs: Vec<String>,
64 /// The other names. Only ever used to say "that is a file": a path whose
65 /// last component is one is not somewhere a session can start, and without
66 /// this it would look like something to create.
67 pub files: Vec<String>,
68}
69
70/// `~/Code/foo` as an absolute path, or nothing.
71///
72/// The panel's own rule is that a leading `~` is what makes the box a path at
73/// all, so this is deliberately strict about the rest: absolute or `~`-rooted,
74/// no `.` or `..`, no `~user`. A relative path has no meaning here — there is
75/// no directory for it to be relative *to* until a session exists, which is
76/// the thing being asked for.
77pub fn expand(raw: &str) -> Option<PathBuf> {
78 expand_in(raw, home().as_deref())
79}
80
81/// [`expand`] against a given home, which is the half of it worth testing:
82/// `$HOME` is process-wide, and a test that set it would be setting it for
83/// every other test running beside it.
84fn expand_in(raw: &str, home: Option<&Path>) -> Option<PathBuf> {
85 let s = raw.trim();
86 if s.is_empty() || s.len() > MAX_PATH || s.chars().any(char::is_control) {
87 return None;
88 }
89 let (mut path, rest) = match s.strip_prefix('~') {
90 Some("") => (home?.to_path_buf(), ""),
91 // `~user` is somebody else's home, which is not ours to guess at: what
92 // follows the tilde has to be the separator.
93 Some(rest) => (home?.to_path_buf(), rest.strip_prefix('/')?),
94 None => (PathBuf::from("/"), s.strip_prefix('/')?),
95 };
96 for part in rest.split('/') {
97 // Empty is a doubled or trailing slash, which is nothing.
98 if part.is_empty() {
99 continue;
100 }
101 if part == "." || part == ".." {
102 return None;
103 }
104 path.push(part);
105 }
106 Some(path)
107}
108
109fn home() -> Option<PathBuf> {
110 let home = PathBuf::from(std::env::var_os("HOME")?);
111 home.is_absolute().then_some(home)
112}
113
114/// Answer one question about one directory.
115///
116/// `raw` is what the box holds up to the last `/`, so the entries are the
117/// candidates for the component being typed. Whether the *full* path exists is
118/// the panel's own arithmetic from `dirs` and `files` — one query per level
119/// typed rather than one per keystroke.
120pub fn list(raw: &str) -> Option<Listing> {
121 let mut path = expand(raw)?;
122 let (kind, mut dirs, mut files) = match std::fs::metadata(&path) {
123 Ok(m) if m.is_dir() => match read_names(&path) {
124 Some((d, f)) => (Kind::Dir, d, f),
125 None => (Kind::Denied, Vec::new(), Vec::new()),
126 },
127 Ok(_) => (Kind::File, Vec::new(), Vec::new()),
128 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
129 (Kind::Missing, Vec::new(), Vec::new())
130 }
131 Err(_) => (Kind::Denied, Vec::new(), Vec::new()),
132 };
133 dirs.truncate(MAX_ENTRIES);
134 files.truncate(MAX_ENTRIES);
135 // Symlinks resolved, but only for a directory that is there: this is the
136 // string the panel matches against the working directories tmux reports,
137 // and tmux reports a pane's real one. `~/code` being a link to `~/Code`
138 // would otherwise be a session it could not see it already had.
139 if kind == Kind::Dir {
140 if let Ok(real) = std::fs::canonicalize(&path) {
141 path = real;
142 }
143 }
144 Some(Listing {
145 creates: missing_ancestors(&path),
146 path,
147 kind,
148 dirs,
149 files,
150 })
151}
152
153/// Names in a directory, split into the ones you can descend into and the rest.
154///
155/// Symlinks are followed for the *classification only* — a link to a directory
156/// is offered as one, because that is what typing it would land you in. Nothing
157/// is followed to read through it, and an entry whose target cannot be stat'ed
158/// is filed as a file rather than skipped, so a broken link is visible instead
159/// of quietly missing.
160///
161/// Sorted, and hidden names last: a truncated listing should lose `.cache`
162/// before it loses a project.
163fn read_names(dir: &Path) -> Option<(Vec<String>, Vec<String>)> {
164 let entries = std::fs::read_dir(dir).ok()?;
165 let mut dirs = Vec::new();
166 let mut files = Vec::new();
167 for entry in entries.flatten() {
168 // Not `to_string_lossy`: a name we cannot round-trip is a name the
169 // panel cannot send back, and offering it would be offering a path that
170 // does not exist.
171 let Some(name) = entry.file_name().to_str().map(String::from) else {
172 continue;
173 };
174 let is_dir = entry
175 .file_type()
176 .ok()
177 .map(|t| {
178 if t.is_symlink() {
179 std::fs::metadata(entry.path())
180 .map(|m| m.is_dir())
181 .unwrap_or(false)
182 } else {
183 t.is_dir()
184 }
185 })
186 .unwrap_or(false);
187 if is_dir { &mut dirs } else { &mut files }.push(name);
188 }
189 for list in [&mut dirs, &mut files] {
190 list.sort_by(|a, b| {
191 let hidden = |s: &String| s.starts_with('.');
192 hidden(a).cmp(&hidden(b)).then_with(|| a.cmp(b))
193 });
194 }
195 Some((dirs, files))
196}
197
198/// How many components of `path` do not exist yet.
199fn missing_ancestors(path: &Path) -> usize {
200 let mut n = 0;
201 for a in path.ancestors() {
202 // `exists()` is false for a path we are not allowed to stat, which
203 // would overcount. It is a number in a row's subtitle, and the mkdir
204 // that follows reports its own failure.
205 if a.exists() {
206 break;
207 }
208 n += 1;
209 }
210 n
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 const HOME: &str = "/home/someone";
218
219 fn home() -> Option<&'static Path> {
220 Some(Path::new(HOME))
221 }
222
223 #[test]
224 fn expands_tilde_and_absolute_only() {
225 let h = PathBuf::from(HOME);
226 assert_eq!(expand_in("~", home()), Some(h.clone()));
227 assert_eq!(expand_in("~/Code", home()), Some(h.join("Code")));
228 assert_eq!(expand_in("~/Code/", home()), Some(h.join("Code")));
229 assert_eq!(expand_in("~/Code//foo", home()), Some(h.join("Code/foo")));
230 assert_eq!(
231 expand_in("/etc/ssh", home()),
232 Some(PathBuf::from("/etc/ssh"))
233 );
234 }
235
236 #[test]
237 fn refuses_anything_that_is_not_a_rooted_path() {
238 for bad in [
239 "",
240 "Code", // relative
241 "./Code", // relative
242 "~/Code/../../..", // traversal
243 "~/..",
244 "/etc/../root",
245 "~root/x", // another user's home is not ours to expand
246 "~/a\nb", // control characters
247 ] {
248 assert_eq!(expand_in(bad, home()), None, "{bad} should not expand");
249 }
250 assert_eq!(
251 expand_in(&format!("~/{}", "a".repeat(MAX_PATH)), home()),
252 None
253 );
254 // No home, no tilde. An absolute path is still fine.
255 assert_eq!(expand_in("~/Code", None), None);
256 assert!(expand_in("/tmp", None).is_some());
257 }
258
259 #[test]
260 fn lists_a_directory_and_says_what_is_missing() {
261 let tmp = std::env::temp_dir().join(format!("tb-project-list-{}", std::process::id()));
262 let _ = std::fs::remove_dir_all(&tmp);
263 std::fs::create_dir_all(tmp.join("alpha")).unwrap();
264 std::fs::create_dir_all(tmp.join(".hidden")).unwrap();
265 std::fs::write(tmp.join("notes.txt"), "hi").unwrap();
266 let at = |p: &Path| list(&p.to_string_lossy()).expect("absolute paths expand");
267
268 let l = at(&tmp);
269 assert_eq!(l.kind, Kind::Dir);
270 assert_eq!(l.creates, 0);
271 // Visible first, hidden after it.
272 assert_eq!(l.dirs, ["alpha", ".hidden"]);
273 assert_eq!(l.files, ["notes.txt"]);
274
275 assert_eq!(at(&tmp.join("alpha")).kind, Kind::Dir);
276 assert_eq!(at(&tmp.join("notes.txt")).kind, Kind::File);
277
278 // Three levels of nothing is three directories to make.
279 let l = at(&tmp.join("a/b/c"));
280 assert_eq!(l.kind, Kind::Missing);
281 assert_eq!(l.creates, 3);
282 assert!(l.dirs.is_empty());
283
284 let _ = std::fs::remove_dir_all(&tmp);
285 }
286}