diff --git a/Cargo.lock b/Cargo.lock index be6f0fd..3bfa6d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,7 @@ checksum = "199c66ffd17ee1a948904d33f3d3f364573951c1f9fb3f859bfe7770bf33862a" dependencies = [ "deno_error_macro", "libc", + "url", ] [[package]] @@ -421,9 +422,9 @@ dependencies = [ [[package]] name = "sys_traits" -version = "0.1.0" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72001c0d1b690f17bb18ea7960821389199fd59ce6784f883fc76d0f3fbb6236" +checksum = "5b46ac05dfbe9fd3a9703eff20e17f5b31e7b6a54daf27a421dcd56c7a27ecdd" dependencies = [ "getrandom", "parking_lot", diff --git a/Cargo.toml b/Cargo.toml index 2465f28..d2e8689 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/denoland/deno_path_util" percent-encoding = "2.3.0" thiserror = "2" sys_traits.workspace = true -deno_error = "0.5.2" +deno_error = { version = "0.5.2", features = ["url"] } url = "2.5.1" [dev-dependencies] @@ -25,4 +25,4 @@ tempfile = "3.4.0" members = ["."] [workspace.dependencies] -sys_traits = "0.1.0" +sys_traits = "0.1.7" diff --git a/src/fs.rs b/src/fs.rs index d5864b3..3a7daf1 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -107,7 +107,7 @@ pub fn atomic_write_file< data: &[u8], mode: u32, ) -> std::io::Result<()> { - let mut options = OpenOptions::write(); + let mut options = OpenOptions::new_write(); options.mode = Some(mode); let mut file = sys.fs_open(temp_file_path, &options)?; file.write_all(data)?; diff --git a/src/lib.rs b/src/lib.rs index 3951d47..1435310 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ #![deny(clippy::unused_async)] #![deny(clippy::unnecessary_wraps)] +use deno_error::JsError; use std::path::Component; use std::path::Path; use std::path::PathBuf; @@ -174,7 +175,7 @@ pub fn normalize_path>(path: P) -> PathBuf { inner(path.as_ref()) } -#[derive(Debug, Error, deno_error::JsError)] +#[derive(Debug, Clone, Error, deno_error::JsError)] #[class(uri)] #[error("Could not convert path to URL.\n Path: {0}")] pub struct PathToUrlError(pub PathBuf); @@ -312,6 +313,75 @@ pub fn strip_unc_prefix(path: PathBuf) -> PathBuf { } } +/// Returns true if the input string starts with a sequence of characters +/// that could be a valid URI scheme, like 'https:', 'git+ssh:' or 'data:'. +/// +/// According to RFC 3986 (https://tools.ietf.org/html/rfc3986#section-3.1), +/// a valid scheme has the following format: +/// scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) +/// +/// We additionally require the scheme to be at least 2 characters long, +/// because otherwise a windows path like c:/foo would be treated as a URL, +/// while no schemes with a one-letter name actually exist. +pub fn specifier_has_uri_scheme(specifier: &str) -> bool { + let mut chars = specifier.chars(); + let mut len = 0usize; + // The first character must be a letter. + match chars.next() { + Some(c) if c.is_ascii_alphabetic() => len += 1, + _ => return false, + } + // Second and following characters must be either a letter, number, + // plus sign, minus sign, or dot. + loop { + match chars.next() { + Some(c) if c.is_ascii_alphanumeric() || "+-.".contains(c) => len += 1, + Some(':') if len >= 2 => return true, + _ => return false, + } + } +} + +#[derive(Debug, Clone, Error, JsError)] +pub enum ResolveUrlOrPathError { + #[error(transparent)] + #[class(inherit)] + UrlParse(url::ParseError), + #[error(transparent)] + #[class(inherit)] + PathToUrl(PathToUrlError), +} + +/// Takes a string representing either an absolute URL or a file path, +/// as it may be passed to deno as a command line argument. +/// The string is interpreted as a URL if it starts with a valid URI scheme, +/// e.g. 'http:' or 'file:' or 'git+ssh:'. If not, it's interpreted as a +/// file path; if it is a relative path it's resolved relative to passed +/// `current_dir`. +pub fn resolve_url_or_path( + specifier: &str, + current_dir: &Path, +) -> Result { + if specifier_has_uri_scheme(specifier) { + Url::parse(specifier).map_err(ResolveUrlOrPathError::UrlParse) + } else { + resolve_path(specifier, current_dir) + .map_err(ResolveUrlOrPathError::PathToUrl) + } +} + +/// Converts a string representing a relative or absolute path into a +/// ModuleSpecifier. A relative path is considered relative to the passed +/// `current_dir`. +pub fn resolve_path( + path_str: impl AsRef, + current_dir: &Path, +) -> Result { + let path = current_dir.join(path_str); + let path = normalize_path(path); + url_from_file_path(&path) +} + pub fn get_atomic_path(sys: &impl SystemRandom, path: &Path) -> PathBuf { let rand = gen_rand_path_component(sys); let extension = format!("{rand}.tmp"); @@ -514,4 +584,141 @@ mod tests { assert_eq!(atomic_path.parent().unwrap(), path.parent().unwrap()); assert_eq!(atomic_path.file_name().unwrap(), "c.3d3d3d3d.tmp"); } + + #[test] + fn test_specifier_has_uri_scheme() { + let tests = vec![ + ("http://foo.bar/etc", true), + ("HTTP://foo.bar/etc", true), + ("http:ftp:", true), + ("http:", true), + ("hTtP:", true), + ("ftp:", true), + ("mailto:spam@please.me", true), + ("git+ssh://git@github.com/denoland/deno", true), + ("blob:https://whatwg.org/mumbojumbo", true), + ("abc.123+DEF-ghi:", true), + ("abc.123+def-ghi:@", true), + ("", false), + (":not", false), + ("http", false), + ("c:dir", false), + ("X:", false), + ("./http://not", false), + ("1abc://kinda/but/no", false), + ("schluẞ://no/more", false), + ]; + + for (specifier, expected) in tests { + let result = specifier_has_uri_scheme(specifier); + assert_eq!(result, expected); + } + } + + #[test] + fn test_resolve_url_or_path() { + // Absolute URL. + let mut tests: Vec<(&str, String)> = vec![ + ( + "http://deno.land/core/tests/006_url_imports.ts", + "http://deno.land/core/tests/006_url_imports.ts".to_string(), + ), + ( + "https://deno.land/core/tests/006_url_imports.ts", + "https://deno.land/core/tests/006_url_imports.ts".to_string(), + ), + ]; + + // The local path tests assume that the cwd is the deno repo root. Note + // that we can't use `cwd` in miri tests, so we just use `/miri` instead. + let cwd = if cfg!(miri) { + PathBuf::from("/miri") + } else { + std::env::current_dir().unwrap() + }; + let cwd_str = cwd.to_str().unwrap(); + + if cfg!(target_os = "windows") { + // Absolute local path. + let expected_url = "file:///C:/deno/tests/006_url_imports.ts"; + tests.extend(vec![ + ( + r"C:/deno/tests/006_url_imports.ts", + expected_url.to_string(), + ), + ( + r"C:\deno\tests\006_url_imports.ts", + expected_url.to_string(), + ), + ( + r"\\?\C:\deno\tests\006_url_imports.ts", + expected_url.to_string(), + ), + // Not supported: `Url::from_file_path()` fails. + // (r"\\.\C:\deno\tests\006_url_imports.ts", expected_url.to_string()), + // Not supported: `Url::from_file_path()` performs the wrong conversion. + // (r"//./C:/deno/tests/006_url_imports.ts", expected_url.to_string()), + ]); + + // Rooted local path without drive letter. + let expected_url = format!( + "file:///{}:/deno/tests/006_url_imports.ts", + cwd_str.get(..1).unwrap(), + ); + tests.extend(vec![ + (r"/deno/tests/006_url_imports.ts", expected_url.to_string()), + (r"\deno\tests\006_url_imports.ts", expected_url.to_string()), + ( + r"\deno\..\deno\tests\006_url_imports.ts", + expected_url.to_string(), + ), + (r"\deno\.\tests\006_url_imports.ts", expected_url), + ]); + + // Relative local path. + let expected_url = format!( + "file:///{}/tests/006_url_imports.ts", + cwd_str.replace('\\', "/") + ); + tests.extend(vec![ + (r"tests/006_url_imports.ts", expected_url.to_string()), + (r"tests\006_url_imports.ts", expected_url.to_string()), + (r"./tests/006_url_imports.ts", (*expected_url).to_string()), + (r".\tests\006_url_imports.ts", (*expected_url).to_string()), + ]); + + // UNC network path. + let expected_url = "file://server/share/deno/cool"; + tests.extend(vec![ + (r"\\server\share\deno\cool", expected_url.to_string()), + (r"\\server/share/deno/cool", expected_url.to_string()), + // Not supported: `Url::from_file_path()` performs the wrong conversion. + // (r"//server/share/deno/cool", expected_url.to_string()), + ]); + } else { + // Absolute local path. + let expected_url = "file:///deno/tests/006_url_imports.ts"; + tests.extend(vec![ + ("/deno/tests/006_url_imports.ts", expected_url.to_string()), + ("//deno/tests/006_url_imports.ts", expected_url.to_string()), + ]); + + // Relative local path. + let expected_url = format!("file://{cwd_str}/tests/006_url_imports.ts"); + tests.extend(vec![ + ("tests/006_url_imports.ts", expected_url.to_string()), + ("./tests/006_url_imports.ts", expected_url.to_string()), + ( + "tests/../tests/006_url_imports.ts", + expected_url.to_string(), + ), + ("tests/./006_url_imports.ts", expected_url), + ]); + } + + for (specifier, expected_url) in tests { + let url = resolve_url_or_path(specifier, &cwd).unwrap().to_string(); + assert_eq!(url, expected_url); + } + } }