Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/uv-fs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
96 changes: 96 additions & 0 deletions crates/uv-fs/src/read.rs
Original file line number Diff line number Diff line change
@@ -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<Path>,
prefix: &str,
) -> io::Result<Option<Vec<u8>>> {
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<Option<Vec<u8>>> {
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<usize> {
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(())
}
}
34 changes: 24 additions & 10 deletions crates/uv/src/commands/workspace/list.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use std::borrow::Cow;
use std::fmt::Write;
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;
Expand Down Expand Up @@ -134,12 +135,15 @@ fn find_scripts(workspace_root: &Path, cache: &Cache) -> Result<Vec<PathBuf>> {
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!(
Expand All @@ -157,16 +161,26 @@ fn find_scripts(workspace_root: &Path, cache: &Cache) -> Result<Vec<PathBuf>> {
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<Option<Vec<u8>>> {
if path.extension().is_some() {
return fs_err::read(path).map(Some);
}

read_utf_8_file_if_starts_with(path, "#!")
}

/// 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")
})
}
12 changes: 10 additions & 2 deletions crates/uv/tests/workspace/workspace_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -303,6 +308,7 @@ fn workspace_list_scripts() -> Result<()> {
.github/hidden.py
script.py
scripts/nested.py
tool

----- stderr -----
");
Expand All @@ -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.
Expand All @@ -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.
Expand Down
Loading