diff --git a/src/functions/push.rs b/src/functions/push.rs index 12862a3..ed7236b 100644 --- a/src/functions/push.rs +++ b/src/functions/push.rs @@ -1448,6 +1448,12 @@ fn validate_python_bundle( } let source_list: Vec = sources.into_iter().collect(); + let archive_root = infer_python_archive_root(entry_module, source_path)?; + for source in &source_list { + let archive_path = archive_source_path(source, &archive_root)?; + validate_python_archive_path(&archive_path)?; + } + if !entry_module_matches_sources(entry_module, &source_list, allowed_roots) { bail!( "python_bundle.entry_module '{}' does not match any bundled source module for '{}'", @@ -1456,17 +1462,6 @@ fn validate_python_bundle( ); } - let archive_root = infer_python_archive_root(entry_module, source_path)?; - for source in &source_list { - if !source.starts_with(&archive_root) { - bail!( - "python source '{}' is outside inferred archive root '{}'", - source.display(), - archive_root.display() - ); - } - } - Ok(ValidatedPythonBundle { entry_module: entry_module.to_string(), sources: source_list, @@ -1677,6 +1672,25 @@ fn archive_source_path(source: &Path, archive_root: &Path) -> Result { Ok(rel.to_path_buf()) } +fn validate_python_archive_path(archive_path: &Path) -> Result<()> { + for component in archive_path.iter() { + let component = component.to_str().ok_or_else(|| { + anyhow!( + "python bundle source path contains invalid utf-8: {}", + archive_path.display() + ) + })?; + if component.chars().any(char::is_whitespace) { + bail!( + "python bundle source path '{}' contains whitespace in path component '{}'; rename the file or directory before running `bt functions push`", + archive_path.display(), + component + ); + } + } + Ok(()) +} + fn copy_directory_files_into_stage(source_root: &Path, stage_root: &Path) -> Result<()> { let files = collect_regular_files_recursive(source_root)?; for file in files { @@ -3223,6 +3237,66 @@ mod tests { .contains("does not match any bundled source module")); } + fn assert_whitespace_in_filename_rejected(filename: &str, entry_module: &str) { + 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 manifest = RunnerManifest { + runtime_context: RuntimeContext { + runtime: "python".to_string(), + version: "3.12.0".to_string(), + }, + files: vec![ManifestFile { + source_file: source.to_string_lossy().to_string(), + entries: vec![ManifestEntry::Code(CodeEntry { + project_id: None, + project_name: None, + name: "Tool".to_string(), + slug: "tool".to_string(), + description: None, + function_type: Some("tool".to_string()), + if_exists: None, + metadata: None, + tags: None, + function_schema: None, + location: Some(serde_json::json!({"type":"function","index":0})), + preview: None, + })], + python_bundle: Some(PythonBundle { + entry_module: entry_module.to_string(), + sources: vec![source.to_string_lossy().to_string()], + }), + }], + baseline_dep_versions: vec![], + }; + + let err = validate_manifest_paths( + &manifest, + std::slice::from_ref(&source), + SourceLanguage::Python, + std::slice::from_ref(&root), + ) + .expect_err("must fail"); + assert_eq!(err.reason, HardFailureReason::ManifestSchemaInvalid); + assert!(err + .message + .contains("contains whitespace in path component")); + } + + #[test] + fn validate_manifest_paths_rejects_python_bundle_with_whitespace_in_filename() { + assert_whitespace_in_filename_rejected("my tool.py", "my tool"); + } + + #[cfg(unix)] + #[test] + fn validate_manifest_paths_rejects_python_bundle_with_leading_whitespace_in_filename() { + assert_whitespace_in_filename_rejected(" tool.py", " tool"); + } + #[test] fn code_function_data_includes_non_empty_preview() { let runtime = RuntimeContext {