diff --git a/apps/browser-extension/src/utils/itemMatcher/ItemMatcher.ts b/apps/browser-extension/src/utils/itemMatcher/ItemMatcher.ts index 1f00ba01e..0e54f4716 100644 --- a/apps/browser-extension/src/utils/itemMatcher/ItemMatcher.ts +++ b/apps/browser-extension/src/utils/itemMatcher/ItemMatcher.ts @@ -61,12 +61,14 @@ export async function filterItems( await ensureInit(); // Map Items to the format expected by the WASM filter + const credentials = items.map(item => ({ + Id: item.Id, + ItemName: item.Name ?? '', + ItemUrls: getFieldValues(item, FieldKey.LoginUrl) + })); + const result = wasmFilterItems({ - credentials: items.map(item => ({ - Id: item.Id, - ItemName: item.Name ?? '', - ItemUrls: getFieldValues(item, FieldKey.LoginUrl) - })), + credentials, current_url: currentUrl, page_title: pageTitle, matching_mode: matchingMode diff --git a/core/rust/src/credential_matcher/domain.rs b/core/rust/src/credential_matcher/domain.rs index b696a5c29..3fc66a73e 100644 --- a/core/rust/src/credential_matcher/domain.rs +++ b/core/rust/src/credential_matcher/domain.rs @@ -131,7 +131,8 @@ pub fn extract_domain_with_port(url: &str) -> DomainWithPort { let mut domain = url.to_lowercase(); - // Check if it has a protocol + // Check if it has a protocol - this is important for allowing single-word hostnames + // like "http://plex" or "https://nas" which are common in self-hosted/homelab setups let has_protocol = domain.starts_with("http://") || domain.starts_with("https://"); // If no protocol and starts with TLD + dot, it's likely an app package name @@ -179,15 +180,18 @@ pub fn extract_domain_with_port(url: &str) -> DomainWithPort { None }; - // Basic domain validation - must contain at least one dot and valid characters - if !domain.contains('.') { + // Domain validation: + // - If URL had a protocol (http:// or https://), allow single-word hostnames + // like "localhost", "plex", "nas", "router" - common in self-hosted/homelab setups + // - If no protocol, require at least one dot to distinguish from random text + if !domain.contains('.') && !has_protocol { return DomainWithPort { domain: String::new(), port: None, }; } - // Check for valid domain characters + // Check for valid domain characters (alphanumeric, dots, hyphens) if !domain .chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') @@ -198,7 +202,7 @@ pub fn extract_domain_with_port(url: &str) -> DomainWithPort { }; } - // Ensure valid domain structure + // Ensure valid domain structure (no leading/trailing dots, no consecutive dots) if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") { return DomainWithPort { domain: String::new(), @@ -206,6 +210,14 @@ pub fn extract_domain_with_port(url: &str) -> DomainWithPort { }; } + // Ensure domain is not empty after all processing + if domain.is_empty() { + return DomainWithPort { + domain: String::new(), + port: None, + }; + } + DomainWithPort { domain, port } } @@ -315,6 +327,62 @@ mod tests { // Invalid domains assert_eq!(extract_domain(""), ""); assert_eq!(extract_domain("nodot"), ""); + + // Single-word hostnames WITH protocol should be supported + // (common in self-hosted/homelab setups with local DNS or /etc/hosts) + assert_eq!(extract_domain("http://localhost"), "localhost"); + assert_eq!(extract_domain("https://localhost"), "localhost"); + assert_eq!(extract_domain("http://localhost/path"), "localhost"); + assert_eq!(extract_domain("http://localhost?query=1"), "localhost"); + assert_eq!(extract_domain("http://plex"), "plex"); + assert_eq!(extract_domain("https://nas"), "nas"); + assert_eq!(extract_domain("http://router"), "router"); + assert_eq!(extract_domain("http://homeassistant"), "homeassistant"); + assert_eq!(extract_domain("http://pihole/admin"), "pihole"); + + // Single-word hostnames WITHOUT protocol should NOT be accepted + // (to avoid matching random text as domains) + assert_eq!(extract_domain("localhost"), ""); + assert_eq!(extract_domain("plex"), ""); + assert_eq!(extract_domain("randomword"), ""); + } + + #[test] + fn test_extract_domain_single_word_hostname_with_port() { + // Single-word hostnames with port (common for self-hosted services) + assert_eq!(extract_domain("http://localhost:8080"), "localhost"); + assert_eq!(extract_domain("http://localhost:81"), "localhost"); + assert_eq!(extract_domain("http://localhost:3000/path"), "localhost"); + assert_eq!(extract_domain("http://plex:32400"), "plex"); + assert_eq!(extract_domain("https://nas:5001"), "nas"); + assert_eq!(extract_domain("http://router:8080/admin"), "router"); + + // Without protocol - should NOT work (could be ambiguous) + assert_eq!(extract_domain("localhost:8080"), ""); + assert_eq!(extract_domain("plex:32400"), ""); + + // Test DomainWithPort struct with localhost + let result = extract_domain_with_port("http://localhost:81"); + assert_eq!(result.domain, "localhost"); + assert_eq!(result.port, Some("81".to_string())); + assert_eq!(result.with_port(), "localhost:81"); + + let result = extract_domain_with_port("http://localhost:8080/path"); + assert_eq!(result.domain, "localhost"); + assert_eq!(result.port, Some("8080".to_string())); + + let result = extract_domain_with_port("http://localhost/path"); + assert_eq!(result.domain, "localhost"); + assert_eq!(result.port, None); + + // Test with other single-word hostnames + let result = extract_domain_with_port("http://plex:32400"); + assert_eq!(result.domain, "plex"); + assert_eq!(result.port, Some("32400".to_string())); + + let result = extract_domain_with_port("https://nas:5001/files"); + assert_eq!(result.domain, "nas"); + assert_eq!(result.port, Some("5001".to_string())); } #[test] diff --git a/core/rust/src/credential_matcher/tests.rs b/core/rust/src/credential_matcher/tests.rs index 051b2d030..2499593c0 100644 --- a/core/rust/src/credential_matcher/tests.rs +++ b/core/rust/src/credential_matcher/tests.rs @@ -876,3 +876,133 @@ fn test_ignore_port_ip_with_multiple_ports_and_no_port() { let small_android = filter_ignore_port(small_credentials.clone(), "https://192.168.1.10", ""); assert_eq!(small_android.len(), 2, "With ignore_port, both credentials match"); } + +/// [#41] - Localhost matching with ports (development scenarios) +/// Common scenario: developers running multiple services on localhost with different ports +#[test] +fn test_localhost_matching_with_ports() { + let credentials = vec![ + create_test_credential("Local API", "http://localhost:3000", "dev@api"), + create_test_credential("Local Frontend", "http://localhost:8080", "dev@frontend"), + create_test_credential("Local Backend", "http://localhost:81", "dev@backend"), + create_test_credential("Local No Port", "http://localhost", "dev@default"), + ]; + + // Visiting localhost:81 should only match the :81 credential + let matches_81 = filter(credentials.clone(), "http://localhost:81", ""); + assert_eq!(matches_81.len(), 1, "Should only return exact localhost:81 match"); + assert_eq!(matches_81[0].item_name.as_deref(), Some("Local Backend")); + + // Visiting localhost:3000 should only match the :3000 credential + let matches_3000 = filter(credentials.clone(), "http://localhost:3000", ""); + assert_eq!(matches_3000.len(), 1, "Should only return exact localhost:3000 match"); + assert_eq!(matches_3000[0].item_name.as_deref(), Some("Local API")); + + // Visiting localhost:8080 should only match the :8080 credential + let matches_8080 = filter(credentials.clone(), "http://localhost:8080", ""); + assert_eq!(matches_8080.len(), 1, "Should only return exact localhost:8080 match"); + assert_eq!(matches_8080[0].item_name.as_deref(), Some("Local Frontend")); + + // Visiting localhost (no port) should only match the no-port credential + let matches_no_port = filter(credentials.clone(), "http://localhost", ""); + assert_eq!(matches_no_port.len(), 1, "Should only return localhost without port match"); + assert_eq!(matches_no_port[0].item_name.as_deref(), Some("Local No Port")); + + // Visiting localhost with a port not in credentials should show domain matches (up to 3) + let matches_unknown_port = filter(credentials.clone(), "http://localhost:5000", ""); + assert_eq!(matches_unknown_port.len(), 3, "Should return domain matches when no exact port match"); +} + +/// [#42] - Localhost matching - exact URL stored +/// User's specific scenario: credential with exactly "http://localhost:81" should match +#[test] +fn test_localhost_exact_url_stored() { + let credentials = vec![ + create_test_credential("My Local Service", "http://localhost:81", "user@local"), + ]; + + // Should match when visiting exactly http://localhost:81 + let matches = filter(credentials.clone(), "http://localhost:81", ""); + assert_eq!(matches.len(), 1, "Should match exact localhost:81 URL"); + assert_eq!(matches[0].item_name.as_deref(), Some("My Local Service")); + + // Should also match with path + let matches_with_path = filter(credentials.clone(), "http://localhost:81/some/path", ""); + assert_eq!(matches_with_path.len(), 1, "Should match localhost:81 with path"); + + // Should also match with query string + let matches_with_query = filter(credentials.clone(), "http://localhost:81?debug=true", ""); + assert_eq!(matches_with_query.len(), 1, "Should match localhost:81 with query"); +} + +/// [#43] - Single-word hostname matching (homelab/self-hosted scenarios) +/// Common scenario: self-hosted services with local DNS or /etc/hosts entries +/// like "plex", "nas", "router", "homeassistant", etc. +#[test] +fn test_single_word_hostname_matching() { + let credentials = vec![ + create_test_credential("Plex Media Server", "http://plex:32400", "admin@plex"), + create_test_credential("Synology NAS", "https://nas:5001", "admin@nas"), + create_test_credential("Pi-hole", "http://pihole/admin", "admin@pihole"), + create_test_credential("Home Assistant", "http://homeassistant:8123", "admin@ha"), + create_test_credential("Router Admin", "http://router", "admin@router"), + ]; + + // Plex with exact port + let plex_matches = filter(credentials.clone(), "http://plex:32400", ""); + assert_eq!(plex_matches.len(), 1, "Should match plex:32400"); + assert_eq!(plex_matches[0].item_name.as_deref(), Some("Plex Media Server")); + + // Plex with path + let plex_path = filter(credentials.clone(), "http://plex:32400/web/index.html", ""); + assert_eq!(plex_path.len(), 1, "Should match plex:32400 with path"); + + // NAS with HTTPS + let nas_matches = filter(credentials.clone(), "https://nas:5001", ""); + assert_eq!(nas_matches.len(), 1, "Should match nas:5001"); + assert_eq!(nas_matches[0].item_name.as_deref(), Some("Synology NAS")); + + // Pi-hole with path + let pihole_matches = filter(credentials.clone(), "http://pihole/admin/index.php", ""); + assert_eq!(pihole_matches.len(), 1, "Should match pihole with path"); + assert_eq!(pihole_matches[0].item_name.as_deref(), Some("Pi-hole")); + + // Home Assistant + let ha_matches = filter(credentials.clone(), "http://homeassistant:8123/dashboard", ""); + assert_eq!(ha_matches.len(), 1, "Should match homeassistant:8123"); + assert_eq!(ha_matches[0].item_name.as_deref(), Some("Home Assistant")); + + // Router without port + let router_matches = filter(credentials.clone(), "http://router", ""); + assert_eq!(router_matches.len(), 1, "Should match router without port"); + assert_eq!(router_matches[0].item_name.as_deref(), Some("Router Admin")); + + // Router with different port should still match via domain + let router_port = filter(credentials.clone(), "http://router:8080", ""); + assert_eq!(router_port.len(), 1, "Should match router with different port via domain match"); +} + +/// [#44] - Single-word hostnames require protocol for extraction +/// Credentials stored WITH protocol should match current URLs with protocol. +/// Note: Browser always sends URLs with protocol, so this mainly affects +/// what URLs can be stored in credentials. +#[test] +fn test_single_word_hostname_extraction_requires_protocol() { + // Credential stored WITHOUT protocol - should NOT be matchable + let credentials_no_protocol = vec![ + create_test_credential("Plex No Protocol", "plex:32400", "admin@plex"), + ]; + + // Even with protocol in current URL, credential without protocol won't match + // because the credential URL "plex:32400" can't be extracted as a domain + let no_match = filter(credentials_no_protocol.clone(), "http://plex:32400", ""); + assert_eq!(no_match.len(), 0, "Credential without protocol should not be matchable"); + + // Credential stored WITH protocol - should match + let credentials_with_protocol = vec![ + create_test_credential("Plex With Protocol", "http://plex:32400", "admin@plex"), + ]; + + let with_match = filter(credentials_with_protocol.clone(), "http://plex:32400", ""); + assert_eq!(with_match.len(), 1, "Credential with protocol should match"); +}