From 748beb877b404bf0a3f8fda48b655f965883bc64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Mon, 6 Jul 2026 16:30:34 -0700 Subject: [PATCH] fix: functions push producing empty python_bundle.sources on Windows Path::canonicalize emits \\?\ verbatim paths that the Python runner can't reconcile against a clean cwd, so every source was filtered out. dunce::canonicalize to strip the prefix, with a matching guard in the Python runner --- Cargo.lock | 23 ++++++---- Cargo.toml | 1 + scripts/functions-runner.py | 3 +- scripts/python_runner_common.py | 20 +++++++-- src/functions/push.rs | 79 ++++++++++++++++++++++----------- 5 files changed, 87 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea38d507..935c0be9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,6 +513,7 @@ dependencies = [ "dialoguer", "dirs", "dotenvy", + "dunce", "flate2", "futures-util", "fuzzy-matcher", @@ -1054,6 +1055,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1094,7 +1101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1474,7 +1481,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -2286,7 +2293,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -2323,9 +2330,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2593,7 +2600,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3075,7 +3082,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3722,7 +3729,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9a3df582..43124e78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ oauth2 = { version = "4.4", default-features = false } getrandom = "0.3" chrono = { version = "0.4.40", features = ["clock"] } dirs = "5" +dunce = "1" pathdiff = "0.2.3" glob = "0.3" flate2 = "1.1.2" diff --git a/scripts/functions-runner.py b/scripts/functions-runner.py index 4bf929b5..d8ae40fd 100644 --- a/scripts/functions-runner.py +++ b/scripts/functions-runner.py @@ -14,6 +14,7 @@ purge_local_modules, python_version, resolve_module_info, + strip_extended_length_prefix, ) @@ -286,7 +287,7 @@ async def collect_function_event_entries(prompts_registry: Any) -> list[dict[str async def process_file(file_path: str) -> dict[str, Any]: - abs_path = os.path.abspath(file_path) + abs_path = os.path.abspath(strip_extended_length_prefix(file_path)) cwd = os.getcwd() if cwd not in sys.path: sys.path.insert(0, cwd) diff --git a/scripts/python_runner_common.py b/scripts/python_runner_common.py index 4a738f94..26a3ed25 100644 --- a/scripts/python_runner_common.py +++ b/scripts/python_runner_common.py @@ -5,15 +5,28 @@ from types import ModuleType +def strip_extended_length_prefix(path: str) -> str: + r"""Strip a Windows ``\\?\`` extended-length (verbatim) prefix. + + Verbatim drives (``\\?\C:``) read as different from ``C:`` under + ``os.path.commonpath``, so cwd comparisons drop every source. + """ + if path.startswith("\\\\?\\UNC\\"): + return "\\\\" + path[len("\\\\?\\UNC\\") :] + if path.startswith("\\\\?\\"): + return path[len("\\\\?\\") :] + return path + + def normalize_file_list(files: list[str]) -> list[str]: unique: set[str] = set() for file_path in files: - unique.add(os.path.abspath(file_path)) + unique.add(os.path.abspath(strip_extended_length_prefix(file_path))) return sorted(unique) def resolve_module_info(in_file: str) -> tuple[str, list[str]]: - in_file = os.path.abspath(in_file) + in_file = os.path.abspath(strip_extended_length_prefix(in_file)) module_dir = os.path.dirname(in_file) module_name = os.path.splitext(os.path.basename(in_file))[0] @@ -71,7 +84,8 @@ def purge_local_modules(cwd: str, preserve_modules: set[str] | None = None) -> N def collect_python_sources(cwd: str, input_file: str) -> list[str]: sources: set[str] = set() - input_abs = os.path.abspath(input_file) + cwd = strip_extended_length_prefix(cwd) + input_abs = os.path.abspath(strip_extended_length_prefix(input_file)) sources.add(input_abs) for module in list(sys.modules.values()): diff --git a/src/functions/push.rs b/src/functions/push.rs index ebc1f28d..072a78ea 100644 --- a/src/functions/push.rs +++ b/src/functions/push.rs @@ -1159,7 +1159,7 @@ fn collect_classified_files(inputs: &[PathBuf]) -> Result { let mut python = BTreeSet::new(); let mut allowed_roots = BTreeSet::new(); if let Ok(cwd) = std::env::current_dir() { - if let Ok(canonical_cwd) = cwd.canonicalize() { + if let Ok(canonical_cwd) = dunce::canonicalize(&cwd) { allowed_roots.insert(canonical_cwd); } } @@ -1183,8 +1183,7 @@ fn collect_classified_files(inputs: &[PathBuf]) -> Result { if path.is_file() { explicit_file_inputs += 1; - let canonical = path - .canonicalize() + let canonical = dunce::canonicalize(&path) .with_context(|| format!("failed to canonicalize file {}", path.display()))?; let parent = canonical .parent() @@ -1207,8 +1206,7 @@ fn collect_classified_files(inputs: &[PathBuf]) -> Result { continue; } - let canonical_dir = path - .canonicalize() + let canonical_dir = dunce::canonicalize(&path) .with_context(|| format!("failed to canonicalize directory {}", path.display()))?; allowed_roots.insert(canonical_dir.clone()); collect_from_dir(&canonical_dir, &mut js_like, &mut python)?; @@ -1259,8 +1257,7 @@ fn collect_from_dir_inner( if file_type.is_dir() && !file_type.is_symlink() { collect_from_dir_inner(&path, js_like, python, depth + 1)?; } else if file_type.is_file() { - let canonical = path - .canonicalize() + let canonical = dunce::canonicalize(&path) .with_context(|| format!("failed to canonicalize file {}", path.display()))?; match classify_source_file(&canonical) { Some(SourceLanguage::JsLike) => { @@ -1337,9 +1334,8 @@ fn validate_manifest_paths( let mut seen = BTreeSet::new(); for file in &manifest.files { - let path = PathBuf::from(&file.source_file) - .canonicalize() - .map_err(|err| FileFailure { + let path = + dunce::canonicalize(PathBuf::from(&file.source_file)).map_err(|err| FileFailure { reason: HardFailureReason::ManifestPathMissing, message: format!("manifest source file missing: {} ({err})", file.source_file), })?; @@ -1424,7 +1420,7 @@ fn validate_python_bundle( let mut sources = BTreeSet::new(); for raw_source in &python_bundle.sources { - let canonical = PathBuf::from(raw_source).canonicalize().with_context(|| { + let canonical = dunce::canonicalize(PathBuf::from(raw_source)).with_context(|| { format!( "manifest file '{}' referenced missing python source {}", manifest_file.source_file, raw_source @@ -2030,8 +2026,7 @@ fn collect_regular_files_recursive_impl( } fn validate_requirements_path(path: &Path, allowed_roots: &[PathBuf]) -> Result { - let canonical = path - .canonicalize() + let canonical = dunce::canonicalize(path) .with_context(|| format!("requirements file not found: {}", path.display()))?; if !canonical.is_file() { bail!("requirements path is not a file: {}", canonical.display()); @@ -2172,8 +2167,7 @@ fn resolve_requirement_path(reference: &str, parent: &Path) -> Result { } else { parent.join(candidate) }; - absolute - .canonicalize() + dunce::canonicalize(&absolute) .with_context(|| format!("failed to resolve requirements reference {}", normalized)) } @@ -3164,8 +3158,8 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let source = dir.path().join("tool.js"); std::fs::write(&source, "export const x = 1;\n").expect("write source file"); - let source = source.canonicalize().expect("canonicalize source"); - let root = dir.path().canonicalize().expect("canonicalize root"); + let source = dunce::canonicalize(&source).expect("canonicalize source"); + let root = dunce::canonicalize(dir.path()).expect("canonicalize root"); let manifest = RunnerManifest { runtime_context: RuntimeContext { @@ -3201,8 +3195,8 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let source = dir.path().join("tool.py"); std::fs::write(&source, "VALUE = 1\n").expect("write source file"); - let source = source.canonicalize().expect("canonicalize source"); - let root = dir.path().canonicalize().expect("canonicalize root"); + let source = dunce::canonicalize(&source).expect("canonicalize source"); + let root = dunce::canonicalize(dir.path()).expect("canonicalize root"); let manifest = RunnerManifest { runtime_context: RuntimeContext { @@ -3246,8 +3240,8 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let source = dir.path().join("tool.py"); std::fs::write(&source, "VALUE = 1\n").expect("write source file"); - let source = source.canonicalize().expect("canonicalize source"); - let root = dir.path().canonicalize().expect("canonicalize root"); + let source = dunce::canonicalize(&source).expect("canonicalize source"); + let root = dunce::canonicalize(dir.path()).expect("canonicalize root"); let manifest = RunnerManifest { runtime_context: RuntimeContext { @@ -3292,8 +3286,8 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let source = dir.path().join("tool.py"); std::fs::write(&source, "VALUE = 1\n").expect("write source file"); - let source = source.canonicalize().expect("canonicalize source"); - let root = dir.path().canonicalize().expect("canonicalize root"); + let source = dunce::canonicalize(&source).expect("canonicalize source"); + let root = dunce::canonicalize(dir.path()).expect("canonicalize root"); let manifest = RunnerManifest { runtime_context: RuntimeContext { @@ -3341,8 +3335,8 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let source = dir.path().join(filename); std::fs::write(&source, "VALUE = 1\n").expect("write source file"); - let source = source.canonicalize().expect("canonicalize source"); - let root = dir.path().canonicalize().expect("canonicalize root"); + let source = dunce::canonicalize(&source).expect("canonicalize source"); + let root = dunce::canonicalize(dir.path()).expect("canonicalize root"); let manifest = RunnerManifest { runtime_context: RuntimeContext { @@ -3545,8 +3539,8 @@ mod tests { let mut python = BTreeSet::new(); collect_from_dir(&root, &mut js_like, &mut python).expect("collect sources"); - let inside = inside.canonicalize().expect("canonicalize inside"); - let outside = outside.canonicalize().expect("canonicalize outside"); + let inside = dunce::canonicalize(&inside).expect("canonicalize inside"); + let outside = dunce::canonicalize(&outside).expect("canonicalize outside"); assert!(js_like.contains(&inside)); assert!( !js_like.contains(&outside), @@ -3585,6 +3579,37 @@ mod tests { ); } + #[test] + fn collect_classified_files_yields_non_verbatim_paths() { + // On Windows `Path::canonicalize` emits `\\?\` verbatim paths the Python + // runner can't reconcile against a clean cwd; `dunce` must strip them. + let dir = tempfile::tempdir().expect("tempdir"); + let source = dir.path().join("scorer.py"); + std::fs::write(&source, "x = 1\n").expect("write source"); + + let classified = + collect_classified_files(std::slice::from_ref(&source)).expect("collect classified"); + assert_eq!(classified.python.len(), 1, "expected the python source"); + + let collected = &classified.python[0]; + assert!(collected.is_file(), "collected path must resolve to a file"); + assert!( + !collected.to_string_lossy().starts_with(r"\\?\"), + "collected path must not carry a verbatim prefix, got {}", + collected.display() + ); + + // Allowed roots must be verbatim-free too, so the runner's clean + // cwd-based comparisons keep matching. + for root in &classified.allowed_roots { + assert!( + !root.to_string_lossy().starts_with(r"\\?\"), + "allowed root must not carry a verbatim prefix, got {}", + root.display() + ); + } + } + fn test_base_args() -> BaseArgs { BaseArgs { json: false,