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
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user