Line data Source code
1 : //! Reads the scripts named on the command line
2 :
3 : use crate::lint::Input;
4 : use std::collections::HashSet;
5 : use std::io::Read;
6 : use std::path::{Component, Path, PathBuf};
7 :
8 : /// The path that reads a script from stdin
9 : pub const STDIN: &str = "-";
10 :
11 : /// Catalog files that sessions keep next to their scripts, they describe the connection and are not meant to run.
12 : /// `dashql-functions.sql` and `dashql-relations.sql` are their names in older versions.
13 : const GENERATED_FILES: [&str; 5] = [
14 : "dashql-catalog-changes.sql",
15 : "dashql-catalog-functions.sql",
16 : "dashql-catalog-relations.sql",
17 : "dashql-functions.sql",
18 : "dashql-relations.sql",
19 : ];
20 :
21 : /// The directory the user invoked the CLI from. Under `bazel run` the process runs in the runfiles tree.
22 0 : pub fn working_directory() -> std::io::Result<PathBuf> {
23 0 : match std::env::var_os("BUILD_WORKING_DIRECTORY") {
24 0 : Some(dir) => Ok(dir.into()),
25 0 : None => std::env::current_dir(),
26 : }
27 0 : }
28 :
29 : /// Remove `.` and `..` components without touching the file system
30 50 : fn normalize(path: &Path) -> PathBuf {
31 50 : let mut out = PathBuf::new();
32 642 : for component in path.components() {
33 642 : match component {
34 0 : Component::CurDir => {}
35 3 : Component::ParentDir => {
36 3 : out.pop();
37 3 : }
38 639 : c => out.push(c),
39 : }
40 : }
41 50 : out
42 50 : }
43 :
44 : /// Paths relative to the invoking directory, absolute ones for files outside of it
45 54 : fn display_path(path: &Path, cwd: &Path) -> String {
46 54 : match path.strip_prefix(cwd) {
47 52 : Ok(rel) if !rel.as_os_str().is_empty() => rel.display().to_string(),
48 3 : _ => path.display().to_string(),
49 : }
50 54 : }
51 :
52 : /// The *.sql files under a directory, in file name order
53 14 : fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
54 14 : walk_below(dir, &mut Vec::new(), out)
55 14 : }
56 :
57 : /// Walk a directory that `parents`, the real paths of the directories being walked, may already contain.
58 : /// A link back to one of them would repeat the scripts on every level, and with two such links never end.
59 26 : fn walk_below(dir: &Path, parents: &mut Vec<PathBuf>, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
60 26 : let real = std::fs::canonicalize(dir)?;
61 26 : if parents.contains(&real) {
62 3 : return Ok(());
63 23 : }
64 23 : parents.push(real);
65 23 : let mut entries = std::fs::read_dir(dir)?.collect::<std::io::Result<Vec<_>>>()?;
66 82 : entries.sort_by_key(|e| e.file_name());
67 46 : for entry in entries {
68 46 : let path = entry.path();
69 46 : let name = entry.file_name();
70 46 : let name = name.to_string_lossy();
71 : // Follow symlinks, SQL libraries are often linked into place
72 46 : let Ok(info) = std::fs::metadata(&path) else { continue };
73 45 : if info.is_dir() {
74 14 : if name != "node_modules" && !name.starts_with('.') {
75 12 : walk_below(&path, parents, out)?;
76 2 : }
77 31 : } else if info.is_file() && name.to_lowercase().ends_with(".sql") && !GENERATED_FILES.contains(&&*name) {
78 21 : out.push(path);
79 21 : }
80 : }
81 21 : parents.pop();
82 21 : Ok(())
83 26 : }
84 :
85 104 : fn with_path<T>(path: &Path, result: std::io::Result<T>) -> Result<T, String> {
86 104 : result.map_err(|e| format!("{}: {e}", path.display()))
87 104 : }
88 :
89 : /// Read SQL scripts from files, directories (recursively, *.sql only, without generated catalog files) and stdin
90 : /// (`-`). Scripts are identified by their path relative to the invoking directory, each is read once.
91 50 : pub fn read_inputs(paths: &[String], cwd: &Path, stdin: &mut dyn Read) -> Result<Vec<Input>, String> {
92 50 : let mut inputs = Vec::new();
93 50 : let mut seen = HashSet::new();
94 54 : for path in paths {
95 54 : if path == STDIN {
96 5 : let mut bytes = Vec::new();
97 5 : stdin.read_to_end(&mut bytes).map_err(|e| format!("stdin: {e}"))?;
98 4 : if seen.insert("<stdin>".to_string()) {
99 3 : inputs.push(Input { id: "<stdin>".into(), text: String::from_utf8_lossy(&bytes).into_owned() });
100 3 : }
101 4 : continue;
102 49 : }
103 49 : let root = normalize(&cwd.join(path));
104 49 : let mut files = Vec::new();
105 49 : if with_path(&root, std::fs::metadata(&root))?.is_dir() {
106 13 : with_path(&root, walk(&root, &mut files))?;
107 31 : } else {
108 31 : files.push(root);
109 31 : }
110 48 : for file in files {
111 48 : let id = display_path(&file, cwd);
112 48 : if seen.insert(id.clone()) {
113 42 : let bytes = with_path(&file, std::fs::read(&file))?;
114 41 : inputs.push(Input { id, text: String::from_utf8_lossy(&bytes).into_owned() });
115 6 : }
116 : }
117 : }
118 42 : Ok(inputs)
119 50 : }
120 :
121 : #[cfg(test)]
122 : mod tests {
123 : use super::*;
124 : use crate::agent::config::temp_dir;
125 :
126 : #[test]
127 1 : fn normalizes_paths_lexically() {
128 1 : assert_eq!(normalize(Path::new("/a/./b/../c.sql")), Path::new("/a/c.sql"));
129 1 : assert_eq!(display_path(Path::new("/a/b/c.sql"), Path::new("/a")), "b/c.sql");
130 1 : assert_eq!(display_path(Path::new("/x/c.sql"), Path::new("/a")), "/x/c.sql");
131 1 : assert_eq!(display_path(Path::new("/a"), Path::new("/a")), "/a");
132 1 : }
133 :
134 : #[test]
135 1 : fn walks_sql_files_in_name_order() {
136 1 : let root = PathBuf::from(std::env::var_os("TEST_TMPDIR").unwrap_or_else(|| std::env::temp_dir().into()))
137 1 : .join("dashql_cli_walk");
138 1 : let _ = std::fs::remove_dir_all(&root);
139 9 : for file in [
140 1 : "b.sql",
141 1 : "a.SQL",
142 1 : "notes.txt",
143 1 : "dashql-relations.sql",
144 1 : "dashql-catalog-relations.sql",
145 1 : "dashql-catalog-changes.sql",
146 1 : "sub/c.sql",
147 1 : ".hidden/d.sql",
148 1 : "node_modules/e.sql",
149 9 : ] {
150 9 : let path = root.join(file);
151 9 : std::fs::create_dir_all(path.parent().unwrap()).unwrap();
152 9 : std::fs::write(path, "select 1;").unwrap();
153 9 : }
154 1 : let mut files = Vec::new();
155 1 : walk(&root, &mut files).unwrap();
156 3 : let files: Vec<_> = files.iter().map(|f| display_path(f, &root)).collect();
157 1 : assert_eq!(files, ["a.SQL", "b.sql", "sub/c.sql"]);
158 1 : std::fs::remove_dir_all(&root).unwrap();
159 1 : }
160 :
161 : /// A directory with the given files, each file holds its own name
162 16 : fn files(names: &[&str]) -> PathBuf {
163 16 : let root = temp_dir("dashql-cli-inputs");
164 26 : for name in names {
165 26 : let path = root.join(name);
166 26 : std::fs::create_dir_all(path.parent().unwrap()).unwrap();
167 26 : std::fs::write(path, name).unwrap();
168 26 : }
169 16 : root
170 16 : }
171 :
172 18 : fn read(cwd: &Path, paths: &[&str], stdin: &[u8]) -> Result<Vec<Input>, String> {
173 32 : let paths: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
174 18 : read_inputs(&paths, cwd, &mut &*stdin)
175 18 : }
176 :
177 12 : fn ids(inputs: &[Input]) -> Vec<&str> {
178 23 : inputs.iter().map(|i| i.id.as_str()).collect()
179 12 : }
180 :
181 : #[test]
182 1 : fn names_scripts_relative_to_the_invoking_directory() {
183 1 : let root = files(&["work/a.sql", "work/sub/b.sql", "outside/c.sql"]);
184 1 : let cwd = root.join("work");
185 1 : let inputs = read(&cwd, &["a.sql", "./sub/b.sql", "../outside/c.sql"], b"").unwrap();
186 1 : let outside = root.join("outside/c.sql").display().to_string();
187 1 : assert_eq!(ids(&inputs), ["a.sql", "sub/b.sql", outside.as_str()]);
188 1 : assert_eq!(inputs[0].text, "work/a.sql");
189 1 : assert_eq!(inputs[1].text, "work/sub/b.sql");
190 1 : }
191 :
192 : #[test]
193 1 : fn reads_absolute_paths_and_names_them_relative_when_they_are_inside() {
194 1 : let root = files(&["a.sql"]);
195 1 : let absolute = root.join("a.sql").display().to_string();
196 1 : assert_eq!(ids(&read(&root, &[&absolute], b"").unwrap()), ["a.sql"]);
197 1 : }
198 :
199 : #[test]
200 1 : fn reads_every_script_once() {
201 1 : let root = files(&["a.sql", "sub/b.sql"]);
202 1 : let absolute = root.join("a.sql").display().to_string();
203 1 : let inputs = read(&root, &["a.sql", "./a.sql", &absolute, "sub/../a.sql", ".", "sub", "sub/b.sql"], b"").unwrap();
204 1 : assert_eq!(ids(&inputs), ["a.sql", "sub/b.sql"]);
205 1 : }
206 :
207 : #[test]
208 1 : fn keeps_the_order_of_the_arguments() {
209 1 : let root = files(&["a.sql", "b.sql", "sub/c.sql"]);
210 1 : assert_eq!(ids(&read(&root, &["sub", "b.sql", "a.sql"], b"").unwrap()), ["sub/c.sql", "b.sql", "a.sql"]);
211 1 : }
212 :
213 : #[test]
214 1 : fn reads_named_files_of_any_kind_but_filters_directories() {
215 1 : let root = files(&["notes.txt", "dashql-relations.sql", "a.sql"]);
216 1 : assert_eq!(ids(&read(&root, &["notes.txt", "dashql-relations.sql"], b"").unwrap()), ["notes.txt", "dashql-relations.sql"]);
217 1 : assert_eq!(ids(&read(&root, &["."], b"").unwrap()), ["a.sql"]);
218 1 : }
219 :
220 : #[test]
221 1 : fn finds_nothing_in_a_directory_without_scripts() {
222 1 : let root = files(&["notes.txt"]);
223 1 : assert_eq!(read(&root, &["."], b"").unwrap(), vec![]);
224 1 : }
225 :
226 : #[test]
227 1 : fn reads_stdin_once() {
228 1 : let root = files(&["a.sql"]);
229 1 : let inputs = read(&root, &["-", "a.sql", "-"], b"select 1;").unwrap();
230 1 : assert_eq!(ids(&inputs), ["<stdin>", "a.sql"]);
231 1 : assert_eq!(inputs[0].text, "select 1;");
232 1 : }
233 :
234 : #[test]
235 1 : fn replaces_invalid_utf8() {
236 1 : let root = files(&[]);
237 1 : std::fs::write(root.join("a.sql"), b"select '\xff';").unwrap();
238 1 : let inputs = read(&root, &["a.sql", "-"], b"select '\xfe';").unwrap();
239 1 : assert_eq!(inputs[0].text, "select '\u{fffd}';");
240 1 : assert_eq!(inputs[1].text, "select '\u{fffd}';");
241 1 : }
242 :
243 : #[test]
244 1 : fn fails_when_stdin_fails() {
245 : struct Broken;
246 : impl Read for Broken {
247 1 : fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
248 1 : Err(std::io::Error::other("closed"))
249 1 : }
250 : }
251 1 : let root = files(&[]);
252 1 : assert_eq!(read_inputs(&["-".to_string()], &root, &mut Broken), Err("stdin: closed".to_string()));
253 1 : }
254 :
255 : #[test]
256 1 : fn names_a_missing_path_in_the_error() {
257 1 : let root = files(&[]);
258 1 : let error = read(&root, &["missing.sql"], b"").unwrap_err();
259 1 : assert!(error.starts_with(&format!("{}: ", root.join("missing.sql").display())), "{error}");
260 1 : }
261 :
262 : #[cfg(unix)]
263 : mod unix {
264 : use super::*;
265 : use std::os::unix::fs::{symlink, PermissionsExt};
266 :
267 : #[test]
268 1 : fn follows_symlinks_to_directories_and_files() {
269 1 : let root = files(&["library/b.sql", "work/a.sql", "other.sql"]);
270 1 : symlink(root.join("library"), root.join("work/linked")).unwrap();
271 1 : symlink(root.join("other.sql"), root.join("work/other.sql")).unwrap();
272 1 : let inputs = read(&root.join("work"), &["."], b"").unwrap();
273 1 : assert_eq!(ids(&inputs), ["a.sql", "linked/b.sql", "other.sql"]);
274 1 : }
275 :
276 : #[test]
277 1 : fn skips_dangling_symlinks_in_a_directory() {
278 1 : let root = files(&["a.sql"]);
279 1 : symlink(root.join("gone.sql"), root.join("dangling.sql")).unwrap();
280 1 : assert_eq!(ids(&read(&root, &["."], b"").unwrap()), ["a.sql"]);
281 1 : assert!(read(&root, &["dangling.sql"], b"").is_err());
282 1 : }
283 :
284 : #[test]
285 1 : fn reads_a_directory_with_a_link_to_itself_once() {
286 1 : let root = files(&["a.sql"]);
287 1 : symlink(&root, root.join("loop")).unwrap();
288 1 : assert_eq!(ids(&read(&root, &["."], b"").unwrap()), ["a.sql"]);
289 1 : }
290 :
291 : #[test]
292 1 : fn survives_two_links_to_the_same_ancestor() {
293 : // Without a guard the walk doubles on every level until the kernel gives up after 40 links
294 1 : let root = files(&["a.sql", "sub/b.sql"]);
295 1 : symlink(&root, root.join("sub/up1")).unwrap();
296 1 : symlink(&root, root.join("sub/up2")).unwrap();
297 1 : assert_eq!(ids(&read(&root, &["."], b"").unwrap()), ["a.sql", "sub/b.sql"]);
298 1 : }
299 :
300 : #[test]
301 1 : fn walks_a_directory_that_two_links_share() {
302 1 : let root = files(&["library/b.sql", "work/x/keep", "work/y/keep"]);
303 1 : symlink(root.join("library"), root.join("work/x/lib")).unwrap();
304 1 : symlink(root.join("library"), root.join("work/y/lib")).unwrap();
305 1 : assert_eq!(ids(&read(&root.join("work"), &["."], b"").unwrap()), ["x/lib/b.sql", "y/lib/b.sql"]);
306 1 : }
307 :
308 : #[test]
309 1 : fn fails_on_files_and_directories_that_cannot_be_read() {
310 : // Root reads everything
311 1 : if unsafe { libc::geteuid() } == 0 {
312 0 : return;
313 1 : }
314 1 : let root = files(&["a.sql", "locked/b.sql"]);
315 4 : let set_mode = |path: &str, mode| std::fs::set_permissions(root.join(path), std::fs::Permissions::from_mode(mode)).unwrap();
316 1 : set_mode("a.sql", 0o000);
317 1 : let error = read(&root, &["a.sql"], b"").unwrap_err();
318 1 : assert!(error.starts_with(&format!("{}: ", root.join("a.sql").display())), "{error}");
319 1 : set_mode("a.sql", 0o644);
320 1 : set_mode("locked", 0o000);
321 1 : assert!(read(&root, &["."], b"").is_err());
322 1 : set_mode("locked", 0o755);
323 1 : }
324 : }
325 : }
|