Browser bridge in the core (shared by the Mac and desktop apps); webpki security update
ci / test (push) Canceled after 0s
ci / wasm (push) Canceled after 0s
ci / deny (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s

- crates/kv-ipc: native-messaging framing and the local IPC socket/pipe (moved from kestrove-desktop),
  plus Listener::bind_in/connect_in and, on macOS, the App Group socket for the kestrove-nmh bundled
  in Kestrove.app (Contents/Resources/kestrove-app-group).
- crates/kv-nmh: the native-messaging host, with an embedded Info.plist on macOS so it can run
  sandboxed (KV_NMH_BUNDLE_ID).
- kv-ffi feature browser-bridge: broker, pairing channel and word list (moved from kestrove-desktop)
  and BrowserBridge for Swift. build-apple.sh enables it and builds a universal kestrove-nmh.
- RUSTSEC-2026-0381/0382: rustls-webpki 0.103.16, webpki-roots 1.0.10 (distrusted emSign roots).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-21 10:02:45 +09:00
co-authored by Claude Opus 5.5
parent d45033f424
commit 9044b00a2b
17 changed files with 2079 additions and 18 deletions
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "kv-ipc"
description = "Local IPC and native-messaging framing between kestrove-nmh and the app that serves the browser extension."
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde_json.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_Pipes",
"Win32_System_Threading",
] }
[dev-dependencies]
tempfile.workspace = true
+68
View File
@@ -0,0 +1,68 @@
//! Length-prefixed JSON frames, the native-messaging wire format (a u32 length in native byte
//! order, then UTF-8 JSON). The same framing is used on the local IPC channel between
//! `kestrove-nmh` and the app, so the host can relay frames without parsing them.
use std::io::{self, Read, Write};
use serde_json::Value;
/// Chrome caps host → browser messages at 1 MiB; browser → host at 4 GiB, but we never need more than this.
pub const MAX_FRAME: usize = 1024 * 1024;
/// Reads one raw frame. `Ok(None)` on a clean EOF before the length prefix.
pub fn read_frame(r: &mut impl Read) -> io::Result<Option<Vec<u8>>> {
let mut len = [0u8; 4];
match r.read_exact(&mut len) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = u32::from_ne_bytes(len) as usize;
if len > MAX_FRAME {
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
}
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
Ok(Some(buf))
}
pub fn write_frame(w: &mut impl Write, bytes: &[u8]) -> io::Result<()> {
if bytes.len() > MAX_FRAME {
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
}
w.write_all(&(bytes.len() as u32).to_ne_bytes())?;
w.write_all(bytes)?;
w.flush()
}
pub fn read_json(r: &mut impl Read) -> io::Result<Option<Value>> {
match read_frame(r)? {
Some(b) => serde_json::from_slice(&b).map(Some).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)),
None => Ok(None),
}
}
pub fn write_json(w: &mut impl Write, v: &Value) -> io::Result<()> {
write_frame(w, &serde_json::to_vec(v)?)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn round_trip_and_eof() {
let mut buf = Vec::new();
write_json(&mut buf, &json!({"t": "ping"})).unwrap();
let mut r = buf.as_slice();
assert_eq!(read_json(&mut r).unwrap(), Some(json!({"t": "ping"})));
assert_eq!(read_json(&mut r).unwrap(), None);
}
#[test]
fn rejects_oversized_length() {
let bytes = ((MAX_FRAME + 1) as u32).to_ne_bytes();
assert!(read_frame(&mut bytes.as_slice()).is_err());
}
}
+533
View File
@@ -0,0 +1,533 @@
//! Local IPC between `kestrove-nmh` (started by the browser) and the running app.
//!
//! * Unix: a socket in a 0700 per-user directory, socket mode 0600, and a peer-credential check
//! on both ends (`SO_PEERCRED` on Linux, `getpeereid` on macOS/BSD). Linux uses
//! `$XDG_RUNTIME_DIR/kestrove/nmh.sock`. On macOS the sandboxed Kestrove app serves the socket from
//! its App Group container (`~/Library/Group Containers/<group>/ipc/nmh.sock`); the `kestrove-nmh`
//! bundled in Kestrove.app finds the group in `Contents/Resources/kestrove-app-group` (sealed by the
//! app's signature). A host outside an app bundle (the desktop app, development) uses the per-user
//! temp dir from `confstr(_CS_DARWIN_USER_TEMP_DIR)`.
//! * Windows: the named pipe `\\.\pipe\kestrove-nmh-<user>`, created first-instance-only (no
//! squatting), local clients only, with a DACL for the current user, plus a client token check.
//!
//! The channel carries only already-encrypted pairing frames (see `channel`), so this layer keeps
//! other users and remote machines out; the pairing key keeps other same-user processes out.
//! Traffic is strictly request → response: no concurrent read and write on one connection
//! (synchronous Windows pipe handles serialize I/O, so that would deadlock).
use std::io::{self, Read, Write};
use std::path::PathBuf;
/// Overrides the IPC directory (Unix) or pipe name suffix (Windows). Tests use it to run an
/// isolated app; the browser passes its environment through to the host, so a browser started
/// with it set reaches that app.
pub const ENV_IPC_DIR: &str = "KESTROVE_IPC_DIR";
pub use imp::{connect, connect_in, Conn, Listener};
/// Where the socket lives (Unix) or a display string for the pipe (Windows).
pub fn endpoint() -> io::Result<PathBuf> {
imp::endpoint()
}
/// Name of the file next to a bundled `kestrove-nmh` (`Contents/Resources/`) that names the Mac app's App Group.
pub const APP_GROUP_FILE: &str = "kestrove-app-group";
impl Read for Conn {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.0.read(buf)
}
}
impl Write for Conn {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.flush()
}
}
#[cfg(unix)]
mod imp {
use std::fs;
use std::io;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
use std::os::unix::io::AsRawFd;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::time::Duration;
use super::ENV_IPC_DIR;
pub struct Conn(pub(super) UnixStream);
/// macOS limits `sun_path` to 104 bytes.
const MAX_SOCKET_PATH: usize = 103;
pub struct Listener {
inner: UnixListener,
path: PathBuf,
}
fn uid() -> u32 {
// SAFETY: geteuid has no preconditions and cannot fail.
unsafe { libc::geteuid() }
}
fn base_dir() -> io::Result<PathBuf> {
if let Some(d) = std::env::var_os(ENV_IPC_DIR).filter(|d| !d.is_empty()) {
return Ok(PathBuf::from(d));
}
#[cfg(target_os = "macos")]
if let Some(d) = bundled_app_group_dir() {
return Ok(d);
}
platform_dir()
}
/// The Mac app's socket directory, when this executable is the `kestrove-nmh` inside Kestrove.app.
#[cfg(target_os = "macos")]
fn bundled_app_group_dir() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let marker = exe.parent()?.parent()?.join("Resources").join(super::APP_GROUP_FILE);
let group = fs::read_to_string(marker).ok()?;
let group = group.trim();
let valid = !group.is_empty()
&& group.len() <= 128
&& !group.starts_with('.')
&& group.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-');
valid.then(|| home_dir().map(|h| h.join("Library/Group Containers").join(group).join("ipc")))?
}
/// The real home directory (not a sandbox container's), from the password database.
#[cfg(target_os = "macos")]
fn home_dir() -> Option<PathBuf> {
use std::ffi::{CStr, OsStr};
use std::os::unix::ffi::OsStrExt;
let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
let mut buf = vec![0 as libc::c_char; 4096];
let mut out: *mut libc::passwd = std::ptr::null_mut();
// SAFETY: all pointers are valid for the sizes passed; `out` is null or points at `pwd`.
let rc = unsafe { libc::getpwuid_r(uid(), &mut pwd, buf.as_mut_ptr(), buf.len(), &mut out) };
if rc != 0 || out.is_null() || pwd.pw_dir.is_null() {
return None;
}
// SAFETY: pw_dir points into `buf`, NUL-terminated by getpwuid_r.
let dir = unsafe { CStr::from_ptr(pwd.pw_dir) };
Some(PathBuf::from(OsStr::from_bytes(dir.to_bytes())))
}
#[cfg(target_os = "macos")]
fn platform_dir() -> io::Result<PathBuf> {
use std::ffi::{CStr, OsStr};
use std::os::unix::ffi::OsStrExt;
let mut buf = vec![0u8; 1024];
// SAFETY: buf is valid for buf.len() bytes; confstr NUL-terminates within that length.
let n = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, buf.as_mut_ptr().cast(), buf.len()) };
if n == 0 || n > buf.len() {
return Err(io::Error::last_os_error());
}
let s = CStr::from_bytes_until_nul(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(PathBuf::from(OsStr::from_bytes(s.to_bytes())).join("kestrove"))
}
#[cfg(not(target_os = "macos"))]
fn platform_dir() -> io::Result<PathBuf> {
match std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from) {
Some(d) if d.is_absolute() => Ok(d.join("kestrove")),
// No runtime dir (e.g. no systemd session): a per-uid dir in /tmp, ownership checked in secure_dir.
_ => Ok(PathBuf::from(format!("/tmp/kestrove-{}", uid()))),
}
}
pub fn endpoint() -> io::Result<PathBuf> {
Ok(base_dir()?.join("nmh.sock"))
}
fn socket_in(dir: &std::path::Path) -> io::Result<PathBuf> {
let path = dir.join("nmh.sock");
if path.as_os_str().len() > MAX_SOCKET_PATH {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "IPC socket path is too long"));
}
Ok(path)
}
/// Creates the directory 0700, or checks that an existing one is ours and not group/world accessible.
fn secure_dir(dir: &std::path::Path) -> io::Result<()> {
match fs::DirBuilder::new().recursive(true).mode(0o700).create(dir) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
let meta = fs::symlink_metadata(dir)?;
if !meta.is_dir() || meta.uid() != uid() {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC directory is not owned by this user"));
}
if meta.mode() & 0o077 != 0 {
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "android"))]
pub(super) fn peer_uid(s: &UnixStream) -> io::Result<u32> {
let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 };
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: cred and len are valid out-pointers of the sizes passed.
let rc = unsafe {
libc::getsockopt(
s.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
(&mut cred as *mut libc::ucred).cast(),
&mut len,
)
};
if rc != 0 {
return Err(io::Error::last_os_error());
}
Ok(cred.uid)
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub(super) fn peer_uid(s: &UnixStream) -> io::Result<u32> {
let (mut uid, mut gid) = (0, 0);
// SAFETY: uid and gid are valid out-pointers.
if unsafe { libc::getpeereid(s.as_raw_fd(), &mut uid, &mut gid) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(uid)
}
fn check_peer(s: &UnixStream) -> io::Result<()> {
if peer_uid(s)? != uid() {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC peer is a different user"));
}
Ok(())
}
impl Listener {
/// Binds the socket. Fails with `AddrInUse` if another app instance is already serving it.
pub fn bind() -> io::Result<Self> {
Self::bind_in(&base_dir()?)
}
/// Binds `<dir>/nmh.sock`: the Mac app passes its App Group container's `ipc` directory.
pub fn bind_in(dir: &std::path::Path) -> io::Result<Self> {
let path = socket_in(dir)?;
secure_dir(dir)?;
if path.exists() {
if UnixStream::connect(&path).is_ok() {
return Err(io::Error::new(io::ErrorKind::AddrInUse, "Kestrove is already running"));
}
fs::remove_file(&path)?; // stale socket from a crashed instance
}
let inner = UnixListener::bind(&path)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
Ok(Self { inner, path })
}
/// Blocks for the next same-user client. Other users' connections are dropped and skipped.
pub fn accept(&self) -> io::Result<Conn> {
loop {
let (s, _) = self.inner.accept()?;
if check_peer(&s).is_ok() {
// A stuck peer must not pin a thread forever.
s.set_read_timeout(Some(Duration::from_secs(600)))?;
return Ok(Conn(s));
}
}
}
}
impl Drop for Listener {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
pub fn connect() -> io::Result<Conn> {
connect_in(&base_dir()?)
}
pub fn connect_in(dir: &std::path::Path) -> io::Result<Conn> {
let s = UnixStream::connect(socket_in(dir)?)?;
check_peer(&s)?; // don't talk to a socket another user planted
Ok(Conn(s))
}
}
#[cfg(windows)]
mod imp {
use std::fs::File;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::path::PathBuf;
use std::ptr;
use windows_sys::Win32::Foundation::{
CloseHandle, GetLastError, LocalFree, ERROR_PIPE_CONNECTED, GENERIC_READ, GENERIC_WRITE, HANDLE,
INVALID_HANDLE_VALUE,
};
use windows_sys::Win32::Security::Authorization::{
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows_sys::Win32::Security::{
EqualSid, GetTokenInformation, TokenUser, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER,
};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAG_FIRST_PIPE_INSTANCE, OPEN_EXISTING, PIPE_ACCESS_DUPLEX,
};
use windows_sys::Win32::System::Pipes::{
ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeClientProcessId, GetNamedPipeServerProcessId,
PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
};
use windows_sys::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION,
};
use super::ENV_IPC_DIR;
pub struct Conn(pub(super) File);
pub struct Listener {
name: Vec<u16>,
sddl: Vec<u16>,
first: std::sync::atomic::AtomicBool,
}
fn wide(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect()
}
fn pipe_name() -> String {
let suffix = std::env::var(ENV_IPC_DIR)
.ok()
.filter(|s| !s.is_empty())
.or_else(|| std::env::var("USERNAME").ok())
.unwrap_or_else(|| "user".into());
let clean: String = suffix.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
format!(r"\\.\pipe\kestrove-nmh-{clean}")
}
pub fn endpoint() -> io::Result<PathBuf> {
Ok(PathBuf::from(pipe_name()))
}
/// The TOKEN_USER buffer of a process (the SID pointer points into it).
fn token_user(process: HANDLE) -> io::Result<Vec<u8>> {
let mut token: HANDLE = ptr::null_mut();
// SAFETY: valid process handle and out-pointer.
if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: we own the token handle from here on.
let token = unsafe { OwnedHandle::from_raw_handle(token) };
let mut len = 0u32;
// SAFETY: a size query with a null buffer is the documented pattern.
unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, ptr::null_mut(), 0, &mut len) };
let mut buf = vec![0u8; len as usize];
// SAFETY: buf has len bytes.
if unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, buf.as_mut_ptr().cast(), len, &mut len) } == 0
{
return Err(io::Error::last_os_error());
}
Ok(buf)
}
fn sid_of(buf: &[u8]) -> *mut core::ffi::c_void {
// SAFETY: buf came from GetTokenInformation(TokenUser) and starts with a TOKEN_USER.
unsafe { (*(buf.as_ptr() as *const TOKEN_USER)).User.Sid }
}
fn same_user_as_process(pid: u32) -> io::Result<()> {
// SAFETY: plain Win32 calls; every handle is closed before returning.
unsafe {
let ours = token_user(GetCurrentProcess())?;
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if h.is_null() {
return Err(io::Error::last_os_error());
}
let theirs = token_user(h);
CloseHandle(h);
let theirs = theirs?;
if EqualSid(sid_of(&ours), sid_of(&theirs)) == 0 {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC peer is a different user"));
}
}
Ok(())
}
/// SDDL granting full access to the current user only (no Everyone read like the default DACL).
fn user_only_sddl() -> io::Result<Vec<u16>> {
// SAFETY: ConvertSidToStringSidW allocates with LocalAlloc; we free it after copying.
unsafe {
let ours = token_user(GetCurrentProcess())?;
let mut s: *mut u16 = ptr::null_mut();
if ConvertSidToStringSidW(sid_of(&ours), &mut s) == 0 {
return Err(io::Error::last_os_error());
}
let mut n = 0;
while *s.add(n) != 0 {
n += 1;
}
let sid = String::from_utf16_lossy(std::slice::from_raw_parts(s, n));
LocalFree(s.cast());
Ok(wide(&format!("D:P(A;;GA;;;{sid})")))
}
}
impl Listener {
pub fn bind() -> io::Result<Self> {
let me = Self {
name: wide(&pipe_name()),
sddl: user_only_sddl()?,
first: std::sync::atomic::AtomicBool::new(true),
};
Ok(me)
}
fn create_instance(&self) -> io::Result<HANDLE> {
let first = self.first.swap(false, std::sync::atomic::Ordering::SeqCst);
let mut sd: PSECURITY_DESCRIPTOR = ptr::null_mut();
// SAFETY: sddl is NUL-terminated; sd is freed with LocalFree below.
unsafe {
if ConvertStringSecurityDescriptorToSecurityDescriptorW(
self.sddl.as_ptr(),
SDDL_REVISION_1,
&mut sd,
ptr::null_mut(),
) == 0
{
return Err(io::Error::last_os_error());
}
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd,
bInheritHandle: 0,
};
let h = CreateNamedPipeW(
self.name.as_ptr(),
PIPE_ACCESS_DUPLEX | if first { FILE_FLAG_FIRST_PIPE_INSTANCE } else { 0 },
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
PIPE_UNLIMITED_INSTANCES,
64 * 1024,
64 * 1024,
0,
&sa,
);
LocalFree(sd);
if h == INVALID_HANDLE_VALUE {
let e = io::Error::last_os_error();
return Err(if first { io::Error::new(io::ErrorKind::AddrInUse, e) } else { e });
}
Ok(h)
}
}
/// Named pipes have no directory: the same as [`Listener::bind`].
pub fn bind_in(_dir: &std::path::Path) -> io::Result<Self> {
Self::bind()
}
pub fn accept(&self) -> io::Result<Conn> {
loop {
let h = self.create_instance()?;
// SAFETY: h is a fresh pipe handle owned by `file` from here on.
let file = unsafe { File::from_raw_handle(h) };
// SAFETY: blocking connect on our own pipe handle.
let ok = unsafe { ConnectNamedPipe(h, ptr::null_mut()) } != 0
|| unsafe { GetLastError() } == ERROR_PIPE_CONNECTED;
if !ok {
continue;
}
let mut pid = 0u32;
// SAFETY: valid handle and out-pointer.
if unsafe { GetNamedPipeClientProcessId(h, &mut pid) } != 0 && same_user_as_process(pid).is_ok() {
return Ok(Conn(file));
}
}
}
}
/// Named pipes have no directory: the same as [`connect`].
pub fn connect_in(_dir: &std::path::Path) -> io::Result<Conn> {
connect()
}
pub fn connect() -> io::Result<Conn> {
let name = wide(&pipe_name());
// SAFETY: name is NUL-terminated; the handle is owned by File on success.
let h = unsafe {
CreateFileW(name.as_ptr(), GENERIC_READ | GENERIC_WRITE, 0, ptr::null(), OPEN_EXISTING, 0, ptr::null_mut())
};
if h == INVALID_HANDLE_VALUE {
return Err(io::Error::last_os_error());
}
// SAFETY: h is a valid, owned handle.
let file = unsafe { File::from_raw_handle(h) };
let mut pid = 0u32;
// SAFETY: valid handle and out-pointer.
if unsafe { GetNamedPipeServerProcessId(h, &mut pid) } == 0 {
return Err(io::Error::last_os_error());
}
same_user_as_process(pid)?;
Ok(Conn(file))
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::framing::{read_json, write_json};
use serde_json::json;
use std::os::unix::fs::PermissionsExt;
#[test]
fn socket_is_private_and_round_trips() {
let dir = tempfile::tempdir().unwrap();
let ipc_dir = dir.path().join("ipc");
// SAFETY: tests in this module are the only ones touching this variable.
unsafe { std::env::set_var(ENV_IPC_DIR, &ipc_dir) };
let l = Listener::bind().unwrap();
let path = endpoint().unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(std::fs::metadata(&ipc_dir).unwrap().permissions().mode() & 0o777, 0o700);
let t = std::thread::spawn(move || {
let mut c = l.accept().unwrap();
let v = read_json(&mut c).unwrap().unwrap();
write_json(&mut c, &json!({"echo": v})).unwrap();
l
});
let mut c = connect().unwrap();
write_json(&mut c, &json!({"t": "ping"})).unwrap();
assert_eq!(read_json(&mut c).unwrap(), Some(json!({"echo": {"t": "ping"}})));
let l = t.join().unwrap();
// A second instance sees the live socket and refuses to steal it.
assert_eq!(Listener::bind().err().map(|e| e.kind()), Some(io::ErrorKind::AddrInUse));
drop(l);
assert!(!path.exists(), "socket removed on shutdown");
}
#[test]
fn explicit_directory() {
let dir = tempfile::tempdir().unwrap();
let ipc_dir = dir.path().join("ipc");
let l = Listener::bind_in(&ipc_dir).unwrap();
assert!(ipc_dir.join("nmh.sock").exists());
let t = std::thread::spawn(move || {
let mut c = l.accept().unwrap();
let v = read_json(&mut c).unwrap().unwrap();
write_json(&mut c, &v).unwrap();
});
let mut c = connect_in(&ipc_dir).unwrap();
write_json(&mut c, &json!({"t": "ping"})).unwrap();
assert_eq!(read_json(&mut c).unwrap(), Some(json!({"t": "ping"})));
t.join().unwrap();
let long = dir.path().join("x".repeat(120));
assert_eq!(Listener::bind_in(&long).err().map(|e| e.kind()), Some(io::ErrorKind::InvalidInput));
}
}
+6
View File
@@ -0,0 +1,6 @@
//! The local channel between `kestrove-nmh` (started by the browser) and the app that serves the extension:
//! the Kestrove app on macOS, the desktop app on Windows and Linux (kestrove-spec/docs/05).
pub mod framing;
pub mod ipc;
pub mod private_file;
+27
View File
@@ -0,0 +1,27 @@
//! Small files only this user may read (0600 on Unix; on Windows the per-user app-data ACL).
use std::fs;
use std::io::{self, Write};
use std::path::Path;
/// Writes atomically (temp file + rename) with owner-only permissions.
pub fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
let tmp = path.with_extension("tmp");
let mut opts = fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
let mut f = opts.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
fs::rename(tmp, path)
}
/// `Ok(None)` when the file doesn't exist.
pub fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(b) => Ok(Some(b)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}