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:
1 parent
472b914985
commit
e3e63cad04
11 files changed
+778
-37
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -1095,6 +1095,7 @@ dependencies = [
|
||||
"cpal",
|
||||
"enigo",
|
||||
"global-hotkey",
|
||||
"libc",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn list_local_ips() -> Vec<String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> String {
|
||||
pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> Result<String, String> {
|
||||
coord.regenerate_remote_pin()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
pub fn new() -> Self {
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1554,14 +1567,23 @@ impl Coordinator {
|
||||
}
|
||||
|
||||
#[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();
|
||||
*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::<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]
|
||||
fn split_polish_translate_parses_both_sections() {
|
||||
let out = format!(
|
||||
|
||||
@@ -131,30 +131,28 @@ fn pin_path(app: &AppHandle) -> Option<std::path::PathBuf> {
|
||||
.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<String> {
|
||||
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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user