Skip to content

Commit 4d4813b

Browse files
authored
Windows device path fixes (nushell#16775)
## Release notes summary - What our users need to know * On Windows, UNC and device paths no longer get a trailing `\` appended when being cast to `path` * On Windows, open, save and source now work with device paths like `\\.\NUL` or `\\.\CON`, as well as reserved device names like `NUL` and `CON`. Using full device paths is recommended. ## Tasks after submitting
1 parent d96dbbd commit 4d4813b

File tree

8 files changed

+279
-11
lines changed

8 files changed

+279
-11
lines changed

crates/nu-command/src/filesystem/open.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#[allow(deprecated)]
22
use nu_engine::{command_prelude::*, current_dir, eval_call};
3+
use nu_path::is_windows_device_path;
34
use nu_protocol::{
45
DataSource, NuGlob, PipelineMetadata, ast,
56
debugger::{WithDebug, WithoutDebug},
@@ -104,8 +105,17 @@ impl Command for Open {
104105
let arg_span = path.span;
105106
// let path_no_whitespace = &path.item.trim_end_matches(|x| matches!(x, '\x09'..='\x0d'));
106107

107-
for path in
108-
nu_engine::glob_from(&path, &cwd, call_span, None, engine_state.signals().clone())
108+
let matches: Box<dyn Iterator<Item = Result<PathBuf, ShellError>> + Send> =
109+
if is_windows_device_path(Path::new(&path.item.to_string())) {
110+
Box::new(vec![Ok(PathBuf::from(path.item.to_string()))].into_iter())
111+
} else {
112+
nu_engine::glob_from(
113+
&path,
114+
&cwd,
115+
call_span,
116+
None,
117+
engine_state.signals().clone(),
118+
)
109119
.map_err(|err| match err {
110120
ShellError::Io(mut err) => {
111121
err.kind = err.kind.not_found_as(NotFound::File);
@@ -115,7 +125,8 @@ impl Command for Open {
115125
_ => err,
116126
})?
117127
.1
118-
{
128+
};
129+
for path in matches {
119130
let path = path?;
120131
let path = Path::new(&path);
121132

crates/nu-command/src/filesystem/save.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::progress_bar;
22
use nu_engine::get_eval_block;
33
#[allow(deprecated)]
44
use nu_engine::{command_prelude::*, current_dir};
5-
use nu_path::expand_path_with;
5+
use nu_path::{expand_path_with, is_windows_device_path};
66
use nu_protocol::{
77
ByteStreamSource, DataSource, OutDest, PipelineMetadata, Signals, ast,
88
byte_stream::copy_with_signals, process::ChildPipe, shell_error::io::IoError,
@@ -432,7 +432,8 @@ fn open_file(
432432
span: Span,
433433
append: bool,
434434
) -> Result<File, ShellError> {
435-
let file: std::io::Result<File> = match (append, path.exists()) {
435+
let file: std::io::Result<File> = match (append, path.exists() || is_windows_device_path(path))
436+
{
436437
(true, true) => std::fs::OpenOptions::new().append(true).open(path),
437438
_ => {
438439
// This is a temporary solution until `std::fs::File::create` is fixed on Windows (rust-lang/rust#134893)

crates/nu-command/src/misc/source.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use nu_engine::{command_prelude::*, get_eval_block_with_early_return};
2-
use nu_path::canonicalize_with;
2+
use nu_path::{canonicalize_with, is_windows_device_path};
33
use nu_protocol::{BlockId, engine::CommandType, shell_error::io::IoError};
44

55
/// Source a file for environment variables.
@@ -55,8 +55,13 @@ impl Command for Source {
5555
let cwd = engine_state.cwd_as_string(Some(stack))?;
5656
let pb = std::path::PathBuf::from(block_id_name);
5757
let parent = pb.parent().unwrap_or(std::path::Path::new(""));
58-
let file_path = canonicalize_with(pb.as_path(), cwd)
59-
.map_err(|err| IoError::new(err.not_found_as(NotFound::File), call.head, pb.clone()))?;
58+
let file_path = if is_windows_device_path(pb.as_path()) {
59+
pb.clone()
60+
} else {
61+
canonicalize_with(pb.as_path(), cwd).map_err(|err| {
62+
IoError::new(err.not_found_as(NotFound::File), call.head, pb.clone())
63+
})?
64+
};
6065

6166
// Note: We intentionally left out PROCESS_PATH since it's supposed to
6267
// to work like argv[0] in C, which is the name of the program being executed.

crates/nu-parser/src/parse_keywords.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::{
1010

1111
use log::trace;
1212
use nu_path::canonicalize_with;
13+
use nu_path::is_windows_device_path;
1314
use nu_protocol::{
1415
Alias, BlockId, CommandWideCompleter, CustomExample, DeclId, FromValue, Module, ModuleId,
1516
ParseError, PositionalArg, ResolvedImportPattern, ShellError, Signature, Span, Spanned,
@@ -3950,6 +3951,10 @@ pub fn find_in_dirs(
39503951
cwd: &str,
39513952
dirs_var_name: Option<&str>,
39523953
) -> Option<ParserPath> {
3954+
if is_windows_device_path(Path::new(&filename)) {
3955+
return Some(ParserPath::RealPath(filename.into()));
3956+
}
3957+
39533958
pub fn find_in_dirs_with_id(
39543959
filename: &str,
39553960
working_set: &StateWorkingSet,

crates/nu-path/src/dots.rs

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#[cfg(windows)]
22
use omnipath::WinPathExt;
3-
use std::path::{Component, Path, PathBuf};
3+
use std::path::{Component, Path, PathBuf, Prefix};
44

55
/// Normalize the path, expanding occurrences of n-dots.
66
///
@@ -17,6 +17,7 @@ pub fn expand_ndots(path: impl AsRef<Path>) -> PathBuf {
1717
let path = path.as_ref();
1818

1919
let mut result = PathBuf::with_capacity(path.as_os_str().len());
20+
let mut has_special_prefix = false;
2021
for component in crate::components(path) {
2122
match component {
2223
Component::Normal(s) if is_ndots(s) => {
@@ -26,6 +27,21 @@ pub fn expand_ndots(path: impl AsRef<Path>) -> PathBuf {
2627
result.push("..");
2728
}
2829
}
30+
Component::Prefix(prefix) => {
31+
match prefix.kind() {
32+
Prefix::Disk(_) => {
33+
// Here, only the disk letter gets parsed as prefix,
34+
// so the following RootDir component makes sense
35+
}
36+
_ => {
37+
has_special_prefix = true;
38+
}
39+
}
40+
result.push(component)
41+
}
42+
Component::RootDir if has_special_prefix => {
43+
// Ignore; this would add a trailing backslash to the path that wasn't in the input
44+
}
2945
_ => result.push(component),
3046
}
3147
}
@@ -51,6 +67,7 @@ pub fn expand_dots(path: impl AsRef<Path>) -> PathBuf {
5167

5268
let path = path.as_ref();
5369

70+
let mut has_special_prefix = false;
5471
let mut result = PathBuf::with_capacity(path.as_os_str().len());
5572
for component in crate::components(path) {
5673
match component {
@@ -60,6 +77,21 @@ pub fn expand_dots(path: impl AsRef<Path>) -> PathBuf {
6077
Component::CurDir if last_component_is_normal(&result) => {
6178
// no-op
6279
}
80+
Component::Prefix(prefix) => {
81+
match prefix.kind() {
82+
Prefix::Disk(_) => {
83+
// Here, only the disk letter gets parsed as prefix,
84+
// so the following RootDir component makes sense
85+
}
86+
_ => {
87+
has_special_prefix = true;
88+
}
89+
}
90+
result.push(component)
91+
}
92+
Component::RootDir if has_special_prefix => {
93+
// Ignore; this would add a trailing backslash to the path that wasn't in the input
94+
}
6395
_ => {
6496
let prev_component = result.components().next_back();
6597
if prev_component == Some(Component::RootDir) && component == Component::ParentDir {
@@ -192,6 +224,54 @@ mod test_expand_ndots {
192224
let path = Path::new("./...");
193225
assert_path_eq!(expand_ndots_safe(path), "./...");
194226
}
227+
228+
#[test]
229+
fn unc_share_no_dots() {
230+
let path = Path::new(r"\\server\share");
231+
assert_path_eq!(expand_ndots(path), path);
232+
}
233+
234+
#[test]
235+
fn unc_file_no_dots() {
236+
let path = Path::new(r"\\server\share\dir\file.nu");
237+
assert_path_eq!(expand_ndots(path), path);
238+
}
239+
240+
#[test]
241+
fn verbatim_no_dots() {
242+
let path = Path::new(r"\\?\pictures\elephants");
243+
assert_path_eq!(expand_ndots(path), path);
244+
}
245+
246+
#[test]
247+
fn verbatim_unc_share_no_dots() {
248+
let path = Path::new(r"\\?\UNC\server\share");
249+
assert_path_eq!(expand_ndots(path), path);
250+
}
251+
252+
#[test]
253+
fn verbatim_unc_file_no_dots() {
254+
let path = Path::new(r"\\?\UNC\server\share\dir\file.nu");
255+
assert_path_eq!(expand_ndots(path), path);
256+
}
257+
258+
#[test]
259+
fn verbatim_disk_no_dots() {
260+
let path = Path::new(r"\\?\c:\");
261+
assert_path_eq!(expand_ndots(path), path);
262+
}
263+
264+
#[test]
265+
fn device_path_no_dots() {
266+
let path = Path::new(r"\\.\CON");
267+
assert_path_eq!(expand_ndots(path), path);
268+
}
269+
270+
#[test]
271+
fn disk_no_dots() {
272+
let path = Path::new(r"c:\Users\Ellie\nu_scripts");
273+
assert_path_eq!(expand_ndots(path), path);
274+
}
195275
}
196276

197277
#[cfg(test)]
@@ -247,4 +327,60 @@ mod test_expand_dots {
247327
let expected = if cfg!(windows) { r"\baz" } else { "/baz" };
248328
assert_path_eq!(expand_dots(path), expected);
249329
}
330+
331+
#[test]
332+
fn unc_share_no_dots() {
333+
let path = Path::new(r"\\server\share");
334+
assert_path_eq!(expand_dots(path), path);
335+
}
336+
337+
#[test]
338+
fn unc_file_no_dots() {
339+
let path = Path::new(r"\\server\share\dir\file.nu");
340+
assert_path_eq!(expand_dots(path), path);
341+
}
342+
343+
#[test]
344+
#[ignore = "bug in upstream library"]
345+
fn verbatim_no_dots() {
346+
// omnipath::windows::sys::Path::to_winuser_path seems to turn this verbatim path into a device path
347+
let path = Path::new(r"\\?\pictures\elephants");
348+
assert_path_eq!(expand_dots(path), path);
349+
}
350+
351+
#[cfg_attr(not(windows), ignore = "only for Windows")]
352+
#[test]
353+
fn verbatim_unc_share_no_dots() {
354+
let path = Path::new(r"\\?\UNC\server\share");
355+
let expected = Path::new(r"\\server\share");
356+
assert_path_eq!(expand_dots(path), expected);
357+
}
358+
359+
#[cfg_attr(not(windows), ignore = "only for Windows")]
360+
#[test]
361+
fn verbatim_unc_file_no_dots() {
362+
let path = Path::new(r"\\?\UNC\server\share\dir\file.nu");
363+
let expected = Path::new(r"\\server\share\dir\file.nu");
364+
assert_path_eq!(expand_dots(path), expected);
365+
}
366+
367+
#[cfg_attr(not(windows), ignore = "only for Windows")]
368+
#[test]
369+
fn verbatim_disk_no_dots() {
370+
let path = Path::new(r"\\?\C:\");
371+
let expected = Path::new(r"C:\");
372+
assert_path_eq!(expand_dots(path), expected);
373+
}
374+
375+
#[test]
376+
fn device_path_no_dots() {
377+
let path = Path::new(r"\\.\CON");
378+
assert_path_eq!(expand_dots(path), path);
379+
}
380+
381+
#[test]
382+
fn disk_no_dots() {
383+
let path = Path::new(r"c:\Users\Ellie\nu_scripts");
384+
assert_path_eq!(expand_dots(path), path);
385+
}
250386
}

crates/nu-path/src/helpers.rs

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use std::path::PathBuf;
1+
#[cfg(windows)]
2+
use std::path::{Component, Prefix};
3+
use std::path::{Path, PathBuf};
24

35
use crate::AbsolutePathBuf;
46

@@ -34,3 +36,102 @@ fn configurable_dir_path(
3436
.or_else(|| dir().and_then(|path| AbsolutePathBuf::try_from(path).ok()))
3537
.map(|path| path.canonicalize().map(Into::into).unwrap_or(path))
3638
}
39+
40+
// List of special paths that can be written to and/or read from, even though they
41+
// don't appear as directory entries.
42+
// See https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
43+
// In rare circumstances, reserved paths _can_ exist as regular files in a
44+
// directory which shadow their special counterpart, so the safe way of referring
45+
// to these paths is by prefixing them with '\\.\' (this instructs the Windows APIs
46+
// to access the Win32 device namespace instead of the Win32 file namespace)
47+
// https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats
48+
#[cfg(windows)]
49+
pub fn is_windows_device_path(path: &Path) -> bool {
50+
match path.components().next() {
51+
Some(Component::Prefix(prefix)) if matches!(prefix.kind(), Prefix::DeviceNS(_)) => {
52+
return true;
53+
}
54+
_ => {}
55+
}
56+
let special_paths: [&Path; 28] = [
57+
Path::new("CON"),
58+
Path::new("PRN"),
59+
Path::new("AUX"),
60+
Path::new("NUL"),
61+
Path::new("COM1"),
62+
Path::new("COM2"),
63+
Path::new("COM3"),
64+
Path::new("COM4"),
65+
Path::new("COM5"),
66+
Path::new("COM6"),
67+
Path::new("COM7"),
68+
Path::new("COM8"),
69+
Path::new("COM9"),
70+
Path::new("COM¹"),
71+
Path::new("COM²"),
72+
Path::new("COM³"),
73+
Path::new("LPT1"),
74+
Path::new("LPT2"),
75+
Path::new("LPT3"),
76+
Path::new("LPT4"),
77+
Path::new("LPT5"),
78+
Path::new("LPT6"),
79+
Path::new("LPT7"),
80+
Path::new("LPT8"),
81+
Path::new("LPT9"),
82+
Path::new("LPT¹"),
83+
Path::new("LPT²"),
84+
Path::new("LPT³"),
85+
];
86+
if special_paths.contains(&path) {
87+
return true;
88+
}
89+
false
90+
}
91+
92+
#[cfg(not(windows))]
93+
pub fn is_windows_device_path(_path: &Path) -> bool {
94+
false
95+
}
96+
97+
#[cfg(test)]
98+
mod test_is_windows_device_path {
99+
use crate::is_windows_device_path;
100+
use std::path::Path;
101+
102+
#[cfg_attr(not(windows), ignore = "only for Windows")]
103+
#[test]
104+
fn device_namespace() {
105+
assert!(is_windows_device_path(Path::new(r"\\.\CON")))
106+
}
107+
108+
#[cfg_attr(not(windows), ignore = "only for Windows")]
109+
#[test]
110+
fn reserved_device_name() {
111+
assert!(is_windows_device_path(Path::new(r"NUL")))
112+
}
113+
114+
#[cfg_attr(not(windows), ignore = "only for Windows")]
115+
#[test]
116+
fn normal_path() {
117+
assert!(!is_windows_device_path(Path::new(r"dir\file")))
118+
}
119+
120+
#[cfg_attr(not(windows), ignore = "only for Windows")]
121+
#[test]
122+
fn absolute_path() {
123+
assert!(!is_windows_device_path(Path::new(r"\dir\file")))
124+
}
125+
126+
#[cfg_attr(not(windows), ignore = "only for Windows")]
127+
#[test]
128+
fn unc_path() {
129+
assert!(!is_windows_device_path(Path::new(r"\\server\share")))
130+
}
131+
132+
#[cfg_attr(not(windows), ignore = "only for Windows")]
133+
#[test]
134+
fn verbatim_path() {
135+
assert!(!is_windows_device_path(Path::new(r"\\?\dir\file")))
136+
}
137+
}

0 commit comments

Comments
 (0)