Skip to main content

nu_protocol/
parser_path.rs

1use crate::{
2    FileId,
3    engine::{StateWorkingSet, VirtualPath},
4};
5use std::{
6    ffi::OsStr,
7    path::{Path, PathBuf},
8};
9
10/// Maximum size of a script file the `run` command will load for parsing.
11///
12/// `run` is a parser keyword: interactive re-parse (syntax highlight, completion, validation)
13/// reloads the argument path on every keystroke / Tab. Without a bound, a large path is fully
14/// loaded and force-fed through the Nu parser, which can hang the REPL and use multi‑GiB of RAM.
15///
16/// 1 MiB is large enough for typical scripts while keeping interactive parse responsive.
17///
18/// This limit is intentionally **only** applied to `run`, not `source` / modules.
19pub const MAX_RUN_SCRIPT_BYTES: u64 = 1_048_576;
20
21/// Bytes sampled from the start of a file when deciding whether it looks like text.
22const TEXT_PROBE_BYTES: usize = 8192;
23
24/// Failure modes when loading a file as a Nu script for `run`.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ScriptLoadError {
27    /// File length exceeds [`MAX_RUN_SCRIPT_BYTES`] (or a custom limit).
28    TooLarge { size: u64, max_size: u64 },
29    /// Contents do not look like UTF-8 text suitable for a Nu script.
30    NotText,
31    /// Open/read failed or path is not a readable file.
32    Unreadable,
33}
34
35/// Heuristic: does `bytes` look like UTF-8 text a Nu script could be?
36///
37/// Inspects up to the first [`TEXT_PROBE_BYTES`] bytes. Rejects when:
38/// - a NUL byte is present (classic binary marker used by git/`file`)
39/// - the sample is not valid UTF-8 (Nu source is UTF-8)
40/// - more than 30% of bytes are C0 control characters other than tab/LF/CR
41///
42/// Empty input is treated as text (empty script).
43pub fn looks_like_text(bytes: &[u8]) -> bool {
44    let sample = if bytes.len() > TEXT_PROBE_BYTES {
45        &bytes[..TEXT_PROBE_BYTES]
46    } else {
47        bytes
48    };
49
50    if sample.is_empty() {
51        return true;
52    }
53
54    // Strong binary signal — archives, executables, compressed data often contain NULs early.
55    if sample.contains(&0) {
56        return false;
57    }
58
59    // Nu scripts are UTF-8; invalid sequences are almost always binary.
60    if std::str::from_utf8(sample).is_err() {
61        return false;
62    }
63
64    // High density of C0 controls (excluding common whitespace) is typical of binary formats
65    // that happen to avoid NULs in the first few KiB.
66    let suspicious_controls = sample
67        .iter()
68        .filter(|&&b| b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r'))
69        .count();
70    // More than 30% suspicious controls → treat as non-text.
71    let mostly_controls = suspicious_controls.saturating_mul(10) > sample.len().saturating_mul(3);
72    !mostly_controls
73}
74
75/// Read a real filesystem path for `run`, applying size and text checks.
76///
77/// Size is checked via metadata before any body read so oversized files never enter memory.
78pub fn read_run_script_file(path: &Path, max_bytes: u64) -> Result<Vec<u8>, ScriptLoadError> {
79    let size = std::fs::metadata(path)
80        .map(|m| m.len())
81        .map_err(|_| ScriptLoadError::Unreadable)?;
82    if size > max_bytes {
83        return Err(ScriptLoadError::TooLarge {
84            size,
85            max_size: max_bytes,
86        });
87    }
88    let contents = std::fs::read(path).map_err(|_| ScriptLoadError::Unreadable)?;
89    if !looks_like_text(&contents) {
90        return Err(ScriptLoadError::NotText);
91    }
92    Ok(contents)
93}
94
95/// An abstraction over a PathBuf that can have virtual paths (files and directories). Virtual
96/// paths always exist and represent a way to ship Nushell code inside the binary without requiring
97/// paths to be present in the file system.
98///
99/// Created from VirtualPath found in the engine state.
100#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
101pub enum ParserPath {
102    RealPath(PathBuf),
103    VirtualFile(PathBuf, usize),
104    VirtualDir(PathBuf, Vec<ParserPath>),
105}
106
107impl ParserPath {
108    pub fn is_dir(&self) -> bool {
109        match self {
110            ParserPath::RealPath(p) => p.is_dir(),
111            ParserPath::VirtualFile(..) => false,
112            ParserPath::VirtualDir(..) => true,
113        }
114    }
115
116    pub fn is_file(&self) -> bool {
117        match self {
118            ParserPath::RealPath(p) => p.is_file(),
119            ParserPath::VirtualFile(..) => true,
120            ParserPath::VirtualDir(..) => false,
121        }
122    }
123
124    pub fn exists(&self) -> bool {
125        match self {
126            ParserPath::RealPath(p) => p.exists(),
127            ParserPath::VirtualFile(..) => true,
128            ParserPath::VirtualDir(..) => true,
129        }
130    }
131
132    pub fn path(&self) -> &Path {
133        match self {
134            ParserPath::RealPath(p) => p,
135            ParserPath::VirtualFile(p, _) => p,
136            ParserPath::VirtualDir(p, _) => p,
137        }
138    }
139
140    pub fn path_buf(self) -> PathBuf {
141        match self {
142            ParserPath::RealPath(p) => p,
143            ParserPath::VirtualFile(p, _) => p,
144            ParserPath::VirtualDir(p, _) => p,
145        }
146    }
147
148    pub fn parent(&self) -> Option<&Path> {
149        match self {
150            ParserPath::RealPath(p) => p.parent(),
151            ParserPath::VirtualFile(p, _) => p.parent(),
152            ParserPath::VirtualDir(p, _) => p.parent(),
153        }
154    }
155
156    pub fn read_dir(&self) -> Option<Vec<ParserPath>> {
157        match self {
158            ParserPath::RealPath(p) => p.read_dir().ok().map(|read_dir| {
159                read_dir
160                    .flatten()
161                    .map(|dir_entry| ParserPath::RealPath(dir_entry.path()))
162                    .collect()
163            }),
164            ParserPath::VirtualFile(..) => None,
165            ParserPath::VirtualDir(_, files) => Some(files.clone()),
166        }
167    }
168
169    pub fn file_stem(&self) -> Option<&OsStr> {
170        self.path().file_stem()
171    }
172
173    pub fn extension(&self) -> Option<&OsStr> {
174        self.path().extension()
175    }
176
177    pub fn join(self, path: impl AsRef<Path>) -> ParserPath {
178        match self {
179            ParserPath::RealPath(p) => ParserPath::RealPath(p.join(path)),
180            ParserPath::VirtualFile(p, file_id) => ParserPath::VirtualFile(p.join(path), file_id),
181            ParserPath::VirtualDir(p, entries) => {
182                let new_p = p.join(path);
183                let mut pp = ParserPath::RealPath(new_p.clone());
184                for entry in entries {
185                    if new_p == entry.path() {
186                        pp = entry.clone();
187                    }
188                }
189                pp
190            }
191        }
192    }
193
194    pub fn open<'a>(
195        &'a self,
196        working_set: &'a StateWorkingSet,
197    ) -> std::io::Result<Box<dyn std::io::Read + 'a>> {
198        match self {
199            ParserPath::RealPath(p) => {
200                std::fs::File::open(p).map(|f| Box::new(f) as Box<dyn std::io::Read>)
201            }
202            ParserPath::VirtualFile(_, file_id) => working_set
203                .get_contents_of_file(FileId::new(*file_id))
204                .map(|bytes| Box::new(bytes) as Box<dyn std::io::Read>)
205                .ok_or(std::io::ErrorKind::NotFound.into()),
206
207            ParserPath::VirtualDir(..) => Err(std::io::ErrorKind::NotFound.into()),
208        }
209    }
210
211    pub fn read<'a>(&'a self, working_set: &'a StateWorkingSet) -> Option<Vec<u8>> {
212        self.open(working_set)
213            .and_then(|mut reader| {
214                let mut vec = vec![];
215                reader.read_to_end(&mut vec)?;
216                Ok(vec)
217            })
218            .ok()
219    }
220
221    /// File length in bytes when available, without reading the body for real paths.
222    pub fn len(&self, working_set: &StateWorkingSet) -> Option<u64> {
223        match self {
224            ParserPath::RealPath(p) => std::fs::metadata(p).ok().map(|m| m.len()),
225            ParserPath::VirtualFile(_, file_id) => working_set
226                .get_contents_of_file(FileId::new(*file_id))
227                .map(|bytes| bytes.len() as u64),
228            ParserPath::VirtualDir(..) => None,
229        }
230    }
231
232    /// Read file contents for the `run` command, refusing oversized or non-text files.
233    ///
234    /// Size is checked before reading real paths so interactive parse of `run <path>` cannot hang
235    /// on large binaries completed via Tab. Not used by `source` / module loading.
236    pub fn read_run_script(
237        &self,
238        working_set: &StateWorkingSet,
239        max_bytes: u64,
240    ) -> Result<Vec<u8>, ScriptLoadError> {
241        match self {
242            ParserPath::RealPath(p) => read_run_script_file(p, max_bytes),
243            ParserPath::VirtualFile(_, file_id) => {
244                let contents = working_set
245                    .get_contents_of_file(FileId::new(*file_id))
246                    .ok_or(ScriptLoadError::Unreadable)?;
247                let size = contents.len() as u64;
248                if size > max_bytes {
249                    return Err(ScriptLoadError::TooLarge {
250                        size,
251                        max_size: max_bytes,
252                    });
253                }
254                if !looks_like_text(contents) {
255                    return Err(ScriptLoadError::NotText);
256                }
257                Ok(contents.to_vec())
258            }
259            ParserPath::VirtualDir(..) => Err(ScriptLoadError::Unreadable),
260        }
261    }
262
263    pub fn from_virtual_path(
264        working_set: &StateWorkingSet,
265        name: &str,
266        virtual_path: &VirtualPath,
267    ) -> Self {
268        match virtual_path {
269            VirtualPath::File(file_id) => {
270                ParserPath::VirtualFile(PathBuf::from(name), file_id.get())
271            }
272            VirtualPath::Dir(entries) => ParserPath::VirtualDir(
273                PathBuf::from(name),
274                entries
275                    .iter()
276                    .map(|virtual_path_id| {
277                        let (virt_name, virt_path) = working_set.get_virtual_path(*virtual_path_id);
278                        ParserPath::from_virtual_path(working_set, virt_name, virt_path)
279                    })
280                    .collect(),
281            ),
282        }
283    }
284
285    /// Normalizes a path to use platform-native separators
286    fn normalize_native(path: &str) -> PathBuf {
287        Path::new(&path)
288            .components()
289            .fold(PathBuf::new(), |mut acc, comp| {
290                acc.push(comp);
291                acc
292            })
293    }
294
295    /// Normalizes a path to always use forward slashes (good for display, configs, cross-platform strings)
296    fn normalize_forward(path: impl AsRef<Path>) -> PathBuf {
297        PathBuf::from(
298            path.as_ref()
299                .to_string_lossy()
300                .replace(std::path::MAIN_SEPARATOR, "/"),
301        )
302    }
303
304    pub fn normalize_slashes_forward(self) -> Self {
305        match self {
306            ParserPath::RealPath(p) => ParserPath::RealPath(Self::normalize_forward(p)),
307            ParserPath::VirtualFile(p, file_id) => {
308                ParserPath::VirtualFile(Self::normalize_forward(p), file_id)
309            }
310            ParserPath::VirtualDir(p, entries) => {
311                ParserPath::VirtualDir(Self::normalize_forward(p), entries)
312            }
313        }
314    }
315
316    pub fn normalize_slashes_native(self) -> Self {
317        match self {
318            ParserPath::RealPath(p) => {
319                ParserPath::RealPath(Self::normalize_native(p.to_string_lossy().as_ref()))
320            }
321            ParserPath::VirtualFile(p, file_id) => ParserPath::VirtualFile(
322                Self::normalize_native(p.to_string_lossy().as_ref()),
323                file_id,
324            ),
325            ParserPath::VirtualDir(p, entries) => ParserPath::VirtualDir(
326                Self::normalize_native(p.to_string_lossy().as_ref()),
327                entries,
328            ),
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn looks_like_text_accepts_empty_and_normal_scripts() {
339        assert!(looks_like_text(b""));
340        assert!(looks_like_text(b"def main [] { 'hi' }\n"));
341        assert!(looks_like_text(b"let x = 1\t# tab and comment\r\n"));
342        // Multi-byte UTF-8 is fine
343        assert!(looks_like_text("print '你好'\n".as_bytes()));
344    }
345
346    #[test]
347    fn looks_like_text_rejects_nul() {
348        assert!(!looks_like_text(b"abc\0def"));
349    }
350
351    #[test]
352    fn looks_like_text_rejects_invalid_utf8() {
353        assert!(!looks_like_text(&[0x80, 0x81, 0xFF]));
354    }
355
356    #[test]
357    fn looks_like_text_rejects_dense_controls() {
358        let dense = vec![0x01u8; 50];
359        assert!(!looks_like_text(&dense));
360    }
361
362    #[test]
363    fn looks_like_text_allows_sparse_controls() {
364        // A few BEL characters in mostly normal text should still count as text.
365        let mut bytes = b"print 'hello'\n".to_vec();
366        bytes.push(0x07);
367        assert!(looks_like_text(&bytes));
368    }
369}