Skip to content
Draft
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ Behavior is tuned via a `Policy`:
```rust
#[derive(Serialize, Deserialize)]
pub struct Policy {
pub allow_intranet_multi_label: bool,
pub allow_intranet_single_label: bool,
pub allow_private_suffix: bool,
pub allowed_schemes: BTreeSet<String>,
pub allow_file_paths: bool,
}
```

Expand Down
161 changes: 161 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ pub struct Policy {
pub allow_intranet_single_label: bool,
pub allow_private_suffix: bool,
pub allowed_schemes: BTreeSet<String>,
#[serde(default)]
pub allow_file_paths: bool,
}

impl Default for Policy {
Expand All @@ -114,6 +116,7 @@ impl Default for Policy {
allow_intranet_single_label: false,
allow_private_suffix: true,
allowed_schemes: allowed,
allow_file_paths: false,
}
}
}
Expand Down Expand Up @@ -226,6 +229,13 @@ pub fn classify_with_db(input: &str, policy: &Policy, db: &dyn SuffixDb) -> Deci
return nav;
}

// File path, e.g. "C:\Users\Username\Documents\file.html"
if policy.allow_file_paths {
if let Some(url) = is_file_path(original) {
return Decision::Navigate { url };
}
}

// Fallback
Decision::Search { query: original.to_string() }
}
Expand Down Expand Up @@ -429,6 +439,54 @@ fn host_like_valid(host: &str) -> bool {
true
}

#[cfg(target_os = "windows")]
fn is_file_path(input: &str) -> Option<String> {
// This is a copy of the logic .NET's Uri class uses to determine if a string is a file path.

let bytes = input.as_bytes();

if bytes.len() < 3 {
return None;
}

// Drive-based path, e.g. C:\foo\bar
if (bytes[1] == b':' || bytes[1] == b'|')
&& bytes[0].is_ascii_alphabetic()
&& (bytes[2] == b'\\' || bytes[2] == b'/')
{
// Normalize the path: convert backslashes to forward slashes and pipe to colon
let normalized = input.replace('\\', "/").replace('|', ":");
return Some(format!("file:///{}", normalized));
}

// UNC-based path, e.g. \\host\foo\bar
if (bytes[0] == b'\\' || bytes[0] == b'/')
&& (bytes[1] == b'\\' || bytes[1] == b'/')
{
// Normalize the path: convert backslashes to forward slashes
// Remove leading slashes
let path = input.trim_start_matches(&['\\', '/'][..]);
let normalized = path.replace('\\', "/");
return Some(format!("file://{}", normalized));
}

None
}

#[cfg(target_os = "macos")]
fn is_file_path(input: &str) -> Option<String> {
if !input.is_empty() && input.as_bytes()[0] == b'/' {
Some(format!("file://{}", input))
} else {
None
}
}

#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn is_file_path(_input: &str) -> Option<String> {
None
}

#[cfg(feature = "real-psl")]
mod psl_buf {
use std::sync::OnceLock;
Expand Down Expand Up @@ -981,6 +1039,109 @@ mod tests {
enabled_test_cases.len(),
failures.join("\n")
);
#[cfg(target_os = "macos")]
#[test]
fn macos_file_paths_with_policy() {
let test_cases = vec![
("/etc/test.html", Some("file:///etc/test.html")),
("/etc/test", Some("file:///etc/test")),
("/", Some("file:///")),

("example.com", None),
("./relative/path", None),
("../parent/path", None),
("relative/path", None),
("http://example.com", None),
("file.txt", None),
("~/Documents/file.txt", None),
];

for (input, expected_url) in &test_cases {
let mut p = Policy::default();
p.allow_file_paths = false;

if expected_url.is_some() {
let result = classify(input, &p);
assert!(matches!(result, Decision::Search { .. }),
"Expected Search for '{}' when allow_file_paths=false, got {:?}", input, result);
}

p.allow_file_paths = true;

if let Some(expected) = expected_url {
let result = classify(input, &p);
match result {
Decision::Navigate { ref url } => {
assert_eq!(url, expected,
"Expected '{}' for input '{}', got '{}'", expected, input, url);
},
Decision::Search { ref query } => {
panic!("Expected Navigate for '{}' when allow_file_paths=true, got Search with query '{}'", input, query);
}
}
} else {
let result = classify(input, &p);
match result {
Decision::Navigate { .. } | Decision::Search { .. } => {},
}
}
}
}

#[cfg(target_os = "windows")]
#[test]
fn windows_file_paths_with_policy() {
let test_cases = vec![
(r"C:\foo\bar.html", Some("file:///C:/foo/bar.html")),
("C:/foo/bar.html", Some("file:///C:/foo/bar.html")),
(r"C|\foo\bar.html", Some("file:///C:/foo/bar.html")),
("C|/foo/bar.html", Some("file:///C:/foo/bar.html")),
(r"c:\foo\bar.html", Some("file:///c:/foo/bar.html")),
("c:/foo/bar.html", Some("file:///c:/foo/bar.html")),
(r"c|\foo\bar.html", Some("file:///c:/foo/bar.html")),
("c|/foo/bar.html", Some("file:///c:/foo/bar.html")),
(r"\\foo\bar.html", Some("file://foo/bar.html")),

("example.com", None),
("C:", None),
("c:", None),
("C", None),
("1:2:3", None),
("::\\test", None),
("http://example.com", None),
("/single/slash/path", None),
(r"\single\slash", None),
];

for (input, expected_url) in &test_cases {
let mut p = Policy::default();
p.allow_file_paths = false;

if expected_url.is_some() {
let result = classify(input, &p);
assert!(matches!(result, Decision::Search { .. }),
"Expected Search for '{}' when allow_file_paths=false, got {:?}", input, result);
}

p.allow_file_paths = true;

if let Some(expected) = expected_url {
let result = classify(input, &p);
match result {
Decision::Navigate { ref url } => {
assert_eq!(url, expected,
"Expected '{}' for input '{}', got '{}'", expected, input, url);
},
Decision::Search { ref query } => {
panic!("Expected Navigate for '{}' when allow_file_paths=true, got Search with query '{}'", input, query);
}
}
} else {
let result = classify(input, &p);
match result {
Decision::Navigate { .. } | Decision::Search { .. } => {},
}
}
}
}
}
Expand Down
Loading