From 41831d3eab4107adf3889b2db2002ee8bd0f4743 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Fri, 3 Jul 2026 15:04:05 +0100 Subject: [PATCH 1/2] Discover shebang extensionless workspace scripts --- crates/uv/src/commands/workspace/list.rs | 112 ++++++++++++++++++-- crates/uv/tests/workspace/workspace_list.rs | 12 ++- 2 files changed, 113 insertions(+), 11 deletions(-) diff --git a/crates/uv/src/commands/workspace/list.rs b/crates/uv/src/commands/workspace/list.rs index 657c33b807897..a37b5afadc63f 100644 --- a/crates/uv/src/commands/workspace/list.rs +++ b/crates/uv/src/commands/workspace/list.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; use std::fmt::Write; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -134,12 +135,15 @@ fn find_scripts(workspace_root: &Path, cache: &Cache) -> Result> { continue; } - let contents = fs_err::read(entry.path()).with_context(|| { + let Some(contents) = read_script_candidate(entry.path()).with_context(|| { format!( "Failed to read candidate PEP 723 script: {}", entry.path().simplified_display() ) - })?; + })? + else { + continue; + }; if Pep723Metadata::parse(&contents) .with_context(|| { format!( @@ -157,16 +161,106 @@ fn find_scripts(workspace_root: &Path, cache: &Cache) -> Result> { Ok(scripts) } -/// Return whether a path uses a conventional Python script filename. +/// Read a candidate script. +/// +/// Extensionless candidates are only read past their prefix when they begin with a shebang. +fn read_script_candidate(path: &Path) -> io::Result>> { + if path.extension().is_some() { + return fs_err::read(path).map(Some); + } + + let mut file = fs_err::File::open(path)?; + read_extensionless_script(&mut file) +} + +/// Read an extensionless script, if it starts with a shebang and is valid UTF-8 text. +fn read_extensionless_script(mut reader: impl Read) -> io::Result>> { + const READ_BUFFER_SIZE: usize = 8 * 1024; + + let mut prefix = [0; 2]; + match reader.read_exact(&mut prefix) { + Ok(()) if &prefix == b"#!" => {} + Ok(()) => return Ok(None), + Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), + Err(err) => return Err(err), + } + + let mut contents = prefix.to_vec(); + let mut valid_utf8_len = contents.len(); + let mut buffer = [0u8; READ_BUFFER_SIZE]; + loop { + let count = match reader.read(&mut buffer) { + Ok(0) => break, + Ok(count) => count, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + }; + + let chunk = &buffer[..count]; + if chunk.contains(&0) { + return Ok(None); + } + contents.extend_from_slice(chunk); + match std::str::from_utf8(&contents[valid_utf8_len..]) { + Ok(_) => valid_utf8_len = contents.len(), + Err(err) if err.error_len().is_some() => return Ok(None), + Err(err) => valid_utf8_len += err.valid_up_to(), + } + } + + Ok((valid_utf8_len == contents.len()).then_some(contents)) +} + +/// Return whether a path could contain a Python script. /// /// PEP 723 does not require a specific filename, and uv can run explicitly requested scripts with /// arbitrary extensions or no extension. For discovery, restrict the search to Python extensions -/// to avoid treating metadata examples embedded in documentation as scripts. This could be expanded -/// if arbitrary script filenames can be distinguished without introducing false positives. +/// and extensionless files to avoid treating metadata examples embedded in documentation as scripts. +/// Extensionless candidates are further restricted to shebang scripts and checked for binary +/// content as they are read. fn is_python_script_path(path: &Path) -> bool { - path.extension().is_some_and(|extension| { - extension.to_str().is_some_and(|extension| { - extension.eq_ignore_ascii_case("py") || extension.eq_ignore_ascii_case("pyw") - }) + path.extension().is_none_or(|extension| { + extension.eq_ignore_ascii_case("py") || extension.eq_ignore_ascii_case("pyw") }) } + +#[cfg(test)] +mod tests { + use std::io::{self, Cursor, Read}; + + use super::read_extensionless_script; + + struct ErrorReader; + + impl Read for ErrorReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::other("read past binary marker")) + } + } + + #[test] + fn extensionless_script_stops_at_non_shebang() -> io::Result<()> { + let reader = Cursor::new(b"# ").chain(ErrorReader); + assert!(read_extensionless_script(reader)?.is_none()); + Ok(()) + } + + #[test] + fn extensionless_script_stops_at_binary_content() -> io::Result<()> { + for marker in [0, 0xff] { + let reader = Cursor::new([b'#', b'!', marker]).chain(ErrorReader); + assert!(read_extensionless_script(reader)?.is_none()); + } + Ok(()) + } + + #[test] + fn extensionless_script_handles_split_utf8() -> io::Result<()> { + let reader = Cursor::new(b"#!\xc3").chain(Cursor::new(b"\xa9")); + assert_eq!( + read_extensionless_script(reader)?, + Some(b"#!\xc3\xa9".to_vec()) + ); + Ok(()) + } +} diff --git a/crates/uv/tests/workspace/workspace_list.rs b/crates/uv/tests/workspace/workspace_list.rs index 5658df6515b5d..6693c57dcbf5c 100644 --- a/crates/uv/tests/workspace/workspace_list.rs +++ b/crates/uv/tests/workspace/workspace_list.rs @@ -251,8 +251,12 @@ fn workspace_list_scripts() -> Result<()> { project.child("scripts/nested.py").write_str(script)?; project.child(".github/hidden.py").write_str(script)?; - // Extensionless scripts are not discovered. - project.child("tool").write_str(script)?; + project + .child("tool") + .write_str(&format!("#!/usr/bin/env python\n{script}"))?; + + // Extensionless files without a shebang aren't scanned, even if they're ASCII. + project.child("no-shebang").write_str(script)?; // PEP 723 examples in documentation are not Python scripts. project @@ -287,6 +291,7 @@ fn workspace_list_scripts() -> Result<()> { .github/hidden.py script.py scripts/nested.py + tool ----- stderr ----- warning: The `--scripts` option is experimental and may change without warning. Pass `--preview-features workspace-list-scripts` to disable this warning. @@ -303,6 +308,7 @@ fn workspace_list_scripts() -> Result<()> { .github/hidden.py script.py scripts/nested.py + tool ----- stderr ----- "); @@ -317,6 +323,7 @@ fn workspace_list_scripts() -> Result<()> { .github/hidden.py script.py scripts/nested.py + tool ----- stderr ----- warning: The `--scripts` option is experimental and may change without warning. Pass `--preview-features workspace-list-scripts` to disable this warning. @@ -335,6 +342,7 @@ fn workspace_list_scripts() -> Result<()> { .github/hidden.py script.py scripts/nested.py + tool ----- stderr ----- warning: The `--scripts` option is experimental and may change without warning. Pass `--preview-features workspace-list-scripts` to disable this warning. From a73502fc782a3484fd066c9b0ac3ed343d04e5bd Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Mon, 6 Jul 2026 17:29:53 +0100 Subject: [PATCH 2/2] Move guarded UTF-8 file reads into uv-fs --- crates/uv-fs/src/lib.rs | 2 + crates/uv-fs/src/read.rs | 96 ++++++++++++++++++++++++ crates/uv/src/commands/workspace/list.rs | 86 +-------------------- 3 files changed, 101 insertions(+), 83 deletions(-) create mode 100644 crates/uv-fs/src/read.rs diff --git a/crates/uv-fs/src/lib.rs b/crates/uv-fs/src/lib.rs index e6b217f05716f..d922fe2c38b0e 100644 --- a/crates/uv-fs/src/lib.rs +++ b/crates/uv-fs/src/lib.rs @@ -11,11 +11,13 @@ use tracing::{debug, warn}; pub use crate::locked_file::*; pub use crate::path::*; +pub use crate::read::read_utf_8_file_if_starts_with; pub mod cachedir; pub mod link; mod locked_file; mod path; +mod read; pub mod which; /// Attempt to check if the two paths refer to the same file. diff --git a/crates/uv-fs/src/read.rs b/crates/uv-fs/src/read.rs new file mode 100644 index 0000000000000..f5adb306ed261 --- /dev/null +++ b/crates/uv-fs/src/read.rs @@ -0,0 +1,96 @@ +use std::io::{self, Read}; +use std::path::Path; + +/// Read a UTF-8 text file if it starts with `prefix`. +/// +/// If the file does not start with `prefix`, no bytes after the prefix are read. If the prefix +/// matches, the rest of the file is read incrementally and rejected as soon as it contains a NUL +/// byte or invalid UTF-8. +pub fn read_utf_8_file_if_starts_with( + path: impl AsRef, + prefix: &str, +) -> io::Result>> { + let mut file = fs_err::File::open(path.as_ref())?; + read_utf_8_if_starts_with(&mut file, prefix) +} + +fn read_utf_8_if_starts_with(mut reader: impl Read, prefix: &str) -> io::Result>> { + const READ_BUFFER_SIZE: usize = 8 * 1024; + + if prefix.as_bytes().contains(&0) { + return Ok(None); + } + + let mut contents = vec![0; prefix.len()]; + match reader.read_exact(&mut contents) { + Ok(()) if contents == prefix.as_bytes() => {} + Ok(()) => return Ok(None), + Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), + Err(err) => return Err(err), + } + + let mut valid_utf_8_len = contents.len(); + let mut buffer = [0u8; READ_BUFFER_SIZE]; + loop { + let count = match reader.read(&mut buffer) { + Ok(0) => break, + Ok(count) => count, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + }; + + let chunk = &buffer[..count]; + if chunk.contains(&0) { + return Ok(None); + } + contents.extend_from_slice(chunk); + match std::str::from_utf8(&contents[valid_utf_8_len..]) { + Ok(_) => valid_utf_8_len = contents.len(), + Err(err) if err.error_len().is_some() => return Ok(None), + Err(err) => valid_utf_8_len += err.valid_up_to(), + } + } + + Ok((valid_utf_8_len == contents.len()).then_some(contents)) +} + +#[cfg(test)] +mod tests { + use std::io::{self, Cursor, Read}; + + use super::read_utf_8_if_starts_with; + + struct ErrorReader; + + impl Read for ErrorReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::other("read past rejected content")) + } + } + + #[test] + fn stops_at_prefix_mismatch() -> io::Result<()> { + let reader = Cursor::new(b"# ").chain(ErrorReader); + assert!(read_utf_8_if_starts_with(reader, "#!")?.is_none()); + Ok(()) + } + + #[test] + fn stops_at_binary_content() -> io::Result<()> { + for marker in [0, 0xff] { + let reader = Cursor::new([b'#', b'!', marker]).chain(ErrorReader); + assert!(read_utf_8_if_starts_with(reader, "#!")?.is_none()); + } + Ok(()) + } + + #[test] + fn handles_split_utf_8() -> io::Result<()> { + let reader = Cursor::new(b"#!\xc3").chain(Cursor::new(b"\xa9")); + assert_eq!( + read_utf_8_if_starts_with(reader, "#!")?, + Some(b"#!\xc3\xa9".to_vec()) + ); + Ok(()) + } +} diff --git a/crates/uv/src/commands/workspace/list.rs b/crates/uv/src/commands/workspace/list.rs index a37b5afadc63f..eb0e46a75fdce 100644 --- a/crates/uv/src/commands/workspace/list.rs +++ b/crates/uv/src/commands/workspace/list.rs @@ -1,13 +1,13 @@ use std::borrow::Cow; use std::fmt::Write; -use std::io::{self, Read}; +use std::io; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use owo_colors::OwoColorize; use uv_cache::Cache; -use uv_fs::{CWD, Simplified, is_virtualenv_base, normalize_path}; +use uv_fs::{CWD, Simplified, is_virtualenv_base, normalize_path, read_utf_8_file_if_starts_with}; use uv_preview::{Preview, PreviewFeature}; use uv_scripts::Pep723Metadata; use uv_warnings::warn_user; @@ -169,46 +169,7 @@ fn read_script_candidate(path: &Path) -> io::Result>> { return fs_err::read(path).map(Some); } - let mut file = fs_err::File::open(path)?; - read_extensionless_script(&mut file) -} - -/// Read an extensionless script, if it starts with a shebang and is valid UTF-8 text. -fn read_extensionless_script(mut reader: impl Read) -> io::Result>> { - const READ_BUFFER_SIZE: usize = 8 * 1024; - - let mut prefix = [0; 2]; - match reader.read_exact(&mut prefix) { - Ok(()) if &prefix == b"#!" => {} - Ok(()) => return Ok(None), - Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), - Err(err) => return Err(err), - } - - let mut contents = prefix.to_vec(); - let mut valid_utf8_len = contents.len(); - let mut buffer = [0u8; READ_BUFFER_SIZE]; - loop { - let count = match reader.read(&mut buffer) { - Ok(0) => break, - Ok(count) => count, - Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, - Err(err) => return Err(err), - }; - - let chunk = &buffer[..count]; - if chunk.contains(&0) { - return Ok(None); - } - contents.extend_from_slice(chunk); - match std::str::from_utf8(&contents[valid_utf8_len..]) { - Ok(_) => valid_utf8_len = contents.len(), - Err(err) if err.error_len().is_some() => return Ok(None), - Err(err) => valid_utf8_len += err.valid_up_to(), - } - } - - Ok((valid_utf8_len == contents.len()).then_some(contents)) + read_utf_8_file_if_starts_with(path, "#!") } /// Return whether a path could contain a Python script. @@ -223,44 +184,3 @@ fn is_python_script_path(path: &Path) -> bool { extension.eq_ignore_ascii_case("py") || extension.eq_ignore_ascii_case("pyw") }) } - -#[cfg(test)] -mod tests { - use std::io::{self, Cursor, Read}; - - use super::read_extensionless_script; - - struct ErrorReader; - - impl Read for ErrorReader { - fn read(&mut self, _buffer: &mut [u8]) -> io::Result { - Err(io::Error::other("read past binary marker")) - } - } - - #[test] - fn extensionless_script_stops_at_non_shebang() -> io::Result<()> { - let reader = Cursor::new(b"# ").chain(ErrorReader); - assert!(read_extensionless_script(reader)?.is_none()); - Ok(()) - } - - #[test] - fn extensionless_script_stops_at_binary_content() -> io::Result<()> { - for marker in [0, 0xff] { - let reader = Cursor::new([b'#', b'!', marker]).chain(ErrorReader); - assert!(read_extensionless_script(reader)?.is_none()); - } - Ok(()) - } - - #[test] - fn extensionless_script_handles_split_utf8() -> io::Result<()> { - let reader = Cursor::new(b"#!\xc3").chain(Cursor::new(b"\xa9")); - assert_eq!( - read_extensionless_script(reader)?, - Some(b"#!\xc3\xa9".to_vec()) - ); - Ok(()) - } -}