Skip to content
Merged
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
73 changes: 71 additions & 2 deletions core/nectarlink-core/src/deck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,14 +225,54 @@ pub fn is_valid_snippet(text: &str) -> bool {
&& !text.chars().any(|c| c.is_control() && c != '\n' && c != '\t')
}

/// Validates that `path` is a non-empty `.exe` or `.lnk` file path on the PC.
/// Validates that `path` is a non-empty `.exe`, `.lnk`, `.desktop`, or `.appimage`
/// file path, or an absolute (or `~/`) Unix executable path excluding shells,
/// `.sh` scripts, and non-application system/temp directories.
pub fn is_valid_app_path(path: &str) -> bool {
let trimmed = path.trim();
if trimmed.is_empty() || trimmed.len() > MAX_PATH_BYTES || trimmed.chars().any(char::is_control) {
return false;
}
if trimmed.split(['/', '\\']).any(|seg| seg == "..") {
return false;
}
let lower = trimmed.to_ascii_lowercase();
(lower.ends_with(".exe") || lower.ends_with(".lnk")) && trimmed.len() > 4
for ext in [".exe", ".lnk", ".desktop", ".appimage"] {
if let Some(stem) = lower.strip_suffix(ext) {
let file_stem = stem.rsplit(['/', '\\']).next().unwrap_or(stem);
return !file_stem.is_empty();
}
}
if !trimmed.starts_with('/') && !trimmed.starts_with("~/") {
return false;
}
let Some((parent, name)) = trimmed.rsplit_once('/') else {
return false;
};
let lower_name = name.to_ascii_lowercase();
let is_shell =
matches!(lower_name.as_str(), "sh" | "bash" | "dash" | "zsh" | "fish" | "csh" | "tcsh" | "ksh");
if parent.is_empty()
|| parent == "~"
|| name.is_empty()
|| name == "."
|| name.contains('\\')
|| lower_name.ends_with(".sh")
|| is_shell
{
return false;
}
let parent_segments = parent.strip_prefix("~/").unwrap_or_else(|| &parent[1..]);
if parent_segments.split('/').any(|seg| seg.is_empty() || seg == ".") {
return false;
}
!matches!(parent, "/etc" | "/tmp" | "/var/tmp" | "/dev" | "/proc" | "/sys")
&& !parent.starts_with("/etc/")
&& !parent.starts_with("/tmp/")
&& !parent.starts_with("/var/tmp/")
&& !parent.starts_with("/dev/")
&& !parent.starts_with("/proc/")
&& !parent.starts_with("/sys/")
}

/// Validates a command string for `RunCommand`.
Expand Down Expand Up @@ -753,7 +793,36 @@ mod tests {
assert!(DeckAction::LaunchApp { path: r"C:\Users\me\Desktop\OBS Studio.LNK".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: r"C:\scripts\evil.bat".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: r"C:\scripts\evil.ps1".into() }.is_valid());

assert!(DeckAction::LaunchApp { path: "/usr/bin/firefox".into() }.is_valid());
assert!(DeckAction::LaunchApp { path: "org.mozilla.firefox.desktop".into() }.is_valid());
assert!(DeckAction::LaunchApp { path: "/opt/app/MyApp.AppImage".into() }.is_valid());
assert!(DeckAction::LaunchApp { path: "/home/alice/.local/bin/obsidian".into() }.is_valid());
assert!(DeckAction::LaunchApp { path: "~/.local/bin/firefox".into() }.is_valid());
assert!(DeckAction::LaunchApp { path: "/run/current-system/sw/bin/alacritty".into() }.is_valid());
assert!(DeckAction::LaunchApp { path: "/usr/games/steam".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/usr/local/bin/start.sh".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/usr/bin/sh".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/usr/bin/bash".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/home/alice/.local/bin/zsh".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "~/.local/bin/zsh".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/home/alice/.local/bin/deploy.sh".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "~/.local/bin/deploy.sh".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/etc/passwd".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/tmp/payload".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/var/tmp/sub/payload".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/dev/shm/payload".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "./payload".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/payload".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "~/payload".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "~/".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: ".exe".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: ".desktop".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "../bin/sh".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/usr/../etc/passwd".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "~/.local/../bin/firefox".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "relative/bin/firefox".into() }.is_valid());
assert!(!DeckAction::LaunchApp { path: "/".into() }.is_valid());

assert!(DeckAction::Shortcut { key: "m".into(), mods: vec![KeyMod::Ctrl, KeyMod::Shift] }.is_valid());
assert!(DeckAction::Shortcut { key: remote_keys::F5.into(), mods: vec![] }.is_valid());
Expand Down
33 changes: 32 additions & 1 deletion core/nectarlink-core/src/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,15 @@ impl DeviceFacts {
DeviceFacts { kind: info.kind, os: info.os.clone(), os_ver: info.os_ver.clone(), caps, power }
}

/// Creates baseline [`DeviceFacts`] for a desktop or phone OS.
pub fn for_os(os: &str) -> Self {
let (kind, power) = match os {
"android" => (DeviceKind::Phone, PowerLevel::Basic),
_ => (DeviceKind::Desktop, PowerLevel::NotApplicable),
};
DeviceFacts { kind, os: os.to_owned(), os_ver: String::new(), caps: BTreeSet::new(), power }
}

fn role(&self) -> Option<Role> {
role_from(self.kind, &self.os)
}
Expand Down Expand Up @@ -308,7 +317,7 @@ fn role_from(kind: DeviceKind, os: &str) -> Option<Role> {
DeviceKind::Desktop | DeviceKind::Laptop => Some(Role::Desktop),
DeviceKind::Unknown => match os {
"android" => Some(Role::Phone),
"windows" | "macos" => Some(Role::Desktop),
"windows" | "macos" | "linux" => Some(Role::Desktop),
_ => None,
},
}
Expand Down Expand Up @@ -1318,6 +1327,28 @@ mod tests {
fn unknown_kinds_fall_back_to_the_os() {
let phone = facts(DeviceKind::Unknown, "android", "16", PowerLevel::Basic, BASIC_PHONE);
assert_eq!(matrix(&phone).state("device.find_phone"), Some(FeatureState::Available));

let linux_unknown = facts(DeviceKind::Unknown, "linux", "6.12", PowerLevel::NotApplicable, PC);
assert_eq!(linux_unknown.role(), Some(Role::Desktop));
assert_eq!(
matrix_with(&phone, &linux_unknown, &HashMap::new()).state("device.find_phone"),
Some(FeatureState::Available)
);
}

#[test]
fn linux_desktop_maps_to_desktop_role_and_matches_windows_feature_matrix() {
let mut linux_pc = DeviceFacts::for_os("linux");
assert_eq!(linux_pc.kind, DeviceKind::Desktop);
assert_eq!(linux_pc.role(), Some(Role::Desktop));
linux_pc.os_ver = "6.12".into();
linux_pc.caps = PC.iter().map(|c| (*c).to_owned()).collect();

let phone = facts(DeviceKind::Phone, "android", "16", PowerLevel::Basic, BASIC_PHONE);
let toggles = HashMap::new();
let m_linux = matrix_with(&phone, &linux_pc, &toggles);
let m_win = matrix_with(&phone, &pc(), &toggles);
assert_eq!(m_linux, m_win);
}

#[test]
Expand Down
16 changes: 13 additions & 3 deletions core/nectarlink-core/src/handoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ fn find_url_or_geo_token(text: &str) -> Option<String> {
return None;
}
// Check if the whole string is a geo: URI (possibly with spaces in ?q= that we encode).
if trimmed.len() > 4 && trimmed[..4].eq_ignore_ascii_case("geo:") {
if trimmed.len() > 4 && trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("geo:")) {
let first_line = trimmed.lines().next().unwrap_or(trimmed).trim();
let normalized: String =
first_line.chars().filter(|c| !c.is_control()).map(|c| if c == ' ' { '+' } else { c }).collect();
Expand Down Expand Up @@ -440,8 +440,9 @@ fn extract_http_map_query(url: &str) -> Option<String> {
let after_scheme = before_frag.split("://").nth(1)?;
let path = after_scheme.find('/').map(|i| &after_scheme[i..])?;
for marker in ["/maps/place/", "/maps/search/"] {
if let Some(idx) = path.to_ascii_lowercase().find(marker) {
let rest = &path[idx + marker.len()..];
if let Some(idx) = path.to_ascii_lowercase().find(marker)
&& let Some(rest) = path.get(idx + marker.len()..)
{
let seg = rest.split('/').next().unwrap_or("");
if !seg.is_empty() && !seg.starts_with('@') {
let decoded = clean_map_label(&percent_decode(seg));
Expand Down Expand Up @@ -610,6 +611,15 @@ mod tests {
assert_eq!(apple.kind, HandoffKind::MapLocation);
}

#[test]
fn handles_multibyte_utf8_at_char_boundaries_without_panicking() {
// '—' is 3 bytes at bytes 3..6 when preceded by 3 ASCII bytes ("abc—def").
assert!(extract_handoff_link("abc—def").is_none());
assert!(extract_handoff_link("🚀✨🔥").is_none());
let link = extract_handoff_link("abc— https://nectarlink.app/docs").unwrap();
assert_eq!(link.url, "https://nectarlink.app/docs");
}

#[test]
fn extracts_and_updates_video_timestamps() {
let yt =
Expand Down
17 changes: 17 additions & 0 deletions core/nectarlink-core/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ fn write_atomically(path: &Path, data: &[u8]) -> io::Result<()> {
let tmp = path.with_extension("tmp");
{
let mut file = fs::File::create(&tmp)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
file.write_all(data)?;
file.sync_all()?;
}
Expand Down Expand Up @@ -262,4 +267,16 @@ mod tests {
assert!(!a.contains("9774d56d682e549c"), "the raw ID never appears");
assert_eq!(install_id_from(" "), None);
}

#[cfg(unix)]
#[test]
fn key_file_has_0600_permissions_on_unix() {
use std::os::unix::fs::PermissionsExt;

let dir = tempfile::tempdir().unwrap();
let protector = default_protector();
load_or_create(dir.path(), protector.as_ref()).unwrap();
let meta = fs::metadata(dir.path().join(FILE_NAME)).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
}
Loading
Loading