From e3e63cad040ff4bd5eefc439d9be065f2b4f8a43 Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Wed, 15 Jul 2026 14:46:06 +0800 Subject: [PATCH] fix(security): harden remote pairing PIN persistence (#821) * fix(security): harden remote pairing PIN persistence * fix(security): harden pairing PIN persistence * test(ci): accept Windows line endings in PIN contract * fix(linux): declare libc for secure PIN opens --------- Co-authored-by: sim --- .github/workflows/ci.yml | 3 + openless-all/app/package.json | 1 + ...pin-persistence-security-contract.test.mjs | 74 +++ openless-all/app/src-tauri/Cargo.toml | 8 +- .../app/src-tauri/backend-tests/Cargo.lock | 1 + .../app/src-tauri/backend-tests/Cargo.toml | 2 + .../backend-tests/tests/backend_rust.rs | 2 + .../src-tauri/src/commands/remote_input.rs | 2 +- openless-all/app/src-tauri/src/coordinator.rs | 92 ++- .../app/src-tauri/src/remote_server/mod.rs | 42 +- .../src/remote_server/pin_persistence.rs | 588 ++++++++++++++++++ 11 files changed, 778 insertions(+), 37 deletions(-) create mode 100644 openless-all/app/scripts/pin-persistence-security-contract.test.mjs create mode 100644 openless-all/app/src-tauri/src/remote_server/pin_persistence.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ceaec3f..3d8ac12c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,9 @@ jobs: - name: Check Windows UI configuration contract run: npm run check:windows-ui-config + - name: Check PIN persistence security contract + run: npm run check:pin-persistence-security + - name: Build frontend (tsc + vite) run: npm run build diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 4fb4cabb..f0d04611 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -22,6 +22,7 @@ "check:hotkey-side-modifiers": "tsx src/lib/hotkeySideModifiers.test.ts", "check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs", "check:windows-ui-config": "node scripts/windows-ui-config.test.mjs", + "check:pin-persistence-security": "node scripts/pin-persistence-security-contract.test.mjs", "check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs" }, "dependencies": { diff --git a/openless-all/app/scripts/pin-persistence-security-contract.test.mjs b/openless-all/app/scripts/pin-persistence-security-contract.test.mjs new file mode 100644 index 00000000..97f0cd75 --- /dev/null +++ b/openless-all/app/scripts/pin-persistence-security-contract.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" + +const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8") + +const [pinModule, coordinator, command, appCargo, backendCargo, backendTests, ci] = + await Promise.all([ + read("src-tauri/src/remote_server/pin_persistence.rs"), + read("src-tauri/src/coordinator.rs"), + read("src-tauri/src/commands/remote_input.rs"), + read("src-tauri/Cargo.toml"), + read("src-tauri/backend-tests/Cargo.toml"), + read("src-tauri/backend-tests/tests/backend_rust.rs"), + read("../../.github/workflows/ci.yml"), + ]) + +for (const token of ["O_NOFOLLOW", "O_NONBLOCK", "O_CLOEXEC"]) { + assert.match(pinModule, new RegExp(`custom_flags\\([^)]*${token}`), `Unix open must use ${token}`) +} +assert.match(pinModule, /file\.metadata\(\)/, "validation must fstat the opened file") +assert.match(pinModule, /file\.set_permissions\(/, "permission repair must use the opened file") +assert.match(pinModule, /\.take\(MAX_PIN_FILE_BYTES \+ 1\)/, "reads must remain bounded after fstat") + +for (const token of [ + "FILE_FLAG_OPEN_REPARSE_POINT", + "GetFileInformationByHandle", + "GetFileType", + "FILE_ATTRIBUTE_REPARSE_POINT", + "nNumberOfLinks", + "ReplaceFileW", + "MoveFileExW", +]) { + assert.match(pinModule, new RegExp(token), `Windows implementation must use ${token}`) +} +assert.doesNotMatch( + pinModule, + /remove_file\(path\)/, + "replacement must never delete the destination before installing the new PIN", +) +assert.match(pinModule, /backup_path/, "Windows replacement must retain a rollback path") + +for (const cargo of [appCargo, backendCargo]) { + assert.match(cargo, /"Win32_Storage_FileSystem"/, "Windows file APIs must be enabled") +} +assert.match( + backendTests, + /src\/remote_server\/pin_persistence\.rs/, + "the Rust-only Windows harness must execute PIN persistence tests", +) +assert.match( + ci, + /if: runner\.os == 'Windows'[\s\S]*cargo test --manifest-path src-tauri\/backend-tests\/Cargo\.toml/, + "Windows CI must execute the Rust-only PIN tests", +) + +const assertCoordinatorContract = (source) => { + const regenerate = source.match( + /pub fn regenerate_remote_pin[\s\S]*?\r?\n }\r?\n\r?\n #\[cfg\(not\(mobile\)\)\]/, + )?.[0] + assert.ok(regenerate, "Coordinator regenerate implementation must be present") + assert.match(regenerate, /-> Result/, "reset must surface persistence errors") + assert.match(regenerate, /persist_and_commit_remote_pin[\s\S]*save_pin[\s\S]*refresh_remote_server/, "reset must delegate persistence and state commit as one transaction") + const transaction = source.match( + /fn persist_and_commit_remote_pin[\s\S]*?\r?\n}\r?\n\r?\nimpl Coordinator/, + )?.[0] + assert.ok(transaction, "PIN reset transaction helper must be present") + assert.match(transaction, /persist\(&pin\)\?;[\s\S]*\*slot\.lock\(\) = Some\(pin\.clone\(\)\);[\s\S]*refresh\(\)/, "persist must succeed before memory commit and server refresh") +} + +assertCoordinatorContract(coordinator) +assertCoordinatorContract(coordinator.replace(/\r?\n/g, "\r\n")) +assert.match(command, /regenerate_remote_pin[\s\S]*-> Result/, "Tauri command must reject on reset failure") + +console.log("PIN persistence security contract passed") diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 9c11198a..44477792 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -88,6 +88,11 @@ version = "3.6.3" default-features = false features = ["windows-native"] +[target.'cfg(unix)'.dependencies] +# PIN persistence uses O_NOFOLLOW/O_NONBLOCK/O_CLOEXEC; macOS qwen-asr also +# uses libc::free for strings allocated by the C runtime. +libc = "0.2" + [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9" gtk = "0.18" @@ -113,8 +118,6 @@ coreaudio-sys = "0.2" objc2 = { version = "0.5", features = ["exception"] } objc2-foundation = "0.2" objc2-app-kit = "0.2" -# qwen-asr 用 malloc 分配返回字符串,必须用 C `free()` 释放。 -libc = "0.2" # 把胶囊窗口转成「非激活 NSPanel」,使其能叠在别的 app 的全屏 Space 之上。 # 普通 NSWindow 即便设了 collectionBehavior 也做不到(tauri#9556 / #11488)—— 必须是 # NSPanel。该插件做窗口 NSWindow→NSPanel subclass 转换;仅 macOS。 @@ -131,6 +134,7 @@ windows = { version = "0.58", features = [ "Win32_Graphics_Gdi", "Win32_Media_Audio", "Win32_Media_Audio_Endpoints", + "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Registry", diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index a10b693e..186b3180 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -1095,6 +1095,7 @@ dependencies = [ "cpal", "enigo", "global-hotkey", + "libc", "log", "once_cell", "parking_lot", diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.toml b/openless-all/app/src-tauri/backend-tests/Cargo.toml index d3dc05c1..35750522 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.toml +++ b/openless-all/app/src-tauri/backend-tests/Cargo.toml @@ -14,6 +14,7 @@ arboard = "3" cpal = "0.15" enigo = "0.2" global-hotkey = "0.6" +libc = "0.2" log = "0.4" once_cell = "1" parking_lot = "0.12" @@ -26,6 +27,7 @@ uuid = { version = "1", features = ["v4", "serde"] } [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.58", features = [ "Win32_Foundation", + "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", diff --git a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs index 583f411b..78c8f5ce 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs @@ -56,6 +56,8 @@ mod hotkey; #[cfg(not(target_os = "macos"))] #[path = "../../src/insertion.rs"] mod insertion; +#[path = "../../src/remote_server/pin_persistence.rs"] +mod pin_persistence; #[path = "../../src/recorder.rs"] mod recorder; #[path = "../../src/shortcut_binding.rs"] diff --git a/openless-all/app/src-tauri/src/commands/remote_input.rs b/openless-all/app/src-tauri/src/commands/remote_input.rs index dee8eaed..38251f1a 100644 --- a/openless-all/app/src-tauri/src/commands/remote_input.rs +++ b/openless-all/app/src-tauri/src/commands/remote_input.rs @@ -23,7 +23,7 @@ pub fn list_local_ips() -> Vec { } #[tauri::command] -pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> String { +pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> Result { coord.regenerate_remote_pin() } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 3b314542..4af648c0 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -574,6 +574,19 @@ impl Drop for CancellableRetranscribeGuard { } } +#[cfg(not(mobile))] +fn persist_and_commit_remote_pin( + slot: &Mutex>, + pin: String, + persist: impl FnOnce(&str) -> Result<(), String>, + refresh: impl FnOnce(), +) -> Result { + persist(&pin)?; + *slot.lock() = Some(pin.clone()); + refresh(); + Ok(pin) +} + impl Coordinator { pub fn new() -> Self { #[cfg(target_os = "windows")] @@ -1554,14 +1567,23 @@ impl Coordinator { } #[cfg(not(mobile))] - pub fn regenerate_remote_pin(self: &Arc) -> String { + pub fn regenerate_remote_pin(self: &Arc) -> Result { let pin = crate::remote_server::generate_pin(); - *self.inner.remote_pin.lock() = Some(pin.clone()); - if let Some(app) = self.inner.app.lock().clone() { - crate::remote_server::save_pin(&app, &pin); - } - self.refresh_remote_server(); - pin + let app = self + .inner + .app + .lock() + .clone() + .ok_or_else(|| "OpenLess app handle is unavailable".to_string())?; + persist_and_commit_remote_pin( + &self.inner.remote_pin, + pin, + |pin| { + crate::remote_server::save_pin(&app, pin) + .map_err(|error| format!("persist pairing PIN failed: {error}")) + }, + || self.refresh_remote_server(), + ) } #[cfg(not(mobile))] @@ -1604,12 +1626,24 @@ impl Coordinator { let Some(app) = app else { return; }; - let pin = { - let mut guard = coord.inner.remote_pin.lock(); - if guard.is_none() { - *guard = Some(crate::remote_server::load_or_create_pin(&app)); + let pin = if let Some(pin) = coord.inner.remote_pin.lock().clone() { + pin + } else { + match crate::remote_server::load_or_create_pin(&app) { + Ok(pin) => { + *coord.inner.remote_pin.lock() = Some(pin.clone()); + pin + } + Err(error) => { + let reason = format!("persist pairing PIN failed: {error}"); + let _ = app.emit( + "remote-input:error", + serde_json::json!({"reason": reason, "port": prefs.remote_input_port}), + ); + log::error!("[remote-input] {reason}"); + return; + } } - guard.clone().unwrap_or_default() }; let port = prefs.remote_input_port; match crate::remote_server::start(crate::remote_server::RemoteServerConfig { @@ -2469,6 +2503,40 @@ mod tests { Uuid::from_u128(n) } + #[test] + fn failed_remote_pin_persistence_keeps_memory_and_server_state() { + let slot = Mutex::new(Some("123456".to_string())); + let refreshed = std::sync::atomic::AtomicBool::new(false); + + let result = persist_and_commit_remote_pin( + &slot, + "654321".to_string(), + |_| Err("injected persistence failure".to_string()), + || refreshed.store(true, Ordering::SeqCst), + ); + + assert_eq!(result.unwrap_err(), "injected persistence failure"); + assert_eq!(slot.lock().as_deref(), Some("123456")); + assert!(!refreshed.load(Ordering::SeqCst)); + } + + #[test] + fn successful_remote_pin_persistence_commits_memory_before_refresh() { + let slot = Mutex::new(Some("123456".to_string())); + let observed = Mutex::new(None::); + + let result = persist_and_commit_remote_pin( + &slot, + "654321".to_string(), + |_| Ok(()), + || *observed.lock() = slot.lock().clone(), + ); + + assert_eq!(result.as_deref(), Ok("654321")); + assert_eq!(slot.lock().as_deref(), Some("654321")); + assert_eq!(observed.lock().as_deref(), Some("654321")); + } + #[test] fn split_polish_translate_parses_both_sections() { let out = format!( diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index 21532a7e..1bc99734 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -131,30 +131,28 @@ fn pin_path(app: &AppHandle) -> Option { .map(|d| d.join("remote-input-pin.txt")) } -/// 读持久化的配对码;没有 / 无效则新生成并写盘。让配对码跨重启稳定 —— 否则每次启动 -/// 都重新随机一个,用户得反复回来找新码("配对码错误"的根因)。 -pub fn load_or_create_pin(app: &AppHandle) -> String { - if let Some(p) = pin_path(app) { - if let Ok(s) = std::fs::read_to_string(&p) { - let s = s.trim(); - if s.len() == 6 && s.bytes().all(|b| b.is_ascii_digit()) { - return s.to_string(); - } - } - } - let pin = generate_pin(); - save_pin(app, &pin); - pin +mod pin_persistence; + +/// 读持久化的配对码;没有 / 无效则新生成并写盘。让配对码跨重启稳定。 +pub fn load_or_create_pin(app: &AppHandle) -> std::io::Result { + let path = pin_path(app).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "OpenLess config directory is unavailable", + ) + })?; + pin_persistence::load_or_create_pin_at_path(&path, generate_pin) } -/// 写配对码到磁盘(用户点"重置配对码"时覆盖)。 -pub fn save_pin(app: &AppHandle, pin: &str) { - if let Some(p) = pin_path(app) { - if let Some(dir) = p.parent() { - let _ = std::fs::create_dir_all(dir); - } - let _ = std::fs::write(&p, pin); - } +/// 原子写配对码到磁盘;只有成功后调用方才能提交内存状态。 +pub fn save_pin(app: &AppHandle, pin: &str) -> std::io::Result<()> { + let path = pin_path(app).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "OpenLess config directory is unavailable", + ) + })?; + pin_persistence::persist_pin_atomically(&path, pin) } fn is_private_lan(ip: &Ipv4Addr) -> bool { diff --git a/openless-all/app/src-tauri/src/remote_server/pin_persistence.rs b/openless-all/app/src-tauri/src/remote_server/pin_persistence.rs new file mode 100644 index 00000000..6141c0e7 --- /dev/null +++ b/openless-all/app/src-tauri/src/remote_server/pin_persistence.rs @@ -0,0 +1,588 @@ +use std::io::{Read, Write}; +use std::path::Path; + +const MAX_PIN_FILE_BYTES: u64 = 64; + +pub(super) fn load_or_create_pin_at_path( + path: &Path, + generate_pin: impl FnOnce() -> String, +) -> std::io::Result { + if let Some(pin) = read_valid_persisted_pin(path)? { + return Ok(pin); + } + + let pin = generate_pin(); + persist_pin_atomically(path, &pin)?; + Ok(pin) +} + +fn read_valid_persisted_pin(path: &Path) -> std::io::Result> { + let file = match open_pin_file(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + read_valid_pin_from_file(file) +} + +#[cfg(unix)] +fn open_pin_file(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) +} + +#[cfg(target_os = "windows")] +fn open_pin_file(path: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + + std::fs::OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0) + .open(path) +} + +#[cfg(not(any(unix, target_os = "windows")))] +fn open_pin_file(path: &Path) -> std::io::Result { + std::fs::File::open(path) +} + +fn read_valid_pin_from_file(mut file: std::fs::File) -> std::io::Result> { + validate_and_repair_pin_file(&file)?; + let mut contents = Vec::with_capacity(MAX_PIN_FILE_BYTES as usize + 1); + std::io::Read::by_ref(&mut file) + .take(MAX_PIN_FILE_BYTES + 1) + .read_to_end(&mut contents)?; + if contents.len() as u64 > MAX_PIN_FILE_BYTES { + return Err(invalid_pin_file("pairing PIN file exceeds size limit")); + } + let Ok(contents) = std::str::from_utf8(&contents) else { + return Ok(None); + }; + let pin = contents.trim(); + if pin.len() != 6 || !pin.bytes().all(|byte| byte.is_ascii_digit()) { + return Ok(None); + } + Ok(Some(pin.to_string())) +} + +#[cfg(unix)] +fn validate_and_repair_pin_file(file: &std::fs::File) -> std::io::Result<()> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let metadata = file.metadata()?; + if !metadata.file_type().is_file() || metadata.nlink() != 1 { + return Err(invalid_pin_file( + "pairing PIN path must be a single-link regular file", + )); + } + if metadata.len() > MAX_PIN_FILE_BYTES { + return Err(invalid_pin_file("pairing PIN file exceeds size limit")); + } + if metadata.permissions().mode() & 0o777 != 0o600 { + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn validate_and_repair_pin_file(file: &std::fs::File) -> std::io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, GetFileType, BY_HANDLE_FILE_INFORMATION, + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_TYPE_DISK, + }; + + let handle = HANDLE(file.as_raw_handle()); + if unsafe { GetFileType(handle) } != FILE_TYPE_DISK { + return Err(invalid_pin_file("pairing PIN path must be a disk file")); + } + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + unsafe { GetFileInformationByHandle(handle, &mut information) }.map_err(windows_io_error)?; + let rejected_attributes = FILE_ATTRIBUTE_DIRECTORY.0 | FILE_ATTRIBUTE_REPARSE_POINT.0; + if information.dwFileAttributes & rejected_attributes != 0 || information.nNumberOfLinks != 1 { + return Err(invalid_pin_file( + "pairing PIN path must be a single-link regular file without reparse points", + )); + } + let file_size = ((information.nFileSizeHigh as u64) << 32) | information.nFileSizeLow as u64; + if file_size > MAX_PIN_FILE_BYTES { + return Err(invalid_pin_file("pairing PIN file exceeds size limit")); + } + Ok(()) +} + +#[cfg(not(any(unix, target_os = "windows")))] +fn validate_and_repair_pin_file(file: &std::fs::File) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.file_type().is_file() || metadata.len() > MAX_PIN_FILE_BYTES { + return Err(invalid_pin_file( + "pairing PIN path must be a bounded regular file", + )); + } + Ok(()) +} + +fn invalid_pin_file(message: &'static str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidData, message) +} + +pub(super) fn persist_pin_atomically(path: &Path, pin: &str) -> std::io::Result<()> { + persist_pin_atomically_with(path, pin, replace_pin_file) +} + +fn persist_pin_atomically_with( + path: &Path, + pin: &str, + replace: impl FnOnce(&Path, &Path) -> std::io::Result<()>, +) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + validate_existing_pin_path(path)?; + + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "remote-input-pin.txt".to_string()); + let temp_path = path.with_file_name(format!( + ".{file_name}.tmp-{}", + uuid::Uuid::new_v4().simple() + )); + + let result = (|| { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut temp = options.open(&temp_path)?; + temp.write_all(pin.as_bytes())?; + temp.sync_all()?; + validate_and_repair_pin_file(&temp)?; + drop(temp); + + replace(&temp_path, path)?; + sync_pin_parent(path)?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + result +} + +fn validate_existing_pin_path(path: &Path) -> std::io::Result { + match open_pin_file(path) { + Ok(file) => { + validate_and_repair_pin_file(&file)?; + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +#[cfg(unix)] +fn replace_pin_file(temp_path: &Path, path: &Path) -> std::io::Result<()> { + std::fs::rename(temp_path, path) +} + +#[cfg(target_os = "windows")] +fn replace_pin_file(temp_path: &Path, path: &Path) -> std::io::Result<()> { + if !validate_existing_pin_path(path)? { + return move_windows_file(temp_path, path); + } + + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ReplaceFileW, REPLACE_FILE_FLAGS}; + + let backup_path = path.with_file_name(format!( + ".{}.backup-{}", + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "remote-input-pin.txt".to_string()), + uuid::Uuid::new_v4().simple() + )); + let replaced = windows_wide_path(path); + let replacement = windows_wide_path(temp_path); + let backup = windows_wide_path(&backup_path); + let result = unsafe { + ReplaceFileW( + PCWSTR(replaced.as_ptr()), + PCWSTR(replacement.as_ptr()), + PCWSTR(backup.as_ptr()), + REPLACE_FILE_FLAGS(0), + None, + None, + ) + }; + match result { + Ok(()) => { + if let Err(error) = std::fs::remove_file(&backup_path) { + if error.kind() != std::io::ErrorKind::NotFound { + log::warn!( + "[remote-input] remove pairing PIN replacement backup failed at {}: {error}", + backup_path.display() + ); + } + } + Ok(()) + } + Err(replace_error) => { + rollback_windows_backup(&backup_path, path)?; + Err(windows_io_error(replace_error)) + } + } +} + +#[cfg(not(any(unix, target_os = "windows")))] +fn replace_pin_file(temp_path: &Path, path: &Path) -> std::io::Result<()> { + std::fs::rename(temp_path, path) +} + +#[cfg(target_os = "windows")] +fn move_windows_file(source: &Path, destination: &Path) -> std::io::Result<()> { + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}; + + let source = windows_wide_path(source); + let destination = windows_wide_path(destination); + unsafe { + MoveFileExW( + PCWSTR(source.as_ptr()), + PCWSTR(destination.as_ptr()), + MOVEFILE_WRITE_THROUGH, + ) + } + .map_err(windows_io_error) +} + +#[cfg(target_os = "windows")] +fn rollback_windows_backup(backup_path: &Path, path: &Path) -> std::io::Result<()> { + if path_entry_exists(backup_path)? && !path_entry_exists(path)? { + move_windows_file(backup_path, path)?; + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn path_entry_exists(path: &Path) -> std::io::Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +#[cfg(target_os = "windows")] +fn windows_wide_path(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + + path.as_os_str().encode_wide().chain(Some(0)).collect() +} + +#[cfg(target_os = "windows")] +fn windows_io_error(error: windows::core::Error) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::Other, error) +} + +#[cfg(unix)] +fn sync_pin_parent(path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::File::open(parent)?.sync_all()?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn sync_pin_parent(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestDir(std::path::PathBuf); + + impl TestDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "openless-pin-{label}-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&path).expect("create test directory"); + Self(path) + } + + fn pin_path(&self) -> std::path::PathBuf { + self.0.join("remote-input-pin.txt") + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn load_or_create_test_pin(path: &Path) -> std::io::Result { + load_or_create_pin_at_path(path, || "654321".to_string()) + } + + #[test] + fn atomic_replacement_never_leaves_partial_or_temporary_pin_files() { + let root = TestDir::new("replace"); + let path = root.pin_path(); + + persist_pin_atomically(&path, "123456").expect("persist initial PIN"); + persist_pin_atomically(&path, "654321").expect("replace PIN"); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), "654321"); + assert_eq!(directory_entry_names(&root), ["remote-input-pin.txt"]); + } + + #[test] + fn replacement_failure_preserves_old_pin_and_cleans_temporary_file() { + let root = TestDir::new("replace-failure"); + let path = root.pin_path(); + persist_pin_atomically(&path, "123456").expect("persist initial PIN"); + + let error = persist_pin_atomically_with(&path, "654321", |_temp_path, _path| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected replacement failure", + )) + }) + .expect_err("injected replacement must fail"); + + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "123456"); + assert_eq!(directory_entry_names(&root), ["remote-input-pin.txt"]); + } + + fn directory_entry_names(root: &TestDir) -> Vec { + let mut names = std::fs::read_dir(&root.0) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + names.sort(); + names + } + + #[test] + fn invalid_or_partial_pin_is_replaced_with_a_complete_six_digit_pin() { + for invalid in ["", "12", "12345x", "1234567"] { + let root = TestDir::new("invalid"); + let path = root.pin_path(); + std::fs::write(&path, invalid).unwrap(); + + let pin = load_or_create_test_pin(&path).unwrap(); + + assert_eq!(pin.len(), 6); + assert!(pin.bytes().all(|byte| byte.is_ascii_digit())); + assert_eq!(std::fs::read_to_string(&path).unwrap(), pin); + } + } + + #[cfg(unix)] + #[test] + fn temporary_pin_file_is_owner_only_before_replacement() { + use std::os::unix::fs::PermissionsExt; + + let root = TestDir::new("new-mode"); + let path = root.pin_path(); + persist_pin_atomically_with(&path, "123456", |temp_path, destination| { + assert_eq!( + std::fs::metadata(temp_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + std::fs::rename(temp_path, destination) + }) + .unwrap(); + } + + #[cfg(unix)] + #[test] + fn loading_valid_legacy_pin_repairs_permissive_mode_on_open_fd() { + use std::os::unix::fs::PermissionsExt; + + let root = TestDir::new("repair-mode"); + let path = root.pin_path(); + std::fs::write(&path, "123456").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + assert_eq!( + read_valid_persisted_pin(&path).unwrap().as_deref(), + Some("123456") + ); + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[cfg(unix)] + #[test] + fn path_swap_after_open_never_repairs_replacement_target() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let root = TestDir::new("path-swap"); + let path = root.pin_path(); + std::fs::write(&path, "123456").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let opened = open_pin_file(&path).unwrap(); + + let original = root.0.join("opened-original.txt"); + std::fs::rename(&path, &original).unwrap(); + let target = root.0.join("replacement-target.txt"); + std::fs::write(&target, "654321").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap(); + symlink(&target, &path).unwrap(); + + assert_eq!( + read_valid_pin_from_file(opened).unwrap().as_deref(), + Some("123456") + ); + assert_eq!( + std::fs::metadata(&original).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o644 + ); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "654321"); + } + + #[cfg(unix)] + #[test] + fn fifo_pin_path_is_rejected_without_blocking() { + use std::os::unix::ffi::OsStrExt; + use std::time::Duration; + + let root = TestDir::new("fifo"); + let path = root.pin_path(); + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }, 0); + + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(read_valid_persisted_pin(&path).is_err()); + }); + + assert!(rx + .recv_timeout(Duration::from_millis(250)) + .expect("FIFO validation must not block")); + } + + #[cfg(unix)] + #[test] + fn device_pin_path_is_rejected_before_reading() { + assert!(read_valid_persisted_pin(Path::new("/dev/null")).is_err()); + } + + #[test] + fn oversized_pin_file_is_rejected_before_unbounded_reading() { + let root = TestDir::new("oversized"); + let path = root.pin_path(); + let oversized = format!("123456{}", " ".repeat(4096)); + std::fs::write(&path, &oversized).unwrap(); + + assert!(read_valid_persisted_pin(&path).is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), oversized); + } + + #[test] + fn directory_pin_path_is_rejected_without_replacement() { + let root = TestDir::new("directory"); + let path = root.pin_path(); + std::fs::create_dir(&path).unwrap(); + + assert!(load_or_create_test_pin(&path).is_err()); + assert!(path.is_dir()); + } + + #[cfg(unix)] + #[test] + fn symlink_to_fifo_is_rejected_without_opening_the_target() { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::symlink; + use std::time::Duration; + + let root = TestDir::new("symlink-fifo"); + let fifo = root.0.join("target-fifo"); + let c_fifo = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(c_fifo.as_ptr(), 0o600) }, 0); + let path = root.pin_path(); + symlink(&fifo, &path).unwrap(); + + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(read_valid_persisted_pin(&path).is_err()); + }); + + assert!(rx + .recv_timeout(Duration::from_millis(250)) + .expect("symlink validation must not open a blocking target")); + } + + #[cfg(unix)] + #[test] + fn symlink_pin_path_is_rejected_without_touching_its_target() { + use std::os::unix::fs::symlink; + + let root = TestDir::new("symlink"); + let path = root.pin_path(); + let target = root.0.join("outside-target.txt"); + std::fs::write(&target, "123456").unwrap(); + symlink(&target, &path).unwrap(); + + assert!(load_or_create_test_pin(&path).is_err()); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "123456"); + assert!(std::fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink()); + } + + #[cfg(target_os = "windows")] + #[test] + fn symlink_pin_path_is_rejected_without_touching_its_target() { + use std::os::windows::fs::symlink_file; + + let root = TestDir::new("symlink"); + let path = root.pin_path(); + let target = root.0.join("outside-target.txt"); + std::fs::write(&target, "123456").unwrap(); + symlink_file(&target, &path).unwrap(); + + assert!(load_or_create_test_pin(&path).is_err()); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "123456"); + assert!(std::fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink()); + } + + #[test] + fn hard_link_pin_path_is_rejected_without_touching_the_other_link() { + let root = TestDir::new("hard-link"); + let path = root.pin_path(); + let other_link = root.0.join("other-link.txt"); + std::fs::write(&other_link, "123456").unwrap(); + std::fs::hard_link(&other_link, &path).unwrap(); + + assert!(load_or_create_test_pin(&path).is_err()); + assert_eq!(std::fs::read_to_string(&other_link).unwrap(), "123456"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "123456"); + } +}