LCOV - code coverage report
Current view: top level - dashql-cli/src - inputs.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 96.9 % 226 219
Test Date: 2026-11-06 17:46:50

            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              : }
        

Generated by: LCOV version 2.0-1