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 <sim@local>
This commit is contained in:
TRIPandsim authored and GitHub committed 2026-07-15 14:46:06 +08:00
1 parent 472b914985
commit e3e63cad04
11 files changed
+778 -37

No files matched your search

+3
View File
@@ -158,6 +158,9 @@ jobs:
- name: Check Windows UI configuration contract - name: Check Windows UI configuration contract
run: npm run check:windows-ui-config 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) - name: Build frontend (tsc + vite)
run: npm run build run: npm run build
+1
View File
@@ -22,6 +22,7 @@
"check:hotkey-side-modifiers": "tsx src/lib/hotkeySideModifiers.test.ts", "check:hotkey-side-modifiers": "tsx src/lib/hotkeySideModifiers.test.ts",
"check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs", "check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs",
"check:windows-ui-config": "node scripts/windows-ui-config.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" "check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs"
}, },
"dependencies": { "dependencies": {
@@ -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<String, String>/, "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<String, String>/, "Tauri command must reject on reset failure")
console.log("PIN persistence security contract passed")
+6 -2
View File
@@ -88,6 +88,11 @@ version = "3.6.3"
default-features = false default-features = false
features = ["windows-native"] 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] [target.'cfg(target_os = "linux")'.dependencies]
dbus = "0.9" dbus = "0.9"
gtk = "0.18" gtk = "0.18"
@@ -113,8 +118,6 @@ coreaudio-sys = "0.2"
objc2 = { version = "0.5", features = ["exception"] } objc2 = { version = "0.5", features = ["exception"] }
objc2-foundation = "0.2" objc2-foundation = "0.2"
objc2-app-kit = "0.2" objc2-app-kit = "0.2"
# qwen-asr 用 malloc 分配返回字符串,必须用 C `free()` 释放。
libc = "0.2"
# 把胶囊窗口转成「非激活 NSPanel」,使其能叠在别的 app 的全屏 Space 之上。 # 把胶囊窗口转成「非激活 NSPanel」,使其能叠在别的 app 的全屏 Space 之上。
# 普通 NSWindow 即便设了 collectionBehavior 也做不到(tauri#9556 / #11488)—— 必须是 # 普通 NSWindow 即便设了 collectionBehavior 也做不到(tauri#9556 / #11488)—— 必须是
# NSPanel。该插件做窗口 NSWindow→NSPanel subclass 转换;仅 macOS。 # NSPanel。该插件做窗口 NSWindow→NSPanel subclass 转换;仅 macOS。
@@ -131,6 +134,7 @@ windows = { version = "0.58", features = [
"Win32_Graphics_Gdi", "Win32_Graphics_Gdi",
"Win32_Media_Audio", "Win32_Media_Audio",
"Win32_Media_Audio_Endpoints", "Win32_Media_Audio_Endpoints",
"Win32_Storage_FileSystem",
"Win32_System_Com", "Win32_System_Com",
"Win32_System_Ole", "Win32_System_Ole",
"Win32_System_Registry", "Win32_System_Registry",
+1
View File
@@ -1095,6 +1095,7 @@ dependencies = [
"cpal", "cpal",
"enigo", "enigo",
"global-hotkey", "global-hotkey",
"libc",
"log", "log",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
@@ -14,6 +14,7 @@ arboard = "3"
cpal = "0.15" cpal = "0.15"
enigo = "0.2" enigo = "0.2"
global-hotkey = "0.6" global-hotkey = "0.6"
libc = "0.2"
log = "0.4" log = "0.4"
once_cell = "1" once_cell = "1"
parking_lot = "0.12" parking_lot = "0.12"
@@ -26,6 +27,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.58", features = [ windows = { version = "0.58", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_Storage_FileSystem",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging", "Win32_UI_WindowsAndMessaging",
@@ -56,6 +56,8 @@ mod hotkey;
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
#[path = "../../src/insertion.rs"] #[path = "../../src/insertion.rs"]
mod insertion; mod insertion;
#[path = "../../src/remote_server/pin_persistence.rs"]
mod pin_persistence;
#[path = "../../src/recorder.rs"] #[path = "../../src/recorder.rs"]
mod recorder; mod recorder;
#[path = "../../src/shortcut_binding.rs"] #[path = "../../src/shortcut_binding.rs"]
@@ -23,7 +23,7 @@ pub fn list_local_ips() -> Vec<String> {
} }
#[tauri::command] #[tauri::command]
pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> String { pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> Result<String, String> {
coord.regenerate_remote_pin() coord.regenerate_remote_pin()
} }
+80 -12
View File
@@ -574,6 +574,19 @@ impl Drop for CancellableRetranscribeGuard {
} }
} }
#[cfg(not(mobile))]
fn persist_and_commit_remote_pin(
slot: &Mutex<Option<String>>,
pin: String,
persist: impl FnOnce(&str) -> Result<(), String>,
refresh: impl FnOnce(),
) -> Result<String, String> {
persist(&pin)?;
*slot.lock() = Some(pin.clone());
refresh();
Ok(pin)
}
impl Coordinator { impl Coordinator {
pub fn new() -> Self { pub fn new() -> Self {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -1554,14 +1567,23 @@ impl Coordinator {
} }
#[cfg(not(mobile))] #[cfg(not(mobile))]
pub fn regenerate_remote_pin(self: &Arc<Self>) -> String { pub fn regenerate_remote_pin(self: &Arc<Self>) -> Result<String, String> {
let pin = crate::remote_server::generate_pin(); let pin = crate::remote_server::generate_pin();
*self.inner.remote_pin.lock() = Some(pin.clone()); let app = self
if let Some(app) = self.inner.app.lock().clone() { .inner
crate::remote_server::save_pin(&app, &pin); .app
} .lock()
self.refresh_remote_server(); .clone()
pin .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))] #[cfg(not(mobile))]
@@ -1604,12 +1626,24 @@ impl Coordinator {
let Some(app) = app else { let Some(app) = app else {
return; return;
}; };
let pin = { let pin = if let Some(pin) = coord.inner.remote_pin.lock().clone() {
let mut guard = coord.inner.remote_pin.lock(); pin
if guard.is_none() { } else {
*guard = Some(crate::remote_server::load_or_create_pin(&app)); 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; let port = prefs.remote_input_port;
match crate::remote_server::start(crate::remote_server::RemoteServerConfig { match crate::remote_server::start(crate::remote_server::RemoteServerConfig {
@@ -2469,6 +2503,40 @@ mod tests {
Uuid::from_u128(n) 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::<String>);
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] #[test]
fn split_polish_translate_parses_both_sections() { fn split_polish_translate_parses_both_sections() {
let out = format!( let out = format!(
@@ -131,30 +131,28 @@ fn pin_path(app: &AppHandle) -> Option<std::path::PathBuf> {
.map(|d| d.join("remote-input-pin.txt")) .map(|d| d.join("remote-input-pin.txt"))
} }
/// 读持久化的配对码;没有 / 无效则新生成并写盘。让配对码跨重启稳定 —— 否则每次启动 mod pin_persistence;
/// 都重新随机一个,用户得反复回来找新码("配对码错误"的根因)。
pub fn load_or_create_pin(app: &AppHandle) -> String { /// 读持久化的配对码;没有 / 无效则新生成并写盘。让配对码跨重启稳定。
if let Some(p) = pin_path(app) { pub fn load_or_create_pin(app: &AppHandle) -> std::io::Result<String> {
if let Ok(s) = std::fs::read_to_string(&p) { let path = pin_path(app).ok_or_else(|| {
let s = s.trim(); std::io::Error::new(
if s.len() == 6 && s.bytes().all(|b| b.is_ascii_digit()) { std::io::ErrorKind::NotFound,
return s.to_string(); "OpenLess config directory is unavailable",
} )
} })?;
} pin_persistence::load_or_create_pin_at_path(&path, generate_pin)
let pin = generate_pin();
save_pin(app, &pin);
pin
} }
/// 写配对码到磁盘(用户点"重置配对码"时覆盖)。 /// 原子写配对码到磁盘;只有成功后调用方才能提交内存状态。
pub fn save_pin(app: &AppHandle, pin: &str) { pub fn save_pin(app: &AppHandle, pin: &str) -> std::io::Result<()> {
if let Some(p) = pin_path(app) { let path = pin_path(app).ok_or_else(|| {
if let Some(dir) = p.parent() { std::io::Error::new(
let _ = std::fs::create_dir_all(dir); std::io::ErrorKind::NotFound,
} "OpenLess config directory is unavailable",
let _ = std::fs::write(&p, pin); )
} })?;
pin_persistence::persist_pin_atomically(&path, pin)
} }
fn is_private_lan(ip: &Ipv4Addr) -> bool { fn is_private_lan(ip: &Ipv4Addr) -> bool {
@@ -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<String> {
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<Option<String>> {
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<std::fs::File> {
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<std::fs::File> {
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> {
std::fs::File::open(path)
}
fn read_valid_pin_from_file(mut file: std::fs::File) -> std::io::Result<Option<String>> {
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<bool> {
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<bool> {
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<u16> {
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<String> {
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<String> {
let mut names = std::fs::read_dir(&root.0)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
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");
}
}